Skill: xzc13815555922/bidding-assistant (version 1.0.1)

--- name: bidding-assistant description: 自动采集盐南高新区、经开区招投标信息；当用户需要定时采集招标网站、筛选特定区域项目、生成PDF日报/月报、推送报到飞书时使用 --- # 招投标信息采集技能 ## 概述 自动化采集盐南高新区、经开区相关招投标信息，支持：多网站并行采集、智能区域筛选、数据去重存储、PDF日报/月报生成、飞书推送。 **触发场景**：用户需要监控招标信息、采集特定区域项目、生成招投标日报/推送飞书 ## 数据来源（固定排序） 按以下顺序排列，PDF所有页面均遵循此顺序： 1. 盐城市政府采购网 2. 苏服采（API直连） 3. 城南新区公共资源交易网 4. 开发区公共资源交易网 5. 盐城市大数据集团 6. 盐城市都市建设投资集团 7. 盐城市东方集团 8. 江苏世纪新城 9. 经开城发集团 10. 悦达集团 ## 核心工作流程 ### 1. 执行采集 ```bash cd /Users/ycaz/.openclaw/workspace/skills/bidding-assistant /usr/bin/python3 scripts/run_daily_collection.py --force ``` ### 2. 生成PDF月报 ```bash cd /Users/ycaz/.openclaw/workspace/skills/bidding-assistant /usr/bin/python3 -c " import sys; sys.path.insert(0, 'scripts') from pdf_generator import PDFGenerator gen = PDFGenerator() gen.generate_monthly_report(2026, 5) # 生成指定月份 " ``` ### 3. 发送飞书通知 ```bash cd /Users/ycaz/.openclaw/workspace/skills/bidding-assistant /usr/bin/python3 scripts/generate_and_send_monthly_report.py ``` ## PDF报告结构 ### 第1页：招标信息概况（日报） - 标题：招投标信息概况 - 汇总表格（5列）：网站名称 | 当日条数 | 当月条数 | 爬虫策略 | 筛选策略 - 当日总计 + 当月总计 ### 第2页：月度概况（月报） - 标题：招投标信息月度报告 - 统计范围：当月第一天 至 最后一天 - 汇总表格（6列）：网站名称 | 数据条数 | 当日数据 | 前一日数据 | 爬虫策略 | 筛选策略 - 底部求和行 - 按 SITE_ORDER 固定顺序排列 ### 第3页：月度平台明细（月报） 每个网站独立一页，表格（4列）：网站 | 日期 | 项目名称 | 链接 - 项目名称列：Paragraph 包裹，触发自动换行（wordWrap='CJK'） - 链接列：可点击超链接（`<link href="...">` XML 标签） - 整表：VALIGN=TOP，无固定行高 ## 技术要点（PDF生成） **ReportLab 表格自动换行关键规则**： 1. 表格单元格必须是 `Paragraph` 对象，普通字符串不会触发换行 2. `ParagraphStyle` 必须设置 `wordWrap='CJK'`（中文）或 `wordWrap='LTR'` 3. `TableStyle` 设为 `VALIGN='TOP'`（不能用 MIDDLE） 4. **禁止**设置固定 `ROWHEIGHT`，否则换行失效 5. 可点击链接用 `Paragraph` + `<link href="url" color="blue" underline="yes">text</link>` ## 数据库路径 - 数据库：`~/.openclaw/workspace/skills/bidding-assistant/招投标数据/history.db` - 表结构：`bidding_projects`（source_site, publish_date, project_name, detail_url） ## 区域筛选规则 - **目标区域**：盐南高新区、经开区 - **排除区域**：亭湖、盐都、响水、滨海、建湖、大丰、射阳、阜宁、东台 - 筛选基于项目标题关键词匹配 ## 飞书推送配置 - 目标群 ID：[REDACTED_SECRET] - 私发用户 ID：[REDACTED_SECRET] - 使用 IM API 上传文件（不是 Drive API） - 上传：`POST /im/v1/files` → 返回 `file_key` - 发送：`POST /im/v1/messages` → `content: {"file_key": xxx}` 详细配置见 `references/飞书企业自建应用配置指南.md` ## Cron 定时任务 Job ID：`27f7ac6354da` - 调度：每天 `0 7 * * *` - 执行：采集 + PDF生成 + 飞书群推送 - 推送目标：飞书群 [REDACTED_SECRET] ## 项目结构 ``` bidding-assistant/ ├── SKILL.md ├── references/ │ ├── 飞书企业自建应用配置指南.md │ ├── 飞书推送配置说明.md │ └── PDF报告排版说明.md # 当前PDF排版规范 └── scripts/ ├── crawler.py # 采集器基类和所有网站采集器 ├── sufu_crawler_final.py # 苏服采采集器（API直连版） ├── pdf_generator.py # PDF日报/月报生成器（核心） ├── feishu_enterprise_app.py # 飞书开放平台API客户端 ├── generate_and_send_monthly_report.py # 生成并发送月报 ├── generate_monthly_pdfs.py # 月报PDF生成入口 └── run_daily_collection.py # 每日采集任务入口 ``` ## 依赖 ``` requests>=2.28.0 beautifulsoup4>=4.11.0 lxml>=4.9.0 reportlab>=4.0.0 openpyxl>=3.1.0 apscheduler>=3.10.0 ``` ## Python 环境 **必须使用 `/usr/bin/python3`**，系统默认 `python3` 在 macOS 上指向不同路径，reportlab 等库安装在此路径下。

path: _meta.json
content: ownerId: kn7dbagdjc1k0yvtsgjx0fq3b184w2qj
slug: bidding-assistant
version: 1.0.0
publishedAt: 1777876888167

path: references/PDF显示优化说明.md
content: # PDF显示优化说明

## 优化内容

### 问题
用户反馈收到的PDF月报中：
- ❌ 项目标题显示不全（被截断）
- ❌ 链接显示不清（被截断）
- ❌ 单行显示不够，信息丢失

### 解决方案
将PDF表格从"单行显示"改为"两行显示"模式：

**优化前**：
```
| 网站 | 日期 | 标题（截断50字符） | 链接（截断30字符） |
```

**优化后**：
```
| 网站 | 日期 | 项目名称（完整显示） |
|      |      | 项目链接（完整显示） |
```

## 技术实现

### 修改位置
- 文件：`scripts/pdf_generator.py`
- 方法：`_create_monthly_platform_pages()`

### 关键改动

**1. 调整列宽**
```python
# 优化前：4列，每列宽度有限
colWidths=[4*cm, 2.5*cm, 6*cm, 2.5*cm]

# 优化后：3列，项目名称+链接合并为11cm
colWidths=[4*cm, 2.5*cm, 11*cm]
```

**2. 改为两行显示**
```python
# 每个项目占用两行
# 第一行：网站、日期、项目名称
row1 = [site, date, project_name]

# 第二行：链接（占满整行）
row2 = ['', '', detail_url]

table_data.append(row1)
table_data.append(row2)
```

**3. 样式优化**
```python
# 表头两行
table_data = [['网站', '日期', '项目名称']]
table_data.append(['', '', '项目链接'])

# 链接样式
('FONTSIZE', (2, 3), (2, -1), 8),  # 链接字体稍小
('TEXTCOLOR', (2, 3), (2, -1), colors.blue),  # 链接蓝色

# 行高调整
('ROWHEIGHT', (0, 2), (-1, -1), 25),  # 项目名称行高
('ROWHEIGHT', (0, 3), (-1, -1), 18),  # 链接行高
```

## 优化效果

### 显示效果

**优化前**：
```
| 盐城市政府采购网 | 2026-04-12 | 某某采购项目招标公告（截断...） | http://xxx.com/xxx/xxx... |
```

**优化后**：
```
| 盐城市政府采购网 | 2026-04-12 | 某某采购项目招标公告完整标题显示（自动换行） |
|                  |            | http://xxx.com/xxx/xxx/完整链接显示（自动换行） |
```

### 优势

✅ **完整显示**
- 项目名称不再截断，完整显示
- 链接不再截断，完整显示

✅ **自动换行**
- 使用 `WORDWRAP` 属性，长文本自动换行
- 不限制字符长度，根据内容自动调整

✅ **视觉优化**
- 链接使用蓝色显示，更易识别
- 链接字体稍小，层次分明
- 表格行高适中，便于阅读

✅ **空间优化**
- 将链接单独一行，充分利用空间
- 项目名称+链接合并列宽，从8.5cm增加到11cm

## 测试结果

**生成成功**：
- 文件：`招投标信息月报_2026年04月.pdf`
- 大小：31KB
- 状态：✅ 正常

**显示效果**：
- ✅ 项目标题完整显示
- ✅ 链接完整显示
- ✅ 自动换行正常
- ✅ 字体清晰

## 使用说明

### 重新生成月报

```bash
# 生成2026年4月月报
python scripts/pdf_generator.py monthly 2026 4

# 生成当前月报
python scripts/pdf_generator.py monthly

# 生成指定月份
python scripts/pdf_generator.py monthly <year> <month>
```

### 查看PDF

生成的PDF文件位于：
```
./招投标数据/daily/招投标信息月报_<年份>年<月份>月.pdf
```

## 后续优化建议

### 可选优化方向

**1. 添加二维码**
- 将长链接转换为二维码
- 节省空间，提升美观度
- 需要安装 `qrcode` 库

**2. 添加打印优化**
- 优化页眉页脚
- 添加打印水印
- 设置打印缩放比例

**3. 添加交互式链接**
- 在PDF中嵌入可点击链接
- 直接点击跳转到详情页
- 使用 `reportlab.platypus.Paragraph` 的 `<link>` 标签

**4. 添加数据筛选**
- 按区域筛选（盐南高新区、经开区）
- 按类型筛选（招标、采购、中标）
- 按金额范围筛选

### 当前实现已满足需求

✅ 项目标题完整显示
✅ 链接完整显示
✅ 自动换行
✅ 字体清晰
✅ 文件大小合理

## 总结

通过将PDF表格从"单行显示"改为"两行显示"，并优化列宽和样式，彻底解决了标题和链接显示不全的问题。

**核心改进**：
- 项目名称和链接分开两行显示
- 扩大列宽，充分利用空间
- 自动换行，不限制字符长度
- 链接蓝色显示，易于识别

**用户体验提升**：
- 信息完整，不再遗漏
- 清晰易读，层次分明
- 空间利用更合理


path: references/website-config.md
content: # 网站配置说明

## 目录
- [网站分类](#网站分类)
- [A类网站：政府采购](#a类网站政府采购)
- [B类网站：企业招标平台](#b类网站企业招标平台)
- [技术实现细节](#技术实现细节)

## 网站分类

### A类：政府采购网站（区域筛选）
此类网站需要采集后根据区域关键词（盐南高新区、经开区）进行筛选。

### B类：企业招标平台（全量采集）
此类网站全量采集所有公告，通过关键词过滤（公告、中标、结果、公示）。

## A类网站：政府采购

### 1. 盐城市政府采购网
- **网址**: `https://czj.yancheng.gov.cn/col/col2383/index.html`
- **状态**: ✅ 已实现
- **采集方式**: dataproxy接口（requests + 正则解析）
- **技术难度**: ⭐⭐ 中等
- **数据量**: 实时采集，根据日期范围动态获取
- **实现细节**:
  - 使用dataproxy接口获取JSON数据
  - URL参数: `?page=1&appid=1&webid=7&path=/&columnid={col_id}&unitid=135567&webname={encoded}&permissiontype=0`
  - 请求头: 需要特定的Referer和User-Agent
  - 数据解析: 使用正则表达式提取record元素中的字段
  - 13个子版块：采购意向、单一来源公示、资格预审和招标公告、竞争性谈判公告、竞争性磋商公告、询价公告、中标（成交）公告、终止公告、更正公告、其他公告、征集公告、入围公告、合同公告
  - 区域筛选: 匹配"盐南高新区"、"经开区"等关键词

### 2. 盐城市公共资源交易网
- **网址**: `https://yccggzy.jszwfw.gov.cn/tradeInfor?secondId=19&secondCode=transactionInfo`
- **状态**: ⏳ 待开发
- **采集方式**: playwright（动态加载）
- **技术难度**: ⭐⭐⭐ 中等

### 3. 开发区公共资源交易网
- **网址**: `http://218.92.181.186:8081/jyxx/about.html`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML）
- **技术难度**: ⭐ 简单
- **区域特性**: 开发区网站，全量采集并标记为"经开区"
- **实现细节**:
  - 列表页URL: `http://218.92.181.186:8081/jyxx/about.html`
  - 链接格式: `/jyxx/{category}/{subcategory}/{date}/{uuid}.html`
  - 日期提取: 从URL路径中提取日期（格式YYYYMMDD）
  - 所有数据都标记为"经开区"，无需区域筛选
  - 支持招标公告、中标公告、采购公告等多种类型

### 3. 江苏世纪新城
- **网址**: `https://jscncg.com/tenderLease/tender/`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML）
- **技术难度**: ⭐ 简单
- **数据量**: 共838条记录
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **实现细节**:
  - 列表页URL: `https://jscncg.com/tenderLease/tender/`
  - 链接格式: `/tenderLease/tender/YYYY-MM-DD/{id}.html`
  - 数据结构: `<li myid="{id}"><a href="...">标题</a><span>日期</span></li>`
  - 日期提取: 从URL路径中提取日期（格式YYYY-MM-DD）
  - 区域标记: 标记为"区域内"，跳过关键词匹配

### 4. 城南新区公共资源交易网
- **网址**: `http://221.231.11.22:8099/jyxx/tradeInfo.html`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML）
- **技术难度**: ⭐ 简单
- **实现细节**:
  - 列表页URL: `http://221.231.11.22:8099/jyxx/tradeInfo.html`
  - 链接格式: `/jyxx/{category}/{number}/{date}/{uuid}.html`
  - 日期提取: 从URL路径中提取日期（格式YYYYMMDD）
  - 已采集到"盐南高新区"相关项目4条（测试结果）

### 5. 中国政府采购网
- **网址**: `http://www.ccgp.gov.cn/`
- **状态**: ⏳ 待开发
- **采集方式**: playwright + 代理（动态+反爬）
- **技术难度**: ⭐⭐⭐⭐ 较难

### 6. 江苏政府采购网
- **网址**: `http://www.ccgp-jiangsu.gov.cn/`
- **状态**: ⏳ 待开发
- **采集方式**: playwright（动态加载）
- **技术难度**: ⭐⭐⭐ 中等

### 7. 江苏省公共资源交易网
- **网址**: `http://jsggzy.jszwfw.gov.cn/jyxx/tradeInfonew.html`
- **状态**: ⏳ 待开发
- **采集方式**: playwright（动态加载）
- **技术难度**: ⭐⭐⭐ 中等

### 8. 盐城市人民政府网
- **网址**: `https://www.yancheng.gov.cn/col/col16735/index.html`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML + iframe）
- **技术难度**: ⭐ 简单
- **实现细节**:
  - 使用iframe加载内容: `/module/xxgk/search_custom.jsp?fieldConfigId=38195`
  - 分页采集，每页15条记录
  - 日期格式: `[2026-04-07]`
  - 数据量: 共2088条记录，140页
  - 区域筛选: 匹配"盐南高新区"、"经开区"等关键词

### 9. 江苏招标采购服务平台
- **网址**: `https://www.jszbcg.com/#/bulletin?page=1&category_id=88`
- **状态**: ⏳ 待开发
- **采集方式**: playwright（SPA单页应用）
- **技术难度**: ⭐⭐⭐ 中等

### 10. 全国招标采购公共服务平台
- **网址**: `https://www.hnzbcgxxw.com/list/4.html`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML）
- **技术难度**: ⭐⭐ 简单
- **实现细节**:
  - 多分类采集: 招标/采购公告、中标/成交公告、变更/补充公告、其他公告/公示
  - 分页采集，每页10条记录
  - 日期格式: `2026-04-12`
  - 区域筛选: 匹配"盐南高新区"、"经开区"等关键词

### 11. 苏服采
- **网址**: `https://jsfwgov.cn/bidding?serviceType=1`
- **状态**: ✅ 已实现
- **采集方式**: Playwright浏览器自动化
- **技术难度**: ⭐⭐⭐ 中等
- **特殊处理**: 需要筛选地区（经开区、盐南高新区）
- **实现细节**:
  - 使用Playwright模拟浏览器操作
  - 自动识别区域选择器并选择区域
  - 支持JavaScript数据提取
  - 自动截图保存调试信息

## B类网站：企业招标平台

### 1. 盐城市大数据集团
- **网址**: `https://www.ycdatagroup.cn/news/19.html`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup
- **URL格式**: `/news/show-{id}.html`
- **关键词过滤**: ["公告", "中标", "结果", "公示", "招标", "采购"]
- **日期提取**: 父级元素中的 `YYYY.MM.DD` 或 `YYYY-MM-DD`
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **采集结果**: 测试采集到10条数据，全部标记为"区域内"

### 2. 盐城市东方集团
- **网址**: `https://www.orientalgroup.net.cn/zbzl/qzzgs/`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML）
- **技术难度**: ⭐ 简单
- **数据量**: 共2229条记录
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **实现细节**:
  - 列表页URL: `https://www.orientalgroup.net.cn/zbzl/qzzgs/`
  - 链接格式: `/zbzl/qzzgs/YYYY-MM-DD/{id}.html`
  - 数据结构: `<li><a class="col-xs-9" href="...">标题</a><span class="col-xs-3">日期</span></li>`
  - 日期提取: 从URL路径中提取日期（格式YYYY-MM-DD）
  - 区域标记: 标记为"区域内"，跳过关键词匹配

### 3. 江苏世纪新城
- **网址**: `https://jscncg.com/tenderLease/tender/`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup（静态HTML）
- **技术难度**: ⭐ 简单
- **数据量**: 共838条记录
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **实现细节**:
  - 列表页URL: `https://jscncg.com/tenderLease/tender/`
  - 链接格式: `/tenderLease/tender/YYYY-MM-DD/{id}.html`
  - 数据结构: `<li myid="{id}"><a href="...">标题</a><span>日期</span></li>`
  - 日期提取: 从URL路径中提取日期（格式YYYY-MM-DD）
  - 区域标记: 标记为"区域内"，跳过关键词匹配

### 4. 盐城市都市建设投资集团
- **网址**: `http://www.ycdsjt.cn/?zhaobiao/`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup
- **URL格式**: `/?tongzhi/{id}.html` 或 `/?zhaobiao/{id}.html`
- **日期提取**: 默认使用当天日期
- **分类**: 多分类（通知、招标）
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **采集结果**: 测试采集到45条数据，全部标记为"区域内"

### 5. 经开城发集团
- **网址**: `http://www.ycjkct.com/zbcg/zbxx/`（招标信息）、`http://www.ycjkct.com/zbcg/zbgs/`（中标信息）
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup
- **URL格式**: `/zbcg/{分类}/YYYY-MM-DD/{id}.html`
- **分类**: 2个（招标信息、中标信息）
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **采集结果**: 测试采集到50条数据（招标25条+中标25条），全部标记为"区域内"

### 6. 悦达集团-阳光采购平台
- **网址**: `http://www.ydtender.com/`
- **状态**: ✅ 已实现
- **采集方式**: requests + BeautifulSoup
- **子分类**: 12个
- **URL格式**: `/{分类代码}/{id}.jhtml`
- **日期提取**: 从`<p class="Gray">`中提取"发布时间"
- **数据结构**: `<div class="List2"><ul><li><a>`嵌套结构
- **区域特性**: 区域内公司，全量采集所有招投标信息
- **采集结果**: 测试采集到120条数据（12个分类各10条），全部标记为"区域内"

#### 悦达集团子分类列表
```
https://www.ydtender.com/zbgg/index.jhtml      # 综合公告汇总页
https://www.ydtender.com/zgcgg/index.jhtml     # 工程公告
https://www.ydtender.com/zhwgg/index.jhtml     # 货物公告
https://www.ydtender.com/zfwgg/index.jhtml     # 服务公告
https://www.ydtender.com/jgcgg/index.jhtml     # 结果公告-工程
https://www.ydtender.com/jhwgg/index.jhtml     # 结果公告-货物
https://www.ydtender.com/jfwgg/index.jhtml     # 结果公告-服务
https://www.ydtender.com/pgcgg/index.jhtml     # 评审公示-工程
https://www.ydtender.com/phwgg/index.jhtml     # 评审公示-货物
https://www.ydtender.com/pfwgg/index.jhtml     # 评审公示-服务
https://www.ydtender.com/yyzbgg/index.jhtml    # 运营采购公告
https://www.ydtender.com/yyjggg/index.jhtml    # 运营结果公告
https://www.ydtender.com/yygyszj/index.jhtml   # 供应商征集
```

## 技术实现细节

### 通用关键词过滤
```python
KEYWORDS = ["公告", "中标", "结果", "公示"]
```

### 区域关键词配置
```python
REGION_KEYWORDS = {
    "盐南高新区": ["盐南高新区", "盐南", "城南新区", "城南"],
    "经开区": ["经开区", "经济技术开发区", "开发区"]
}
```

### 日期格式标准化
- 常见格式: `2026.04.12` → `2026-04-12`
- URL格式: `/2026-04-12/` → `2026-04-12`

### 去重策略
- 唯一标识: MD5(项目名称 + 发布日期 + 来源网站)
- 内容哈希: MD5(完整项目JSON)

## 注意事项

1. **实际可用网站**: 3个网站可正常采集并返回数据
   - ✅ 盐城市大数据集团（可用，测试采集10条）
   - ✅ 盐城市都市建设投资集团（可用，测试采集45条）
   - ✅ 悦达集团（可用，测试采集120条）

2. **被拦截网站**: 2个网站需要技术方案绕过反爬
   - ⚠️ 盐城市政府采购网（WAF拦截，需要Playwright）
   - ⚠️ 经开城投集团（反爬拦截，需要优化请求头）

3. **无法访问网站**: 2个网站域名可能已失效
   - ❌ 江苏世纪新城（DNS解析失败）
   - ❌ 盐城市东方集团（DNS解析失败）

4. **详细校验报告**: 请参见 [verification-report.md](verification-report.md)

5. **反爬策略**:
   - 设置合理的请求间隔（1-2秒）
   - 使用随机User-Agent
   - 必要时使用代理IP
   - 被拦截网站建议使用Playwright

6. **错误处理**:
   - 网络超时重试3次
   - 解析失败记录原始HTML
   - 异常情况记录日志

## 扩展开发

### 添加新网站适配器
1. 继承 `BaseCrawler` 类
2. 实现 `crawl(start_date, end_date)` 方法
3. 返回格式: `{"total": int, "new": int}`
4. 在 `main()` 函数中添加到爬虫列表

### 示例
```python
class NewSiteCrawler(BaseCrawler):
    SITE_NAME = "新网站名称"
    BASE_URL = "https://example.com"

    def crawl(self, start_date: str, end_date: str) -> Dict:
        # 实现采集逻辑
        pass
```


path: references/飞书推送配置说明.md
content: # 飞书推送配置说明

## 方式一：使用飞书机器人Webhook（推荐）

### 步骤

1. **创建飞书机器人**
   - 在飞书群聊中，点击群设置 → 群机器人 → 添加机器人
   - 选择"自定义机器人"
   - 设置机器人名称和头像
   - 复制生成的Webhook URL

2. **配置Webhook URL**
   ```bash
   # 方式1：设置环境变量
   export FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"

   # 方式2：在代码中直接设置
   from scripts.feishu_notifier import FeishuNotifier
   notifier = FeishuNotifier(webhook_url="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx")
   ```

3. **测试发送**
   ```python
   from scripts.feishu_notifier import FeishuNotifier
   notifier = FeishuNotifier()
   notifier.send_text_message("测试消息")
   ```

### 优缺点

**优点**：
- 配置简单，只需Webhook URL
- 不需要申请飞书开放平台应用
- 适合单群聊推送

**缺点**：
- 仅支持文本和Markdown消息
- 不支持发送文件（PDF等）
- 每分钟发送次数有限制

---

## 方式二：使用飞书开放平台API（高级）

### 步骤

1. **创建飞书应用**
   - 访问 https://open.feishu.cn/
   - 创建企业自建应用
   - 获取App ID和App Secret
   - 配置应用权限：
     - 获取群聊信息
     - 发送消息
     - 上传文件

2. **配置应用凭证**
   ```bash
   export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx"
   export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
   ```

3. **获取群聊ID**
   ```python
   from scripts.feishu_notifier import FeishuOpenAPI

   api = FeishuOpenAPI()
   api.get_access_token()

   # 获取群聊列表（需要先添加机器人到群聊）
   # 或者直接使用已知的群聊ID
   chat_id = "oc_xxxxxxxxxxxxxxxx"
   ```

4. **发送文件消息**
   ```python
   from scripts.feishu_notifier import FeishuOpenAPI

   api = FeishuOpenAPI()

   # 上传文件
   file_key = api.upload_file("./招投标数据/daily/招投标信息日报_2026-04-12.pdf")

   # 发送文件消息
   api.send_file_message(chat_id, file_key)
   ```

### 优缺点

**优点**：
- 支持发送文件（PDF等）
- 支持更多消息类型
- 发送次数限制更宽松

**缺点**：
- 配置复杂，需要申请应用和权限
- 需要获取群聊ID
- 需要定期刷新访问令牌

---

## 使用示例

### 示例1：每日自动推送

```python
from scripts.scheduler import TaskScheduler

# 创建调度器
scheduler = TaskScheduler()

# 添加每日9:00定时任务
scheduler.add_daily_job(cron_expression="0 9 * * *")

# 启动调度器
scheduler.start()
```

### 示例2：手动推送

```python
from scripts.pdf_generator import PDFGenerator
from scripts.feishu_notifier import FeishuNotifier
from scripts.crawler import DatabaseManager
from datetime import datetime

# 生成PDF
generator = PDFGenerator()
today = datetime.now().strftime('%Y-%m-%d')
pdf_file = generator.generate_daily_report(today)

# 发送通知
notifier = FeishuNotifier()
db = DatabaseManager()

# 查询数据
projects = db.get_new_projects(today)
notifier.send_daily_report(today, projects, pdf_file)
```

### 示例3：命令行推送

```bash
# 方式1：立即执行一次
python scripts/scheduler.py --mode once

# 方式2：定时执行（每天9:00）
python scripts/scheduler.py --mode schedule --cron "0 9 * * *"

# 方式3：自定义时间（每天18:00）
python scripts/scheduler.py --mode schedule --cron "0 18 * * *"
```

---

## 安全建议

1. **保护凭证**
   - 不要将Webhook URL、App ID、App Secret提交到代码仓库
   - 使用环境变量或配置文件存储敏感信息
   - 定期更换Webhook和App Secret

2. **权限控制**
   - 仅授予应用必要的权限
   - 限制机器人可以操作的群聊
   - 定期审计应用权限使用情况

3. **错误处理**
   - 添加重试机制
   - 记录详细的错误日志
   - 设置告警阈值

---

## 常见问题

### Q1: Webhook URL失效怎么办？
**A**: Webhook URL有时效性，如果失效需要重新生成。

### Q2: 飞书机器人消息发送失败？
**A**: 检查以下几点：
- Webhook URL是否正确
- 网络连接是否正常
- 消息格式是否符合要求
- 是否超过发送频率限制

### Q3: 如何发送PDF文件？
**A**: 需要使用飞书开放平台API（方式二），Webhook不支持发送文件。

### Q4: 如何获取群聊ID？
**A**:
1. 将机器人添加到群聊
2. 调用API获取群聊列表
3. 从列表中找到目标群聊ID

### Q5: 定时任务不执行？
**A**: 检查以下几点：
- Cron表达式是否正确
- 系统时间是否准确
- 进程是否正常运行
- 日志中是否有错误信息

---

## 更多信息

- 飞书开放平台文档：https://open.feishu.cn/document/
- 飞书机器人Webhook文档：https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNzkE4N
- APScheduler文档：https://apscheduler.readthedocs.io/


path: references/PDF报告生成与飞书推送使用指南.md
content: # PDF报告生成与飞书推送功能使用指南

## 功能概述

本Skill提供了完整的PDF报告生成和飞书推送功能，实现招投标信息的自动化日报生成和推送。

---

## PDF报告生成

### 功能特性

- ✅ 自动从数据库查询指定日期的数据
- ✅ 按区域分类展示（盐南高新区、经开区、区域内）
- ✅ 包含汇总统计表格
- ✅ 支持中文字体显示
- ✅ 自动保存到指定目录
- ✅ 支持单日报和日期范围报告

### 使用方法

#### 方法1：通过Python代码调用

```python
from scripts.pdf_generator import PDFGenerator
from datetime import datetime

# 创建PDF生成器
generator = PDFGenerator()

# 生成今日日报
today = datetime.now().strftime('%Y-%m-%d')
pdf_file = generator.generate_daily_report(today)

if pdf_file:
    print(f"PDF报告已生成: {pdf_file}")
else:
    print("PDF报告生成失败或无数据")
```

#### 方法2：通过命令行调用

```python
import sys
sys.path.insert(0, '/workspace/projects/bidding-crawler')
from scripts.pdf_generator import PDFGenerator

generator = PDFGenerator()
pdf_file = generator.generate_daily_report('2026-04-12')
```

### 报告内容

PDF报告包含以下内容：

1. **标题**：招投标信息日报
2. **日期**：报告日期
3. **汇总统计**：
   - 总项目数
   - 盐南高新区项目数
   - 经开区项目数
   - 区域内项目数
   - 总预算
4. **项目明细**：按区域分类展示，包含：
   - 项目名称
   - 发布日期
   - 预算
   - 采购人
   - 来源

### 文件存储

- **存储目录**：`./招投标数据/daily/`
- **文件命名**：`招投标信息日报_YYYY-MM-DD.pdf`
- **示例**：`招投标信息日报_2026-04-12.pdf`

---

## 飞书推送

### 功能特性

- ✅ 支持文本消息推送
- ✅ 支持Markdown格式消息
- ✅ 支持日报摘要推送
- ✅ 自动更新推送状态
- ✅ 支持告警消息推送

### 使用方法

#### 方式1：使用Webhook（推荐）

```python
from scripts.feishu_notifier import FeishuNotifier

# 初始化通知器
notifier = FeishuNotifier()  # 需要设置环境变量 FEISHU_WEBHOOK_URL

# 发送文本消息
notifier.send_text_message("这是一条测试消息")

# 发送日报
notifier.send_daily_report('2026-04-12', projects, pdf_file)
```

#### 方式2：使用飞书开放平台API

```python
from scripts.feishu_notifier import FeishuOpenAPI

# 初始化API客户端
api = FeishuOpenAPI(
    app_id='cli_xxxxxxxxxxxxxxxx',
    app_secret='xxxxxxxxxxxxxxxxxxxxxxxx'
)

# 上传文件
file_key = api.upload_file('./招投标数据/daily/招投标信息日报_2026-04-12.pdf')

# 发送文件消息
api.send_file_message('oc_xxxxxxxxxxxxxxxx', file_key)
```

### 配置步骤

#### 1. 创建飞书机器人（Webhook方式）

1. 在飞书群聊中，点击群设置 → 群机器人 → 添加机器人
2. 选择"自定义机器人"
3. 设置机器人名称和头像
4. 复制生成的Webhook URL
5. 设置环境变量：
   ```bash
   export FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"
   ```

#### 2. 创建飞书应用（开放平台方式）

1. 访问 https://open.feishu.cn/
2. 创建企业自建应用
3. 获取App ID和App Secret
4. 配置应用权限：
   - 获取群聊信息
   - 发送消息
   - 上传文件
5. 设置环境变量：
   ```bash
   export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx"
   export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxx"
   ```

---

## 定时任务调度

### 功能特性

- ✅ 支持每日定时执行
- ✅ 支持自定义Cron表达式
- ✅ 自动执行完整流程：采集 → 生成PDF → 发送通知
- ✅ 支持立即执行
- ✅ 完善的日志记录

### 使用方法

#### 命令行方式

```bash
# 立即执行一次
python scripts/scheduler.py --mode once

# 定时执行（每天9:00）
python scripts/scheduler.py --mode schedule --cron "0 9 * * *"

# 自定义时间（每天18:00）
python scripts/scheduler.py --mode schedule --cron "0 18 * * *"
```

#### Python代码方式

```python
from scripts.scheduler import TaskScheduler

# 创建调度器
scheduler = TaskScheduler()

# 添加每日9:00定时任务
scheduler.add_daily_job(cron_expression="0 9 * * *")

# 启动调度器
scheduler.start()

# 或者立即执行一次
scheduler.run_once()
```

### Cron表达式说明

Cron表达式格式：`分 时 日 月 周`

常用示例：
- `0 9 * * *`：每天9:00执行
- `0 18 * * *`：每天18:00执行
- `0 */6 * * *`：每6小时执行一次
- `0 9 * * 1`：每周一9:00执行

---

## 完整工作流程

### 标准流程

```
1. 采集数据
   ↓
2. 生成PDF报告
   ↓
3. 发送飞书通知
   ↓
4. 更新推送状态
```

### 代码实现

```python
from scripts.scheduler import TaskScheduler

# 创建调度器
scheduler = TaskScheduler()

# 执行完整流程
scheduler.run_daily_task()
```

### 执行步骤

1. **采集数据**
   - 调用所有采集器
   - 采集指定日期的数据
   - 存储到数据库

2. **生成PDF报告**
   - 查询数据库中的数据
   - 按区域分类
   - 生成PDF文件
   - 保存到指定目录

3. **发送飞书通知**
   - 查询数据库中的数据
   - 生成日报摘要
   - 发送到飞书群聊
   - 更新推送状态

4. **更新推送状态**
   - 将已推送的项目标记为已推送
   - 记录推送时间

---

## 测试与验证

### 测试PDF生成

```python
python scripts/test_pdf_feishu.py
```

### 测试飞书推送

```python
from scripts.feishu_notifier import FeishuNotifier

notifier = FeishuNotifier()
notifier.send_text_message("测试消息")
```

### 测试完整流程

```bash
python scripts/scheduler.py --mode once
```

---

## 常见问题

### Q1: PDF中文显示乱码？

**A**: 系统会自动查找并注册中文字体，如果仍然乱码，请检查：
- 系统是否安装了中文字体
- 字体路径是否正确

### Q2: 飞书通知发送失败？

**A**: 请检查：
- Webhook URL是否正确
- 网络连接是否正常
- 消息格式是否符合要求

### Q3: 定时任务不执行？

**A**: 请检查：
- Cron表达式是否正确
- 系统时间是否准确
- 进程是否正常运行

### Q4: 如何修改报告样式？

**A**: 编辑 `scripts/pdf_generator.py` 文件，修改表格样式和字体配置。

### Q5: 如何自定义推送消息格式？

**A**: 编辑 `scripts/feishu_notifier.py` 文件，修改 `send_daily_report` 方法中的消息模板。

---

## 配置文件

### 环境变量

```bash
# 飞书Webhook URL（推荐）
export FEISHU_WEBHOOK_URL="https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"

# 飞书开放平台应用凭证（高级）
export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx"
export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxx"
```

### 配置文件示例

```bash
# .env 文件
FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
```

---

## 监控与日志

### 日志文件

- 采集日志：`./招投标数据/logs/crawler_YYYY-MM-DD.log`
- 调度器日志：`./招投标_data/logs/scheduler.log`
- 测试报告：`./招投标_data/logs/test_report_YYYY-MM-DD_HH-MM-SS.txt`

### 监控指标

- 采集成功率
- 数据完整性
- PDF生成成功率
- 飞书推送成功率
- 推送延迟

---

## 扩展功能

### 自定义报告模板

编辑 `scripts/pdf_generator.py`，修改 `_create_region_section` 方法，自定义报告样式。

### 添加更多推送渠道

参考 `scripts/feishu_notifier.py`，实现其他推送方式（如邮件、企业微信等）。

### 多群聊推送

创建多个 `FeishuNotifier` 实例，配置不同的Webhook URL。

---

## 参考文档

- [飞书推送配置说明](references/飞书推送配置说明.md)
- [全面测试报告](references/全面测试报告.md)
- [网页结构变化检测与自修正机制](references/网页结构变化检测与自修正机制.md)

---

**文档版本**：1.0.0
**最后更新**：2026-04-12


path: references/openclaw-browser-relay-guide.md
content: # OpenClaw Browser Relay 配置指南

## 概述

OpenClaw Browser Relay 是一个浏览器中转服务，用于解决以下问题：
1. **字体加密**：浏览器端可以正确渲染加密字体
2. **地区选择**：支持一次性选择多个地区
3. **实时验证**：可以直观看到采集过程

## 部署 Browser Relay 服务

### 方式1：使用 Docker（推荐）

```bash
# 拉取镜像
docker pull openclaw/browser-relay:latest

# 启动服务
docker run -d \
  --name browser-relay \
  -p 8080:8080 \
  -v /tmp/browser-data:/data \
  openclaw/browser-relay:latest
```

### 方式2：使用 Docker Compose

```yaml
version: '3.8'
services:
  browser-relay:
    image: openclaw/browser-relay:latest
    container_name: browser-relay
    ports:
      - "8080:8080"
    volumes:
      - /tmp/browser-data:/data
    environment:
      - HEADLESS=true
      - MAX_SESSIONS=5
```

### 方式3：本地运行（开发环境）

```bash
# 安装依赖
npm install -g @openclaw/browser-relay

# 启动服务
browser-relay --port 8080
```

## 服务接口

### 1. 执行命令

```bash
POST /execute
Content-Type: application/json

{
  "action": "goto|click|evaluate|screenshot",
  "url": "页面URL",  // goto 需要
  "selector": "CSS选择器",  // click 需要
  "script": "JavaScript代码",  // evaluate 需要
  "waitUntil": "load|networkidle"
}
```

### 2. 查看会话状态

```bash
GET /sessions
```

### 3. 关闭会话

```bash
DELETE /sessions/:sessionId
```

## 使用示例

### 基础使用

```python
from scripts.sufu_crawler_openclaw import SufuCrawlerOpenClaw

# 创建采集器
crawler = SufuCrawlerOpenClaw(relay_url="http://localhost:8080")

# 采集数据（一次性选择多个地区）
results = crawler.collect_sufu(regions=["盐南高新区", "经开区"])

# 处理结果
if results:
    for project in results:
        print(f"{project['project_name']} - {project['region']}")
```

### 集成到主采集器

```python
from scripts.crawler import SufuCrawler

class SufuCrawler(BaseCrawler):
    def crawl(self, region=None, days=None):
        # 使用 Browser Relay 方式采集
        from scripts.sufu_crawler_openclaw import SufuCrawlerOpenClaw

        crawler = SufuCrawlerOpenClaw()
        results = crawler.collect_sufu(regions=["盐南高新区", "经开区"])

        # 保存到数据库
        for project in results:
            self.db.save_project(project)

        return results
```

## 优势

1. **解决字体加密**：浏览器端渲染，无需处理加密字体
2. **支持多地区选择**：一次性选择多个地区，提高效率
3. **实时可见**：可以查看采集过程，便于调试
4. **易于扩展**：支持自定义JavaScript脚本
5. **无头模式**：支持无头浏览器，适合服务器环境

## 注意事项

1. **服务依赖**：需要先启动 Browser Relay 服务
2. **端口占用**：确保8080端口未被占用
3. **资源消耗**：浏览器实例会占用较多内存
4. **超时设置**：合理设置超时时间，避免长时间等待
5. **错误处理**：注意处理网络异常和服务不可用的情况

## 故障排查

### 问题1：无法连接到 Browser Relay 服务

**解决方案**：
- 检查服务是否启动：`docker ps | grep browser-relay`
- 检查端口是否开放：`netstat -an | grep 8080`
- 检查防火墙设置

### 问题2：字体仍然显示乱码

**解决方案**：
- 确保浏览器版本支持字体渲染
- 检查字体文件是否加载成功
- 尝试使用有头模式查看实际效果

### 问题3：地区选择不生效

**解决方案**：
- 查看浏览器截图确认选择是否成功
- 检查选择器是否正确
- 尝试增加等待时间

## 性能优化

1. **会话复用**：复用浏览器会话，减少启动开销
2. **并发限制**：限制并发会话数量，避免资源耗尽
3. **缓存策略**：对不频繁变化的数据使用缓存
4. **超时控制**：合理设置超时，避免长时间等待


path: references/PDF报告排版说明.md
content: # PDF报告排版说明

## 报告结构（当前版本）

### 第1页：招标信息概况（日报）

**标题**：招投标信息概况
**内容**：
- 报告日期
- 汇总表格（5列）：网站名称 | 当日条数 | 当月条数 | 爬虫策略 | 筛选策略
- 当日总计 + 当月总计

**表格样式**：
- 表头背景：浅灰色 (`colors.lightgrey`)
- 边框：灰色细线 0.5pt
- 字体：中文，9pt，数据格居中，策略格左对齐
- **关键**：所有单元格均为 `Paragraph` 对象，`VALIGN='TOP'`，无固定行高，支持自动换行

---

### 第2页：月度概况（月报）

**标题**：招投标信息月度报告
**内容**：
- 报告时间 + 统计范围（当月第一天 至 最后一天）
- 汇总表格（6列）：网站名称 | 数据条数 | 当日数据 | 前一日数据 | 爬虫策略 | 筛选策略
- 底部求和行（背景色 `#d9e2f3`）
- 按 SITE_ORDER 固定顺序排列

**表格样式**：
- 表头背景：深蓝色 (`#1f4e79`)，白字
- 边框：灰色细线 0.5pt
- 字体：中文，9pt
- **关键**：所有单元格均为 `Paragraph` 对象（含白字表头样式），`VALIGN='TOP'`，无固定行高

---

### 第3页开始：月度平台明细（月报）

**结构**：每个平台独立一页，按 SITE_ORDER 排序

**每页内容**：
- 标题：平台名称（共X条）
- 项目清单表格（4列）：网站 | 日期 | 项目名称 | 链接

**表格样式**：
- 表头背景：深蓝色 (`#1f4e79`)，白字，居中
- 边框：灰色细线 0.3pt
- 字体：中文，9pt
- 行背景：白色和whitesmoke交替
- **关键**：所有单元格均为 `Paragraph` 对象，`VALIGN='TOP'`，无固定行高

---

## 技术实现要点

### Paragraph + 自动换行（核心）

ReportLab 表格只有 `Paragraph` 对象才触发 `wordWrap`，普通字符串不会换行：

```python
# 单元格样式
cell_style = ParagraphStyle(
    'Cell',
    fontName=self.chinese_font,
    fontSize=9,
    alignment=TA_LEFT,
    leading=11,
    wordWrap='CJK',      # 中文换行
)

# 表头样式（白字）
header_style = ParagraphStyle(
    'Header',
    fontName=self.chinese_font,
    fontSize=10,
    alignment=TA_CENTER,
    leading=12,
    wordWrap='CJK',
    textColor=colors.white,  # 白字
)

# 表格数据用 Paragraph 包裹
table_data = [[
    Paragraph('网站名称', header_style),
    Paragraph('2026-05-07', cell_style),
    Paragraph(project_name, cell_style),  # 触发自动换行
    Paragraph('<link href="...">点击查看</link>', link_style),  # 可点击
]]
```

### TableStyle 关键设置

```python
table.setStyle(TableStyle([
    ('VALIGN', (0, 0), (-1, -1), 'TOP'),      # 顶部对齐（不是MIDDLE）
    ('TOPPADDING', (0, 0), (-1, -1), 4),      # 单元格内边距
    ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
    ('LEFTPADDING', (0, 0), (-1, -1), 4),
    ('RIGHTPADDING', (0, 0), (-1, -1), 4),
    # 禁止设置固定 ROWHEIGHT，否则换行失效
]))
```

### 可点击超链接

```python
link_style = ParagraphStyle(
    'Link',
    fontName=self.chinese_font,
    fontSize=8,
    textColor=colors.blue,
    leading=10,
    wordWrap='LTR',
)

if url:
    link_para = Paragraph(
        f"<link href='{url}' color='blue' underline='yes'>{url}</link>",
        link_style
    )
```

---

## 数据统计规则

### 当日统计
- 查询条件：`publish_date = 指定日期`
- 分组字段：`source_site`

### 当月统计
- 查询条件：`publish_date BETWEEN 当月第一天 AND 当月最后一天`
- 分组字段：`source_site`

### 月度概况页"当日数据"和"前一日数据"
- 调用 `_get_daily_count_for_site(site, date)` 实时查询
- 用于展示最新一天的数据

---

## PDF文件规范

### 文件命名
- 月报：`招投标信息月报_<年份>年<月份>月.pdf`
- 示例：`招投标信息月报_2026年05月.pdf`

### 文件存储
- 目录：`~/.openclaw/workspace/skills/bidding-assistant/招投标数据/daily/`

### 页面设置
- 页面大小：A4
- 边距：1.5cm（上、下、左、右）
- 方向：纵向

---

## 中文字体配置

系统自动查找，macOS 优先使用苹方：

```python
def get_chinese_font(self):
    font_paths = [
        '/System/Library/Fonts/PingFang.ttc',       # macOS
        'C:/Windows/Fonts/msyh.ttc',                 # Windows
        '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',  # Linux
    ]
```

---

## 版本历史

- **v3.0** (2026-05-07)：全表Paragraph换行优化
  - 所有表格所有列均改为 `Paragraph` 对象
  - `VALIGN='TOP'`，去掉所有固定 `ROWHEIGHT`
  - 链接改为可点击超链接（不截断URL）
  - 月度概况表新增"当日数据""前一日数据"两列 + 求和行
  - 所有页面均按 SITE_ORDER 固定顺序排列

- **v2.0** (2026-04-12)：重新设计排版结构
  - 第1页：招标信息概况
  - 第2页：当日清单
  - 第3页开始：分平台清单

- **v1.0** (2026-04-12)：初始版本

---

**文档版本**：3.0
**最后更新**：2026-05-07


path: references/飞书企业自建应用配置指南.md
content: # 飞书企业自建应用配置指南

## 概述

本指南详细介绍如何配置飞书企业自建应用，用于发送PDF报告到飞书群聊。

---

## 配置步骤

### 第一步：创建飞书企业自建应用

1. **访问飞书开放平台**
   - 打开浏览器，访问：https://open.feishu.cn/

2. **登录飞书账号**
   - 使用飞书管理员账号登录

3. **创建应用**
   - 点击"创建企业自建应用"
   - 选择"从零创建"
   - 填写应用信息：
     - 应用名称：招投标信息推送
     - 应用描述：自动推送招投标信息日报
     - 应用图标：上传应用图标（可选）
   - 点击"创建"

4. **获取应用凭证**
   - 创建成功后，进入应用详情页
   - 在左侧菜单中，点击"凭证与基础信息"
   - 复制以下信息：
     - App ID
     - App Secret

---

### 第二步：配置应用权限

1. **添加权限**
   - 在左侧菜单中，点击"权限管理"
   - 点击"添加权限"
   - 搜索并添加以下权限：
     - `im:chat`：获取群聊信息
     - `im:message`：发送消息
     - `im:message:send_at_all`：发送@所有人消息（可选）
     - `drive:drive`：上传文件到云空间

2. **申请权限**
   - 添加权限后，点击"申请权限"
   - 填写申请理由：
     - 应用用途：推送招投标信息日报
     - 使用场景：每日自动推送PDF报告到指定群聊
   - 提交申请，等待管理员审批

---

### 第三步：添加机器人到群聊

1. **获取机器人配置**
   - 在左侧菜单中，点击"机器人"
   - 记录机器人信息：
     - 机器人名称
     - 机器人App ID

2. **将机器人添加到群聊**
   - 打开目标群聊
   - 点击群设置 → 群成员 → 添加成员
   - 搜索并添加机器人（招投标信息推送）
   - 设置机器人权限：
     - 允许机器人发送消息
     - 允许机器人上传文件

3. **获取群聊ID**
   - 在群聊中，点击右上角"..."
   - 点击"复制群ID"或"查看群信息"
   - 记录 `open_chat_id`

---

### 第四步：配置环境变量

1. **设置环境变量**

**方式1：临时设置（终端）**
```bash
export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx"
export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export FEISHU_CHAT_ID="oc_xxxxxxxxxxxxxxxx"
```

**方式2：永久设置（~/.bashrc 或 ~/.zshrc）**
```bash
echo 'export FEISHU_APP_ID="cli_xxxxxxxxxxxxxxxx"' >> ~/.bashrc
echo 'export FEISHU_APP_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"' >> ~/.bashrc
echo 'export FEISHU_CHAT_ID="oc_xxxxxxxxxxxxxxxx"' >> ~/.bashrc
source ~/.bashrc
```

**方式3：使用 .env 文件**
```bash
# 创建 .env 文件
cat > .env << EOF
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
FEISHU_CHAT_ID=oc_xxxxxxxxxxxxxxxx
EOF

# 加载环境变量
export $(cat .env | xargs)
```

**方式4：Python代码中设置**
```python
import os
os.environ['FEISHU_APP_ID'] = 'cli_xxxxxxxxxxxxxxxx'
os.environ['FEISHU_APP_SECRET'] = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
os.environ['FEISHU_CHAT_ID'] = 'oc_xxxxxxxxxxxxxxxx'
```

---

### 第五步：测试配置

1. **测试获取token**
```python
from scripts.feishu_enterprise_app import FeishuEnterpriseApp

app = FeishuEnterpriseApp()
[REDACTED_SECRET])

if [REDACTED_SECRET]"✅ 配置成功")
else:
    print("❌ 配置失败")
```

2. **测试上传文件**
```python
from scripts.feishu_enterprise_app import FeishuEnterpriseApp

app = FeishuEnterpriseApp()
file_key = app.upload_file('./招投标_data/daily/招投标信息日报_2026-04-12.pdf')

if file_key:
    print(f"✅ 文件上传成功: {file_key}")
else:
    print("❌ 文件上传失败")
```

3. **测试发送消息**
```python
from scripts.feishu_enterprise_app import FeishuEnterpriseApp

app = FeishuEnterpriseApp()
chat_id = os.getenv('FEISHU_CHAT_ID')

# 发送文本消息
app.send_text_message(chat_id, "这是一条测试消息")

# 发送文件消息
file_key = app.upload_file('./招投标_data/daily/招投标信息日报_2026-04-12.pdf')
app.send_file_message(chat_id, file_key)
```

---

## API接口说明

### 1. 获取 tenant_access_token

**接口地址**：`POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal`

**请求参数**：
```json
{
  "app_id": "cli_xxxxxxxxxxxxxxxx",
  "app_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

**返回参数**：
```json
{
  "code": 0,
  "msg": "success",
  "tenant_access_token": "t-xxxxxxxxxxxxxxxxxxxxxxxx",
  "expire": 7200
}
```

---

### 2. 上传文件

**接口地址**：`POST https://open.feishu.cn/open-apis/drive/v1/medias/upload_all`

**请求头**：
```
Authorization: Bearer {tenant_access_token}
```

**请求参数**（multipart/form-data）：
- `file_name`: 文件名
- `parent_type`: 父节点类型（默认：explorer）
- `size`: 文件大小
- `file_type`: 文件类型
- `file`: 文件内容

**返回参数**：
```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "file_key": [REDACTED_SECRET]
  }
}
```

---

### 3. 发送文件消息

**接口地址**：`POST https://open.feishu.cn/open-apis/im/v1/messages`

**请求头**：
```
Authorization: Bearer {tenant_access_token}
Content-Type: application/json; charset=utf-8
```

**请求参数**：
```json
{
  "receive_id_type": "chat_id",
  "receive_id": "oc_xxxxxxxxxxxxxxxx",
  "msg_type": "file",
  "content": "{\"file_key\":\"file_v2_XXXXXXXXXXXXXXXXXXXXXXXX\"}"
}
```

**返回参数**：
```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "msg_id": "om_xxxxxxxxxxxxxxxx"
  }
}
```

---

## 使用示例

### 示例1：发送PDF日报

```python
from scripts.feishu_enterprise_app import FeishuEnterpriseApp
from scripts.crawler import DatabaseManager
from datetime import datetime
import os

# 初始化应用
app = FeishuEnterpriseApp()
chat_id = os.getenv('FEISHU_CHAT_ID')

# 查询数据
db = DatabaseManager()
today = datetime.now().strftime('%Y-%m-%d')
projects = db.get_new_projects(today)

# 发送日报
pdf_file = './招投标_data/daily/招投标信息日报_2026-04-12.pdf'
result = app.send_daily_report(chat_id, today, projects, pdf_file)

if result:
    print("✅ 日报发送成功")
else:
    print("❌ 日报发送失败")
```

### 示例2：定时发送

```python
from scripts.scheduler import TaskScheduler
import os

# 设置环境变量
os.environ['FEISHU_CHAT_ID'] = 'oc_xxxxxxxxxxxxxxxx'

# 创建调度器
scheduler = TaskScheduler()

# 添加每日9:00定时任务
scheduler.add_daily_job(cron_expression="0 9 * * *")

# 启动调度器
scheduler.start()
```

---

## 常见问题

### Q1: 如何获取群聊ID（open_chat_id）？

**A**: 有以下几种方式：

**方式1：通过群聊设置获取**
1. 打开群聊
2. 点击右上角"..."
3. 点击"复制群ID"

**方式2：通过API获取**
```python
from scripts.feishu_enterprise_app import FeishuEnterpriseApp

app = FeishuEnterpriseApp()
chat_info = app.get_chat_info('oc_xxxxxxxxxxxxxxxx')
print(f"群聊名称: {chat_info['name']}")
```

**方式3：通过开发者工具获取**
1. 打开飞书网页版
2. 按F12打开开发者工具
3. 切换到Network标签
4. 在群聊中发送一条消息
5. 查看请求的URL，找到chat_id参数

---

### Q2: tenant_access_token 过期了怎么办？

**A**: tenant_access_token 的有效期为2小时（7200秒），客户端需要定期刷新。

代码中已自动处理：
```python
# 每次调用API时都会检查token
if not self.tenant_access_token:
    self.get_tenant_access_token()
```

如果手动处理：
```python
import time
from datetime import datetime

# 记录token获取时间
token_time = datetime.now()

# 检查token是否过期（2小时）
if (datetime.now() - token_time).seconds > 7200:
    app.get_tenant_access_token()
```

---

### Q3: 权限申请被拒绝怎么办？

**A**:

1. **检查申请理由**
   - 是否清楚说明了应用用途
   - 是否说明了使用场景
   - 是否说明了数据来源

2. **联系管理员**
   - 请企业飞书管理员审批权限申请
   - 提供详细的申请说明和使用场景

3. **使用最小权限原则**
   - 只申请必要的权限
   - 避免申请敏感权限

---

### Q4: 文件上传失败怎么办？

**A**: 检查以下几点：

1. **文件大小限制**
   - 单个文件大小不能超过100MB
   - 如果文件过大，考虑压缩或拆分

2. **文件类型限制**
   - 支持的文件类型：PDF、DOC、DOCX、XLS、XLSX、PPT、PPTX、PNG、JPG、JPEG、GIF、TXT
   - 确保文件类型在支持范围内

3. **网络问题**
   - 检查网络连接是否稳定
   - 尝试重试上传

4. **权限问题**
   - 确保应用有上传文件的权限（`drive:drive`）
   - 检查权限是否已审批通过

---

### Q5: 消息发送失败怎么办？

**A**: 检查以下几点：

1. **群聊ID是否正确**
   - 确认使用的是 `open_chat_id`，不是 `chat_id`
   - 确认机器人已添加到该群聊

2. **机器人权限**
   - 确认机器人有发送消息的权限
   - 确认群主没有禁用机器人

3. **消息格式**
   - 检查消息内容是否符合格式要求
   - 检查JSON格式是否正确

4. **请求频率限制**
   - 飞书API有请求频率限制
   - 避免短时间内发送大量消息

---

### Q6: 如何查看API调用日志？

**A**: 飞书开放平台提供详细的API调用日志：

1. **访问开放平台**
   - 打开 https://open.feishu.cn/

2. **进入应用详情**
   - 点击你的应用

3. **查看日志**
   - 在左侧菜单中，点击"数据概览"
   - 查看API调用次数、成功率、错误率等

---

## 安全建议

1. **保护凭证**
   - 不要将 App ID 和 App Secret 提交到代码仓库
   - 使用环境变量或配置文件存储敏感信息
   - 定期更换 App Secret

2. **权限控制**
   - 只授予应用必要的权限
   - 限制机器人可以操作的群聊
   - 定期审计应用权限使用情况

3. **错误处理**
   - 添加重试机制
   - 记录详细的错误日志
   - 设置告警阈值

4. **监控与告警**
   - 监控API调用成功率
   - 监控消息发送成功率
   - 异常情况及时告警

---

## 参考文档

- **飞书开放平台主页**：https://open.feishu.cn/
- **获取 tenant_access_token**：https://open.feishu.cn/document/ukTMukTMukTM/uYjNwUjL2YDM14iN2ATN
- **上传文件**：https://open.feishu.cn/document/ukTMukTMukTM/uYjNwUjL2YDM14iN2ATN
- **发送消息**：https://open.feishu.cn/document/ukTMukTMukTM/uYjNwUjL2YDM14iN2ATN
- **权限管理**：https://open.feishu.cn/document/ukTMukTMukTM/uYjNwUjL2YDM14iN2ATN

---

## 总结

按照以上步骤配置完成后，即可使用飞书企业自建应用发送PDF报告到飞书群聊。

**配置清单**：
- [ ] 创建飞书企业自建应用
- [ ] 获取 App ID 和 App Secret
- [ ] 配置应用权限
- [ ] 将机器人添加到群聊
- [ ] 获取群聊ID（open_chat_id）
- [ ] 设置环境变量
- [ ] 测试配置

**快速测试命令**：
```python
from scripts.feishu_enterprise_app import FeishuEnterpriseApp

app = FeishuEnterpriseApp()
[REDACTED_SECRET])
print(f"✅ 配置成功" if token else "❌ 配置失败")
```

---

**文档版本**：1.0
**最后更新**：2026-04-12


path: references/sufu-workflow.md
content: # 苏服采交互式采集流程

## 概览
本文档描述苏服采网站的交互式采集流程，通过脚本执行操作、智能体分析截图的闭环方式完成复杂的数据采集任务。

## 适用场景
- 苏服采网站需要复杂的交互操作（登录、三级地区选择、搜索勾选等）
- 自动化脚本难以处理动态页面和复杂交互
- 需要灵活适应页面变化和异常情况

## 流程概述
采用"操作 → 截图 → 分析 → 决策 → 操作"的循环模式：
1. 脚本执行操作（打开页面、点击、输入等）
2. 脚本截图返回当前页面状态
3. 智能体分析截图，理解当前状态
4. 智能体根据分析结果决定下一步操作
5. 循环执行，直到完成采集

## 详细操作步骤

### 步骤 1: 初始化浏览器
**操作**:
```bash
python scripts/sufu_interactive.py --action open --url "https://js.fwgov.cn/bidding?serviceType=1" --headless false
```

**预期结果**: 浏览器打开苏服采网站

**决策点**:
- 如果页面正常加载 → 进入步骤 2
- 如果显示登录界面 → 需要处理登录（暂不支持，跳过苏服采）
- 如果页面加载失败 → 等待后重试

### 步骤 2: 等待页面稳定并截图
**操作**:
```bash
python scripts/sufu_interactive.py --action wait --wait-time 2000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step1_initial.png"
```

**预期结果**: 获取初始页面截图

**分析重点**:
- 检查是否有地区选择框（通常在页面顶部）
- 识别选择框的数量和位置
- 检查是否需要登录

### 步骤 3: 识别地区选择框
**分析截图**:
- 查找地区选择相关元素（省、市、区三级选择框）
- 确认选择框是否可见
- 识别选择框的当前状态

**操作（如果需要点击地区选择）**:
```bash
python scripts/sufu_interactive.py --action click --selector ".ivu-select-visible" --timeout 5000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step2_select_opened.png"
```

### 步骤 4: 选择江苏省
**操作（如果需要在第一个选择框选择江苏省）**:
```bash
python scripts/sufu_interactive.py --action click --selector ".ivu-select-dropdown .ivu-select-item" --timeout 5000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step3_province_selected.png"
```

**决策点**:
- 如果江苏省已选中或不需要选择 → 进入步骤 5
- 如果需要重新选择 → 点击正确的选项后截图

### 步骤 5: 选择盐城市
**操作（如果需要在第二个选择框选择盐城市）**:
```bash
# 点击第二个选择框
python scripts/sufu_interactive.py --action click --selector ".ivu-select:nth-of-type(2)" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 1000

# 点击盐城市
python scripts/sufu_interactive.py --action click --selector ".ivu-select-dropdown .ivu-select-item" --timeout 5000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step4_city_selected.png"
```

### 步骤 6: 选择盐南高新区和经开区（复选框）
**操作**:
```bash
# 点击第三个选择框（复选框）
python scripts/sufu_interactive.py --action click --selector ".ivu-select:nth-of-type(3)" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 1000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step5_dropdown_opened.png"

# 第二次点击，激活输入框
python scripts/sufu_interactive.py --action click --selector ".ivu-select:nth-of-type(3)" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 500
python scripts/sufu_interactive.py --action screenshot --screenshot_name "step6_input_activated.png"
```

**分析截图**:
- 检查是否出现输入框
- 识别输入框的位置和状态
- 检查是否有搜索建议或下拉列表

**操作（输入"盐南高新区"）**:
```bash
python scripts/sufu_interactive.py --action input --selector ".ivu-select-dropdown input" --text "盐南高新区" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 1000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step7_search_yannan.png"
```

**分析截图**:
- 检查搜索结果是否出现
- 识别"盐南高新区"选项
- 确认选项的完整文本（可能是"江苏省 / 盐城市 / 盐南高新区"）

**操作（勾选盐南高新区）**:
```bash
# 点击搜索结果中的盐南高新区选项
python scripts/sufu_interactive.py --action click --selector ".ivu-select-item" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 500
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step8_yannan_selected.png"
```

**操作（输入"经开区"）**:
```bash
python scripts/sufu_interactive.py --action input --selector ".ivu-select-dropdown input" --text "经开区" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 1000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step9_search_jingkai.png"
```

**操作（勾选经开区）**:
```bash
# 点击搜索结果中的经开区选项
python scripts/sufu_interactive.py --action click --selector ".ivu-select-item" --timeout 5000
python scripts/sufu_interactive.py --action wait --wait-time 500
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step10_jingkai_selected.png"
```

### 步骤 7: 隐藏下拉框并等待页面刷新
**操作**:
```bash
# 点击页面空白处
python scripts/sufu_interactive.py --action click-coords --x 500 --y 500
python scripts/sufu_interactive.py --action wait --wait-time 2000
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step11_after_select.png"
```

**分析截图**:
- 检查下拉框是否关闭
- 检查页面是否显示筛选结果
- 验证地区选择是否生效（URL或页面元素是否包含选定区域）

### 步骤 8: 提取招投标数据
**操作**:
```bash
# 使用JavaScript提取页面数据
python scripts/sufu_interactive.py --action script --script "
(function() {
    const items = document.querySelectorAll('.ivu-card, .ivu-list-item, [class*=\"project\"], [class*=\"bidding\"]');
    const results = [];
    items.forEach(item => {
        const title = item.querySelector('[class*=\"title\"], [class*=\"name\"], h3, h4, a');
        const date = item.querySelector('[class*=\"date\"], [class*=\"time\"]');
        const link = item.querySelector('a');
        if (title || link) {
            results.push({
                title: title ? title.innerText.trim() : '',
                date: date ? date.innerText.trim() : '',
                link: link ? link.href : ''
            });
        }
    });
    return results;
})()
"
```

**预期结果**: 返回提取的项目列表（JSON格式）

**分析提取结果**:
- 检查数据数量
- 验证项目标题是否包含盐南高新区或经开区
- 检查日期是否在采集范围内
- 确认链接是否有效

### 步骤 9: 验证数据准确性
**分析重点**:
- 所有项目都应属于盐南高新区或经开区
- 项目状态应为"招标中"或"进行中"，不包括"响应截止"
- 项目标题应清晰，无乱码
- 日期应准确

**如果数据不准确**:
- 返回步骤 6，重新执行地区选择
- 检查是否有遗漏的选项
- 调整搜索关键词

### 步骤 10: 保存数据并关闭浏览器
**操作**:
```bash
# 获取最终截图
python scripts/sufu_interactive.py --action screenshot --screenshot-name "step12_final.png"

# 关闭浏览器
python scripts/sufu_interactive.py --action close
```

## 脚本操作命令参考

### 基础操作
- `--action open`: 打开页面
- `--action screenshot`: 截图
- `--action click`: 点击元素
- `--action click-coords`: 通过坐标点击
- `--action input`: 输入文本
- `--action wait`: 等待
- `--action wait-element`: 等待元素出现
- `--action script`: 执行JavaScript
- `--action close`: 关闭浏览器

### 常用参数
- `--url`: 要打开的URL
- `--selector`: CSS选择器
- `--text`: 要输入的文本
- `--x`, `--y`: 点击坐标
- `--timeout`: 等待超时时间（毫秒）
- `--wait-time`: 等待时间（毫秒）
- `--script`: JavaScript代码
- `--screenshot-name`: 截图文件名
- `--headless`: 是否无头模式（true/false）

## 注意事项
1. **截图优先**: 每次重要操作后都要截图，便于智能体分析当前状态
2. **等待时间**: 页面加载和异步操作需要适当的等待时间
3. **选择器灵活性**: 根据实际页面结构调整CSS选择器
4. **异常处理**: 如果操作失败，分析截图后调整策略
5. **数据验证**: 提取数据后必须验证其准确性和完整性

## 调试技巧
- 使用 `--headless false` 模式可以看到实际浏览器操作
- 每步操作后截图，便于回溯和分析
- 使用浏览器开发者工具获取准确的CSS选择器
- 记录每个步骤的输入和输出，便于问题定位


path: scripts/generate_and_send_monthly_report.py
content: #!/usr/bin/env python3
"""
生成并发送月报到飞书用户/群聊
支持发送PDF到用户个人或群聊
"""

import os
import sys
import logging
from datetime import datetime, timedelta

# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from scripts.feishu_enterprise_app import FeishuEnterpriseApp
from scripts.pdf_generator import PDFGenerator

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)


def main():
    """主函数：生成月报并发送"""
    print("=" * 60)
    print("招投标信息月报生成与发送")
    print("=" * 60)

    # 初始化
    app = FeishuEnterpriseApp()
    generator = PDFGenerator()

    # 检查配置
    if not app.app_id or not app.app_secret:
        print("❌ 未配置飞书应用凭证（appId/appSecret）")
        return False

    # 获取目标用户/群聊
    # 优先使用环境变量指定的接收者
    open_id = os.getenv('FEISHU_OPEN_ID')
    chat_id = os.getenv('FEISHU_CHAT_ID')

    if not open_id and not chat_id:
        print("⚠️ 未配置 FEISHU_OPEN_ID 或 FEISHU_CHAT_ID，将只生成PDF不发送")
        # 不直接退出，让后面可以生成PDF

    # 获取当前月份
    now = datetime.now()
    year = now.year
    month = now.month

    print(f"\n📅 正在生成 {year}年{month:02d}月 月报...")

    # 生成月度PDF（PDFGenerator默认输出到 daily 目录，需要改为 monthly）
    # 先生成到临时位置，然后再移动
    pdf_dir = os.path.join(os.path.dirname(generator.db_path), 'monthly')
    os.makedirs(pdf_dir, exist_ok=True)
    generator.output_dir = pdf_dir  # 临时修改输出目录

    pdf_file = generator.generate_monthly_report(year, month)
    if pdf_file:
        print(f"✅ PDF生成成功: {os.path.basename(pdf_file)} ({os.path.getsize(pdf_file) / 1024:.1f} KB)")
    else:
        print("❌ PDF生成失败")
        return False

    # 恢复输出目录
    generator.output_dir = os.path.join(os.path.dirname(generator.db_path), 'daily')

    # 查询月度数据
    first_day = datetime(year, month, 1).strftime('%Y-%m-%d')
    if month == 12:
        next_month_first_day = datetime(year + 1, 1, 1)
    else:
        next_month_first_day = datetime(year, month + 1, 1)
    last_day = (next_month_first_day - timedelta(days=1)).strftime('%Y-%m-%d')

    monthly_summary = generator._get_monthly_summary_data(first_day, last_day)
    if not monthly_summary:
        print("⚠️ 当月无数据")
        total = 0
    else:
        total = sum(monthly_summary.values())
        print(f"📊 查询到 {len(monthly_summary)} 个网站，共 {total} 条数据")

    # 发送PDF
    if open_id:
        print(f"\n📤 正在发送PDF到用户 {open_id}...")
        result = app.send_local_file(open_id, pdf_file, receive_id_type="open_id")
        if result:
            print(f"✅ 用户PDF发送成功！")
        else:
            print(f"❌ 用户PDF发送失败（详见日志）")

    if chat_id:
        print(f"\n📤 正在发送PDF到群聊 {chat_id}...")
        result = app.send_local_file(chat_id, pdf_file, receive_id_type="chat_id")
        if result:
            print(f"✅ 群聊PDF发送成功！")
        else:
            print(f"❌ 群聊PDF发送失败（详见日志）")

    if not open_id and not chat_id:
        print("\n📄 PDF已生成但未发送（未配置接收者）")

    return True


if __name__ == '__main__':
    success = main()
    sys.exit(0 if success else 1)

path: scripts/feishu_enterprise_app.py
content: #!/usr/bin/env python3
"""
飞书企业自建应用客户端
基于飞书开放平台官方文档实现
支持发送文件到用户或群聊
"""

import os
import json
import logging
from datetime import datetime
from typing import Optional, Dict, List

logger = logging.getLogger(__name__)

# 尝试导入requests，失败时使用urllib
try:
    import requests
except ImportError:
    import urllib.request as requests


def get_config():
    """从OpenClaw配置读取飞书凭证"""
    config_path = os.path.expanduser('~/.openclaw/openclaw.json')
    try:
        with open(config_path, 'r', encoding='utf-8') as f:
            config = json.load(f)
        feishu = config.get('channels', {}).get('feishu', {})
        account = feishu.get('accounts', {}).get('default', {})
        return account.get('appId'), account.get('appSecret')
    except Exception as e:
        logger.warning(f"读取飞书配置失败: {e}")
        return None, None


class FeishuEnterpriseApp:
    """飞书企业自建应用客户端"""

    def __init__(self, app_id: Optional[str] = None, app_secret: Optional[str] = None):
        """
        初始化飞书企业自建应用客户端

        Args:
            app_id: 飞书应用ID（可选，默认从OpenClaw配置读取）
            app_secret: 飞书应用Secret（可选，默认从OpenClaw配置读取）
        """
        # 优先使用传入的参数，否则从配置读取
        self.app_id = app_id
        self.app_secret = app_secret

        if not self.app_id or not self.app_secret:
            config_app_id, config_app_secret = get_config()
            self.app_id = self.app_id or config_app_id
            self.app_secret = self.app_secret or config_app_secret

        if not self.app_id or not self.app_secret:
            logger.error("未配置飞书应用凭证，请设置环境变量或OpenClaw配置")

        self.tenant_access_token = None
        self.base_url = "https://open.feishu.cn/open-apis"

    def get_tenant_access_token(self) -> Optional[str]:
        """获取 tenant_access_token"""
        if not self.app_id or not self.app_secret:
            logger.error("未配置飞书应用凭证")
            return None

        try:
            url = f"{self.base_url}/auth/v3/tenant_access_token/internal"
            headers = {"Content-Type": "application/json; charset=utf-8"}
            data = {"app_id": self.app_id, "app_secret": self.app_secret}

            response = requests.post(url, headers=headers, json=data, timeout=10)

            if response.status_code == 200:
                result = response.json()
                if result.get('code') == 0:
                    self.tenant_access_token = result['tenant_access_token']
                    logger.debug("获取 tenant_access_token 成功")
                    return self.tenant_access_token
                else:
                    logger.error(f"获取 tenant_access_token 失败: {result.get('msg')}")
                    return None
            else:
                logger.error(f"获取 tenant_access_token 失败: HTTP {response.status_code}")
                return None

        except Exception as e:
            logger.error(f"获取 tenant_access_token 异常: {e}")
            return None

    def _ensure_token(self) -> bool:
        """确保有有效的token"""
        if not self.tenant_access_token:
            return bool(self.get_tenant_access_token())
        return True

    def upload_file(self, file_path: str, parent_type: str = "explorer") -> Optional[str]:
        """
        上传文件到飞书云空间

        Args:
            file_path: 本地文件路径
            parent_type: 父节点类型，默认 "explorer"（云文档）

        Returns:
            file_token，失败返回None
        """
        if not self._ensure_token():
            return None

        try:
            url = f"{self.base_url}/drive/v1/medias/upload_all"
            headers = {"Authorization": f"Bearer {self.tenant_access_token}"}

            filename = os.path.basename(file_path)
            file_size = os.path.getsize(file_path)

            # 根据文件扩展名确定文件类型（飞书API用字符串类型如"pdf"）
            file_ext = os.path.splitext(filename)[1].lower()
            file_type_map = {
                '.pdf': 'pdf',
                '.doc': 'doc',
                '.docx': 'docx',
                '.xls': 'xls',
                '.xlsx': 'xlsx',
                '.png': 'image',
                '.jpg': 'image',
                '.jpeg': 'image',
                '.gif': 'image',
                '.txt': 'text',
            }
            file_type = file_type_map.get(file_ext, 'file')

            with open(file_path, 'rb') as f:
                file_content = f.read()

            # 关键：使用 application/octet-stream，file_type 用字符串如 "pdf"
            files = {'file': (filename, file_content, 'application/octet-stream')}
            data = {
                'file_name': filename,
                'parent_type': parent_type,
                'size': str(file_size),
                'file_type': file_type  # 用字符串如 "pdf"，不要用数字如 "12"
            }

            response = requests.post(url, headers=headers, files=files, data=data, timeout=60)

            if response.status_code == 200:
                result = response.json()
                if result.get('code') == 0:
                    file_token = result['data']['file_token']
                    logger.info(f"文件上传成功: {filename} -> {file_token}")
                    return file_token
                else:
                    logger.error(f"文件上传失败: {result.get('msg')}")
                    return None
            else:
                logger.error(f"文件上传失败: HTTP {response.status_code}")
                return None

        except Exception as e:
            logger.error(f"上传文件异常: {e}")
            return None

    def send_file_message(self, receive_id: str, file_path: str,
                          receive_id_type: str = "open_id",
                          file_name: Optional[str] = None) -> bool:
        """
        发送文件消息到用户或群聊

        关键：必须使用IM API上传文件（/im/v1/files），获取file_key用于消息发送

        Args:
            receive_id: 接收者ID（open_id 或 chat_id）
            file_path: 本地文件路径
            receive_id_type: 接收者ID类型，默认 "open_id"（用户），也可设为 "chat_id"（群聊）
            file_name: 文件名（可选，默认使用原文件名）

        Returns:
            是否发送成功
        """
        if not self._ensure_token():
            return False

        # 1. 使用IM API上传文件获取 file_key
        logger.info(f"上传文件: {file_path}")
        file_key = self._upload_im_file(file_path)
        if not file_key:
            logger.error("文件上传失败，无法发送")
            return False

        # 2. 发送文件消息
        logger.info(f"发送文件消息: receive_id={receive_id}, file_key={file_key}")

        try:
            url = f"{self.base_url}/im/v1/messages?receive_id_type={receive_id_type}"
            headers = {
                "Authorization": f"Bearer {self.tenant_access_token}",
                "Content-Type": "application/json; charset=utf-8"
            }

            # 使用 file_key（不是 file_token）
            msg_content = json.dumps({"file_key": file_key})
            msg_data = {
                "receive_id": receive_id,
                "msg_type": "file",
                "content": msg_content,
                "file_name": file_name or os.path.basename(file_path)
            }

            response = requests.post(url, headers=headers, json=msg_data, timeout=10)

            if response.status_code == 200:
                result = response.json()
                if result.get('code') == 0:
                    logger.info(f"文件消息发送成功: {file_path}")
                    return True
                else:
                    logger.error(f"文件消息发送失败: {result.get('msg')} (code={result.get('code')})")
                    return False
            else:
                logger.error(f"文件消息发送失败: HTTP {response.status_code}")
                return False

        except Exception as e:
            logger.error(f"发送文件消息异常: {e}")
            return False

    def _upload_im_file(self, file_path: str) -> Optional[str]:
        """
        使用IM API上传文件，获取file_key

        Args:
            file_path: 本地文件路径

        Returns:
            file_key，失败返回None
        """
        try:
            url = f"{self.base_url}/im/v1/files"
            filename = os.path.basename(file_path)
            file_size = os.path.getsize(file_path)

            # 根据扩展名确定file_type
            file_ext = os.path.splitext(filename)[1].lower()
            file_type_map = {
                '.pdf': 'pdf',
                '.doc': 'doc',
                '.docx': 'docx',
                '.xls': 'xls',
                '.xlsx': 'xlsx',
                '.png': 'png',
                '.jpg': 'jpg',
                '.jpeg': 'jpeg',
                '.gif': 'gif',
                '.mp4': 'mp4',
                '.mp3': 'mp3',
            }
            file_type = file_type_map.get(file_ext, 'pdf')

            with open(file_path, 'rb') as f:
                file_content = f.read()

            files = {'file': (filename, file_content, 'application/octet-stream')}
            data = {
                'file_name': filename,
                'file_type': file_type,
                'file_size': str(file_size)
            }
            headers = {"Authorization": f"Bearer {self.tenant_access_token}"}

            response = requests.post(url, headers=headers, files=files, data=data, timeout=60)

            if response.status_code == 200:
                result = response.json()
                if result.get('code') == 0:
                    file_key = result['data']['file_key']
                    logger.info(f"IM文件上传成功: {filename} -> {file_key}")
                    return file_key
                else:
                    logger.error(f"IM文件上传失败: {result.get('msg')}")
                    return None
            else:
                logger.error(f"IM文件上传失败: HTTP {response.status_code}")
                return None

        except Exception as e:
            logger.error(f"IM文件上传异常: {e}")
            return None

    def send_text_message(self, receive_id: str, text: str,
                          receive_id_type: str = "open_id") -> bool:
        """
        发送文本消息

        Args:
            receive_id: 接收者ID
            text: 消息内容
            receive_id_type: 接收者ID类型，默认 "open_id"

        Returns:
            是否发送成功
        """
        if not self._ensure_token():
            return False

        try:
            url = f"{self.base_url}/im/v1/messages?receive_id_type={receive_id_type}"
            headers = {
                "Authorization": f"Bearer {self.tenant_access_token}",
                "Content-Type": "application/json; charset=utf-8"
            }

            msg_data = {
                "receive_id": receive_id,
                "msg_type": "text",
                "content": json.dumps({"text": text})
            }

            response = requests.post(url, headers=headers, json=msg_data, timeout=10)

            if response.status_code == 200:
                result = response.json()
                if result.get('code') == 0:
                    logger.info(f"文本消息发送成功")
                    return True
                else:
                    logger.error(f"文本消息发送失败: {result.get('msg')}")
                    return False
            else:
                logger.error(f"文本消息发送失败: HTTP {response.status_code}")
                return False

        except Exception as e:
            logger.error(f"发送文本消息异常: {e}")
            return False

    def send_local_file(self, receive_id: str, file_path: str,
                        receive_id_type: str = "open_id",
                        file_name: Optional[str] = None) -> bool:
        """
        直接发送本地文件到飞书用户/群聊（封装了上传和发送两步）

        Args:
            receive_id: 接收者ID（open_id 或 chat_id）
            file_path: 本地文件路径
            receive_id_type: 接收者ID类型，默认 "open_id"
            file_name: 文件名（可选）

        Returns:
            是否发送成功
        """
        return self.send_file_message(receive_id, file_path, receive_id_type, file_name)


if __name__ == '__main__':
    # 测试
    app = FeishuEnterpriseApp()

    # 测试获取token
    [REDACTED_SECRET])
    if [REDACTED_SECRET]"✅ Token获取成功")
    else:
        print("❌ Token获取失败")

path: scripts/pdf_generator.py
content: #!/usr/bin/env python3
"""
PDF报告生成器（修复版）
修复中文乱码问题，支持中文字体
"""

import os
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.platypus.flowables import HRFlowable

import sqlite3
import logging
import re

logger = logging.getLogger(__name__)

# 来源网站固定排序
SITE_ORDER = [
    '盐城市政府采购网',
    '苏服采',
    '城南新区公共资源交易网',
    '开发区公共资源交易网',
    '盐城市大数据集团',
    '盐城市都市建设投资集团',
    '盐城市东方集团',
    '江苏世纪新城',
    '经开城发集团',
    '悦达集团',
]


class PDFGenerator:
    """PDF报告生成器（修复版）"""

    # 网站配置映射
    SITE_CONFIG = {
        '盐城市政府采购网': {
            'crawl_strategy': 'dataproxy接口',
            'filter_strategy': '区域关键词筛选（盐南高新区、经开区）',
            'crawl_method': 'requests + 正则解析',
            'categories': '13个子版块（采购意向、公告、中标等）'
        },
        '开发区公共资源交易网': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"经开区"（无需筛选）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '招标公告、中标公告、采购公告等'
        },
        '城南新区公共资源交易网': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"盐南高新区"（无需筛选）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '交易信息'
        },
        '江苏世纪新城': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"区域内"（跳过关键词匹配）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '招标租赁'
        },
        '盐城市大数据集团': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"区域内"（跳过关键词匹配）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '新闻资讯'
        },
        '盐城市东方集团': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"区域内"（跳过关键词匹配）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '招标采购'
        },
        '盐城市都市建设投资集团': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"区域内"（跳过关键词匹配）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '通知、招标'
        },
        '经开城发集团': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"区域内"（跳过关键词匹配）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '招标信息、中标信息'
        },
        '悦达集团': {
            'crawl_strategy': '全量采集',
            'filter_strategy': '标记为"区域内"（跳过关键词匹配）',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '12个子分类（工程、货物、服务等）'
        },
        '苏服采': {
            'crawl_strategy': '地区筛选后采集',
            'filter_strategy': '选择区域（经开区、盐南高新区）',
            'crawl_method': 'Playwright浏览器自动化',
            'categories': '招标采购'
        },
        '盐城市人民政府网': {
            'crawl_strategy': '区域关键词筛选',
            'filter_strategy': '匹配"盐南高新区"、"经开区"',
            'crawl_method': 'requests + BeautifulSoup + iframe',
            'categories': '政府信息公开'
        },
        '全国招标采购公共服务平台': {
            'crawl_strategy': '区域关键词筛选',
            'filter_strategy': '匹配"盐南高新区"、"经开区"',
            'crawl_method': 'requests + BeautifulSoup',
            'categories': '招标/采购公告、中标/成交公告等'
        }
    }

    def __init__(self, db_path: str = './招投标数据/history.db'):
        self.db_path = db_path
        self.output_dir = './招投标数据/daily'
        os.makedirs(self.output_dir, exist_ok=True)

        # 注册中文字体
        self._register_fonts()
        self.chinese_font = self._get_chinese_font()

    def _register_fonts(self):
        """注册中文字体"""
        try:
            # 尝试使用系统中的中文字体
            font_paths = [
                # Linux
                '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',  # 微米黑
                '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc',  # 正黑
                '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf',  # Droid Sans
                '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',  # Noto Sans CJK
                '/usr/share/fonts/truetype/arphic/uming.ttc',  # 文鼎PL简中黑
                # macOS
                '/System/Library/Fonts/PingFang.ttc',
                '/System/Library/Fonts/STHeiti Light.ttc',
                # Windows
                'C:/Windows/Fonts/msyh.ttc',  # 微软雅黑
                'C:/Windows/Fonts/simhei.ttf',  # 黑体
                'C:/Windows/Fonts/simsun.ttc',  # 宋体
            ]

            for font_path in font_paths:
                if os.path.exists(font_path):
                    pdfmetrics.registerFont(TTFont('ChineseFont', font_path))
                    logger.info(f"✅ 成功注册中文字体: {font_path}")
                    return

            # 如果找不到系统字体，尝试使用内置字体
            logger.warning("⚠️ 未找到系统中的中文字体，尝试使用备用方案")
            self._register_backup_fonts()

        except Exception as e:
            logger.warning(f"⚠️ 注册中文字体失败: {e}")
            self._register_backup_fonts()

    def _register_backup_fonts(self):
        """注册备用字体"""
        try:
            # 尝试使用reportlab内置的字体（可能不支持中文）
            logger.warning("使用备用字体方案，中文可能显示为乱码")
            # 这里不注册任何字体，让reportlab使用默认字体
        except Exception as e:
            logger.warning(f"注册备用字体失败: {e}")

    def _get_chinese_font(self) -> str:
        """获取中文字体名称"""
        try:
            # 检查字体是否已注册
            if 'ChineseFont' in pdfmetrics.getRegisteredFontNames():
                return 'ChineseFont'
            else:
                logger.warning("未找到中文字体，使用默认字体（中文可能显示为乱码）")
                return 'Helvetica'  # 默认字体
        except Exception as e:
            logger.warning(f"获取中文字体失败: {e}")
            return 'Helvetica'

    def generate_daily_report(self, date: str) -> Optional[str]:
        """生成日报PDF（修复版）

        Args:
            date: 日期字符串，格式: YYYY-MM-DD

        Returns:
            PDF文件路径，如果生成失败返回None
        """
        try:
            # 查询数据
            daily_summary = self._get_daily_summary(date)
            monthly_summary = self._get_monthly_summary(date)
            daily_projects = self._get_daily_projects(date)
            monthly_projects = self._get_monthly_projects(date)

            if not daily_projects and not monthly_projects:
                logger.warning(f"日期 {date} 没有数据，跳过生成PDF")
                return None

            # 生成PDF文件名
            filename = f"招投标信息日报_{date}.pdf"
            filepath = os.path.join(self.output_dir, filename)

            # 创建PDF文档
            doc = SimpleDocTemplate(
                filepath,
                pagesize=A4,
                rightMargin=1.5*cm,
                leftMargin=1.5*cm,
                topMargin=1.5*cm,
                bottomMargin=1.5*cm
            )

            # 构建内容
            story = []

            # 第1页：招标信息概况
            story.extend(self._create_summary_page(date, daily_summary, monthly_summary))

            # 第2页：当日清单
            if daily_projects:
                story.append(PageBreak())
                story.extend(self._create_daily_list_page(date, daily_projects))

            # 第3页开始：当月分平台清单
            if monthly_projects:
                story.append(PageBreak())
                story.extend(self._create_monthly_platform_pages(date, monthly_projects))

            # 生成PDF
            doc.build(story)

            logger.info(f"✅ PDF报告生成成功: {filepath}")
            return filepath

        except Exception as e:
            logger.error(f"❌ 生成PDF报告失败: {e}")
            import traceback
            traceback.print_exc()
            return None

    def generate_monthly_report(self, year: int, month: int) -> Optional[str]:
        """生成月度报告PDF

        Args:
            year: 年份
            month: 月份

        Returns:
            PDF文件路径，如果生成失败返回None
        """
        try:
            # 计算月份的第一天和最后一天
            first_day = datetime(year, month, 1).strftime('%Y-%m-%d')

            if month == 12:
                next_month_first_day = datetime(year + 1, 1, 1)
            else:
                next_month_first_day = datetime(year, month + 1, 1)

            last_day = (next_month_first_day - timedelta(days=1)).strftime('%Y-%m-%d')
            report_date = f"{year}年{month}月"

            logger.info(f"生成月度报告: {report_date} ({first_day} ~ {last_day})")

            # 查询数据
            monthly_summary = self._get_monthly_summary_data(first_day, last_day)
            monthly_projects = self._get_monthly_projects_by_site(first_day, last_day)

            if not monthly_projects:
                logger.warning(f"{report_date} 没有数据，跳过生成PDF")
                return None

            # 生成PDF文件名
            filename = f"招投标信息月报_{year}年{month:02d}月.pdf"
            filepath = os.path.join(self.output_dir, filename)

            # 创建PDF文档
            doc = SimpleDocTemplate(
                filepath,
                pagesize=A4,
                rightMargin=1.5*cm,
                leftMargin=1.5*cm,
                topMargin=1.5*cm,
                bottomMargin=1.5*cm
            )

            # 构建内容
            story = []

            # 第1页：月度概况
            story.extend(self._create_monthly_summary_page(report_date, first_day, last_day, monthly_summary))

            # 第2页开始：分平台清单
            story.append(PageBreak())
            story.extend(self._create_monthly_platform_pages(report_date, monthly_projects))

            # 生成PDF
            doc.build(story)

            logger.info(f"✅ 月度PDF报告生成成功: {filepath}")
            return filepath

        except Exception as e:
            logger.error(f"❌ 生成月度PDF报告失败: {e}")
            import traceback
            traceback.print_exc()
            return None

    def _get_monthly_summary_data(self, first_day: str, last_day: str) -> Dict:
        """获取月度汇总数据（按网站统计）"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        cursor.execute('''
            SELECT
                source_site,
                COUNT(*) as count
            FROM bidding_projects
            WHERE publish_date BETWEEN ? AND ?
            GROUP BY source_site
        ''', (first_day, last_day))

        summary = {}
        for site, count in cursor.fetchall():
            summary[site] = count

        conn.close()
        return summary

    def _sort_sites(self, sites: List[str]) -> List[str]:
        """按 SITE_ORDER 排序网站列表"""
        def sort_key(site):
            if site in SITE_ORDER:
                return SITE_ORDER.index(site)
            return 999
        return sorted(sites, key=sort_key)

    def _get_monthly_projects_by_site(self, first_day: str, last_day: str) -> Dict[str, List[Dict]]:
        """获取月度项目数据（按网站分类）"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        cursor.execute('''
            SELECT
                source_site,
                publish_date,
                project_name,
                detail_url
            FROM bidding_projects
            WHERE publish_date BETWEEN ? AND ?
            ORDER BY source_site, publish_date DESC, project_name
        ''', (first_day, last_day))

        columns = ['source_site', 'publish_date', 'project_name', 'detail_url']

        # 按网站分类
        projects_by_site = {}
        for row in cursor.fetchall():
            project = dict(zip(columns, row))
            site = project['source_site']

            if site not in projects_by_site:
                projects_by_site[site] = []
            projects_by_site[site].append(project)

        conn.close()
        return projects_by_site

    def _get_daily_summary(self, date: str) -> Dict:
        """获取当日汇总数据（按网站统计）"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        cursor.execute('''
            SELECT
                source_site,
                COUNT(*) as count
            FROM bidding_projects
            WHERE publish_date = ?
            GROUP BY source_site
            ORDER BY count DESC
        ''', (date,))

        summary = {}
        for site, count in cursor.fetchall():
            summary[site] = count

        conn.close()
        return summary

    def _get_monthly_summary(self, date: str) -> Dict:
        """获取当月汇总数据（按网站统计）"""
        # 计算当月第一天和最后一天
        date_obj = datetime.strptime(date, '%Y-%m-%d')
        first_day = date_obj.replace(day=1).strftime('%Y-%m-%d')

        # 计算下个月的第一天，然后减一天得到当月的最后一天
        if date_obj.month == 12:
            next_month_first_day = date_obj.replace(year=date_obj.year + 1, month=1, day=1)
        else:
            next_month_first_day = date_obj.replace(month=date_obj.month + 1, day=1)
        last_day = (next_month_first_day - timedelta(days=1)).strftime('%Y-%m-%d')

        return self._get_monthly_summary_data(first_day, last_day)

    def _get_daily_projects(self, date: str) -> List[Dict]:
        """获取当日项目数据"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        cursor.execute('''
            SELECT
                source_site,
                publish_date,
                project_name,
                detail_url
            FROM bidding_projects
            WHERE publish_date = ?
            ORDER BY source_site, publish_date, project_name
        ''', (date,))

        columns = ['source_site', 'publish_date', 'project_name', 'detail_url']

        projects = []
        for row in cursor.fetchall():
            project = dict(zip(columns, row))
            projects.append(project)

        conn.close()
        return projects

    def _get_monthly_projects(self, date: str) -> Dict[str, List[Dict]]:
        """获取当月项目数据（按网站分类）"""
        # 计算当月第一天和最后一天
        date_obj = datetime.strptime(date, '%Y-%m-%d')
        first_day = date_obj.replace(day=1).strftime('%Y-%m-%d')

        if date_obj.month == 12:
            next_month_first_day = date_obj.replace(year=date_obj.year + 1, month=1, day=1)
        else:
            next_month_first_day = date_obj.replace(month=date_obj.month + 1, day=1)
        last_day = (next_month_first_day - timedelta(days=1)).strftime('%Y-%m-%d')

        return self._get_monthly_projects_by_site(first_day, last_day)

    def _create_summary_page(self, date: str, daily_summary: Dict, monthly_summary: Dict) -> List:
        """创建第1页：招标信息概况"""
        page = []

        # 标题
        title_style = ParagraphStyle(
            'Title',
            parent=getSampleStyleSheet()['Heading1'],
            fontName=self.chinese_font,
            fontSize=22,
            textColor=colors.darkblue,
            alignment=TA_CENTER,
            spaceAfter=0.5*cm
        )
        page.append(Paragraph("招投标信息概况", title_style))

        # 日期
        date_style = ParagraphStyle(
            'Date',
            parent=getSampleStyleSheet()['Normal'],
            fontName=self.chinese_font,
            fontSize=12,
            alignment=TA_CENTER,
            spaceAfter=1*cm
        )
        page.append(Paragraph(f"报告日期：{date}", date_style))

        # 单元格样式（用于触发自动换行）
        cell_center_style = ParagraphStyle('CellCenter', fontName=self.chinese_font, fontSize=9, alignment=TA_CENTER, leading=11, wordWrap='CJK')
        cell_left_style = ParagraphStyle('CellLeft', fontName=self.chinese_font, fontSize=9, alignment=TA_LEFT, leading=11, wordWrap='CJK')
        header_style = ParagraphStyle('Header', fontName=self.chinese_font, fontSize=10, alignment=TA_CENTER, leading=12, wordWrap='CJK')

        # 汇总表格（包含爬虫策略和筛选策略）
        table_data = [[
            Paragraph('网站名称', header_style),
            Paragraph('当日条数', header_style),
            Paragraph('当月条数', header_style),
            Paragraph('爬虫策略', header_style),
            Paragraph('筛选策略', header_style)
        ]]

        # 获取所有网站（合并当日和当月的网站）
        all_sites = set(daily_summary.keys()) | set(monthly_summary.keys())

        for site in sorted(all_sites, key=lambda x: -daily_summary.get(x, 0)):
            daily_count = daily_summary.get(site, 0)
            monthly_count = monthly_summary.get(site, 0)

            # 获取网站配置
            config = self.SITE_CONFIG.get(site, {})
            crawl_strategy = config.get('crawl_strategy', '-')
            filter_strategy = config.get('filter_strategy', '-')

            table_data.append([
                Paragraph(site, cell_center_style),
                Paragraph(str(daily_count), cell_center_style),
                Paragraph(str(monthly_count), cell_center_style),
                Paragraph(crawl_strategy, cell_left_style),
                Paragraph(filter_strategy, cell_left_style)
            ])

        # 创建表格（调整列宽以适应新增列）
        table = Table(table_data, colWidths=[5*cm, 2*cm, 2*cm, 3.5*cm, 4*cm])
        table.setStyle(TableStyle([
            ('VALIGN', (0, 0), (-1, -1), 'TOP'),
            ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
            ('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),
            ('TOPPADDING', (0, 0), (-1, -1), 4),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
            ('LEFTPADDING', (0, 0), (-1, -1), 4),
            ('RIGHTPADDING', (0, 0), (-1, -1), 4),
        ]))

        page.append(table)

        # 添加总统计
        total_daily = sum(daily_summary.values())
        total_monthly = sum(monthly_summary.values())

        total_style = ParagraphStyle(
            'Total',
            parent=getSampleStyleSheet()['Normal'],
            fontName=self.chinese_font,
            fontSize=11,
            alignment=TA_LEFT,
            spaceBefore=0.5*cm
        )

        page.append(Paragraph(f"<b>当日总计：</b>{total_daily} 条", total_style))
        page.append(Paragraph(f"<b>当月总计：</b>{total_monthly} 条", total_style))

        return page

    def _create_monthly_summary_page(self, report_date: str, first_day: str, last_day: str, monthly_summary: Dict) -> List:
        """创建月度概况页"""
        page = []

        # 标题
        title_style = ParagraphStyle(
            'Title',
            parent=getSampleStyleSheet()['Heading1'],
            fontName=self.chinese_font,
            fontSize=22,
            textColor=colors.darkblue,
            alignment=TA_CENTER,
            spaceAfter=0.5*cm
        )
        page.append(Paragraph("招投标信息月度报告", title_style))

        # 日期范围
        date_style = ParagraphStyle(
            'Date',
            parent=getSampleStyleSheet()['Normal'],
            fontName=self.chinese_font,
            fontSize=12,
            alignment=TA_CENTER,
            spaceAfter=0.5*cm
        )
        page.append(Paragraph(f"报告时间：{report_date}", date_style))
        page.append(Paragraph(f"统计范围：{first_day} 至 {last_day}", date_style))
        page.append(Spacer(1, 0.3*cm))

        # 计算今天和昨天
        today = datetime.now().strftime('%Y-%m-%d')
        yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')

        # 单元格样式（用于触发自动换行）
        cell_center_style = ParagraphStyle('CellCenter', fontName=self.chinese_font, fontSize=9, alignment=TA_CENTER, leading=11, wordWrap='CJK')
        cell_left_style = ParagraphStyle('CellLeft', fontName=self.chinese_font, fontSize=9, alignment=TA_LEFT, leading=11, wordWrap='CJK')
        header_style = ParagraphStyle('Header', fontName=self.chinese_font, fontSize=10, alignment=TA_CENTER, leading=12, wordWrap='CJK', textColor=colors.white)
        sum_cell_style = ParagraphStyle('SumCell', fontName=self.chinese_font, fontSize=10, alignment=TA_CENTER, leading=12, wordWrap='CJK')

        # 汇总表格：网站名称、数据条数、当日数据、前一日数据
        table_data = [[
            Paragraph('网站名称', header_style),
            Paragraph('数据条数', header_style),
            Paragraph('当日数据', header_style),
            Paragraph('前一日数据', header_style),
            Paragraph('爬虫策略', header_style),
            Paragraph('筛选策略', header_style)
        ]]

        # 按 SITE_ORDER 排序，不在列表中的排后面
        def sort_key(item):
            site, count = item
            if site in SITE_ORDER:
                return SITE_ORDER.index(site)
            return 999

        sorted_items = sorted(monthly_summary.items(), key=sort_key)

        total_count = 0
        total_today = 0
        total_yesterday = 0

        for site, count in sorted_items:
            today_count = self._get_daily_count_for_site(site, today)
            yesterday_count = self._get_daily_count_for_site(site, yesterday)

            # 获取网站配置
            config = self.SITE_CONFIG.get(site, {})
            crawl_strategy = config.get('crawl_strategy', '-')
            filter_strategy = config.get('filter_strategy', '-')

            table_data.append([
                Paragraph(site, cell_center_style),
                Paragraph(str(count), cell_center_style),
                Paragraph(str(today_count), cell_center_style),
                Paragraph(str(yesterday_count), cell_center_style),
                Paragraph(crawl_strategy, cell_left_style),
                Paragraph(filter_strategy, cell_left_style)
            ])

            total_count += count
            total_today += today_count
            total_yesterday += yesterday_count

        # 添加求和行
        table_data.append([
            Paragraph('合计', sum_cell_style),
            Paragraph(str(total_count), sum_cell_style),
            Paragraph(str(total_today), sum_cell_style),
            Paragraph(str(total_yesterday), sum_cell_style),
            Paragraph('-', sum_cell_style),
            Paragraph('-', sum_cell_style)
        ])

        # 创建表格
        table = Table(table_data, colWidths=[3.5*cm, 2*cm, 2*cm, 2*cm, 2.5*cm, 3.5*cm])
        table.setStyle(TableStyle([
            ('VALIGN', (0, 0), (-1, -1), 'TOP'),
            ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
            ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4e79')),
            # 求和行样式
            ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#d9e2f3')),
            ('ALIGN', (4, 1), (5, -1), 'LEFT'),
            ('TOPPADDING', (0, 0), (-1, -1), 4),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
            ('LEFTPADDING', (0, 0), (-1, -1), 4),
            ('RIGHTPADDING', (0, 0), (-1, -1), 4),
        ]))

        page.append(table)

        return page

    def _get_daily_count_for_site(self, site: str, date: str) -> int:
        """获取指定网站指定日期的数据条数"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        cursor.execute('''
            SELECT COUNT(*) FROM bidding_projects
            WHERE source_site = ? AND publish_date = ?
        ''', (site, date))
        count = cursor.fetchone()[0]
        conn.close()
        return count

    def _get_monthly_summary_data_by_date(self, date: str) -> Dict:
        """获取指定日期所在月份的汇总数据（按网站统计）"""
        date_obj = datetime.strptime(date, '%Y-%m-%d')
        first_day = date_obj.replace(day=1).strftime('%Y-%m-%d')

        if date_obj.month == 12:
            next_month_first_day = date_obj.replace(year=date_obj.year + 1, month=1, day=1)
        else:
            next_month_first_day = date_obj.replace(month=date_obj.month + 1, day=1)
        last_day = (next_month_first_day - timedelta(days=1)).strftime('%Y-%m-%d')

        return self._get_monthly_summary_data(first_day, last_day)

    def _create_daily_list_page(self, date: str, projects: List[Dict]) -> List:
        """创建第2页：当日清单"""
        page = []

        # 标题
        title_style = ParagraphStyle(
            'Title',
            parent=getSampleStyleSheet()['Heading1'],
            fontName=self.chinese_font,
            fontSize=18,
            textColor=colors.darkblue,
            alignment=TA_CENTER,
            spaceAfter=0.5*cm
        )
        page.append(Paragraph(f"当日招标信息清单（{date}）", title_style))

        # 单元格样式（用于触发自动换行）
        cell_style = ParagraphStyle('Cell', fontName=self.chinese_font, fontSize=9, alignment=TA_LEFT, leading=11, wordWrap='CJK')
        header_style = ParagraphStyle('Header', fontName=self.chinese_font, fontSize=10, alignment=TA_CENTER, leading=12, wordWrap='CJK')
        link_style = ParagraphStyle('Link', fontName=self.chinese_font, fontSize=8, alignment=TA_LEFT, leading=10, wordWrap='LTR', textColor=colors.blue)

        # 创建表格数据
        table_data = [[
            Paragraph('网站', header_style),
            Paragraph('日期', header_style),
            Paragraph('标题', header_style),
            Paragraph('链接', header_style)
        ]]

        for project in projects:
            url = project['detail_url'] or ''

            # 项目名称用 Paragraph 触发自动换行
            title_para = Paragraph(project['project_name'], cell_style)

            # 链接用可点击的 Paragraph
            if url:
                link_para = Paragraph(f'<link href="{url}" color="blue" underline="0"><u>{url}</u></link>', link_style)
            else:
                link_para = Paragraph('-', link_style)

            table_data.append([
                Paragraph(project['source_site'], cell_style),
                Paragraph(project['publish_date'], cell_style),
                title_para,
                link_para
            ])

        # 创建表格
        table = Table(table_data, colWidths=[4*cm, 2.5*cm, 6*cm, 2.5*cm])
        table.setStyle(TableStyle([
            ('VALIGN', (0, 0), (-1, -1), 'TOP'),
            ('GRID', (0, 0), (-1, -1), 0.3, colors.grey),
            ('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),
            ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.whitesmoke]),
            ('TOPPADDING', (0, 0), (-1, -1), 4),
            ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
            ('LEFTPADDING', (0, 0), (-1, -1), 4),
            ('RIGHTPADDING', (0, 0), (-1, -1), 4),
        ]))

        page.append(table)

        return page

    def _create_monthly_platform_pages(self, report_date: str, projects_by_site: Dict[str, List[Dict]]) -> List:
        """创建第3页开始：当月分平台清单（按 SITE_ORDER 排序，网址为超链接）"""
        pages = []

        # 按 SITE_ORDER 排序
        def sort_key(site):
            if site in SITE_ORDER:
                return SITE_ORDER.index(site)
            return 999

        sorted_sites = sorted(projects_by_site.keys(), key=sort_key)

        for idx, site in enumerate(sorted_sites):
            projects = projects_by_site[site]
            page = []

            # 标题
            title_style = ParagraphStyle(
                'Title',
                parent=getSampleStyleSheet()['Heading1'],
                fontName=self.chinese_font,
                fontSize=16,
                textColor=colors.darkblue,
                alignment=TA_CENTER,
                spaceAfter=0.5*cm
            )
            page.append(Paragraph(f"{site}（共{len(projects)}条）", title_style))

            # 单元格样式（用于触发自动换行）
            cell_style = ParagraphStyle('Cell', fontName=self.chinese_font, fontSize=9, alignment=TA_LEFT, leading=11, wordWrap='CJK')
            header_style = ParagraphStyle('Header', fontName=self.chinese_font, fontSize=10, alignment=TA_CENTER, leading=12, wordWrap='CJK', textColor=colors.white)
            link_cell_style = ParagraphStyle(
                'LinkCell',
                fontName=self.chinese_font,
                fontSize=8,
                textColor=colors.blue,
                leading=10,
                wordWrap='LTR',
            )

            # 创建表格数据
            table_data = [[
                Paragraph('网站', header_style),
                Paragraph('日期', header_style),
                Paragraph('项目名称', header_style),
                Paragraph('链接', header_style)
            ]]

            for project in projects:
                url = project['detail_url'] or ''

                # 项目名称用 Paragraph 包裹，自动换行
                name_para = Paragraph(project['project_name'], cell_style)

                # 链接列用 Paragraph 包裹，实现可点击超链接
                if url:
                    link_para = Paragraph(
                        f"<link href='{url}' color='blue' underline='yes'>{url}</link>",
                        link_cell_style
                    )
                else:
                    link_para = Paragraph('-', link_cell_style)

                table_data.append([
                    Paragraph(project['source_site'], cell_style),
                    Paragraph(project['publish_date'], cell_style),
                    name_para,
                    link_para
                ])

            # 创建表格（网站3cm，日期2.5cm，项目名称7cm，链接4cm）
            table = Table(table_data, colWidths=[3*cm, 2.5*cm, 7*cm, 4*cm], repeatRows=1)
            table.setStyle(TableStyle([
                ('VALIGN', (0, 0), (-1, -1), 'TOP'),
                ('GRID', (0, 0), (-1, -1), 0.3, colors.grey),
                ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1f4e79')),
                ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.whitesmoke]),
                ('TOPPADDING', (0, 0), (-1, -1), 4),
                ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
                ('LEFTPADDING', (0, 0), (-1, -1), 4),
                ('RIGHTPADDING', (0, 0), (-1, -1), 4),
            ]))

            page.append(table)

            pages.extend(page)

            # 如果不是最后一个网站，添加分页
            if idx < len(sorted_sites) - 1:
                pages.append(PageBreak())

        return pages


if __name__ == '__main__':
    import sys

    generator = PDFGenerator()

    # 根据参数选择生成日报还是月报
    if len(sys.argv) > 1 and sys.argv[1] == 'monthly':
        # 生成月报
        year = int(sys.argv[2]) if len(sys.argv) > 2 else 2026
        month = int(sys.argv[3]) if len(sys.argv) > 3 else 4
        filepath = generator.generate_monthly_report(year, month)
        print(f"{'生成成功' if filepath else '生成失败'}: {filepath}")
    else:
        # 生成日报
        today = datetime.now().strftime('%Y-%m-%d')
        filepath = generator.generate_daily_report(today)
        print(f"{'生成成功' if filepath else '生成失败'}: {filepath}")


path: scripts/feishu_notifier.py
content: #!/usr/bin/env python3
"""
飞书通知器
支持文本、Markdown、文件发送
"""

import os
import json
import logging
from datetime import datetime
from typing import Optional, List, Dict

logger = logging.getLogger(__name__)

try:
    import requests
except ImportError:
    import urllib.request as requests


class FeishuNotifier:
    """飞书通知器（支持Webhook和开放平台API）"""

    def __init__(self, webhook_url: Optional[str] = None):
        """
        初始化飞书通知器

        Args:
            webhook_url: 飞书机器人Webhook URL（可选，用于文本快捷发送）
        """
        self.webhook_url = webhook_url or os.getenv('FEISHU_WEBHOOK_URL')

    def send_text_message(self, text: str) -> bool:
        """发送文本消息（通过Webhook）"""
        if not self.webhook_url:
            logger.warning("未配置飞书Webhook URL")
            return False

        try:
            data = {"msg_type": "text", "content": {"text": text}}
            response = requests.post(
                self.webhook_url, json=data,
                headers={'Content-Type': 'application/json'}, timeout=10
            )
            result = response.json()
            if result.get('StatusCode') == 0:
                logger.info("文本消息发送成功")
                return True
            else:
                logger.error(f"发送失败: {result.get('StatusMessage')}")
                return False
        except Exception as e:
            logger.error(f"发送异常: {e}")
            return False

    def send_markdown_message(self, text: str) -> bool:
        """发送Markdown格式消息（通过Webhook）"""
        if not self.webhook_url:
            logger.warning("未配置飞书Webhook URL")
            return False

        try:
            data = {
                "msg_type": "interactive",
                "card": {
                    "config": {"wide_screen_mode": True},
                    "elements": [{"tag": "div", "text": {"tag": "lark_md", "content": text}}]
                }
            }
            response = requests.post(
                self.webhook_url, json=data,
                headers={'Content-Type': 'application/json'}, timeout=10
            )
            result = response.json()
            if result.get('StatusCode') == 0:
                logger.info("Markdown消息发送成功")
                return True
            else:
                logger.error(f"发送失败: {result.get('StatusMessage')}")
                return False
        except Exception as e:
            logger.error(f"发送异常: {e}")
            return False

    def send_daily_report(self, date: str, projects: List[Dict],
                          pdf_file: Optional[str] = None,
                          feishu_app=None) -> bool:
        """
        发送日报（先发统计文字，再发PDF文件）

        Args:
            date: 日期
            projects: 项目列表
            pdf_file: PDF文件路径（可选）
            feishu_app: FeishuEnterpriseApp实例（用于发送文件）
        """
        total = len(projects)
        yannan = len([p for p in projects if p.get('region') == '盐南高新区'])
        jingkai = len([p for p in projects if p.get('region') == '经开区'])
        regional = len([p for p in projects if p.get('region') == '区域内'])

        lines = [
            f"**招投标信息日报**",
            f"📅 日期：{date}",
            "",
            f"📊 数据统计：",
            f"- 总项目数：{total} 个",
            f"- 盐南高新区：{yannan} 个",
            f"- 经开区：{jingkai} 个",
            f"- 区域内：{regional} 个",
            ""
        ]

        if projects:
            lines.append("**项目列表：**\n")
            for i, p in enumerate(projects[:10], 1):
                lines.append(f"{i}. **{p['project_name']}**")
                lines.append(f"   区域：{p.get('region', 'N/A')} | 来源：{p.get('source_site', 'N/A')}")
                if p.get('budget_text'):
                    lines.append(f"   预算：{p['budget_text']}")
                lines.append("")

            if len(projects) > 10:
                lines.append(f"... 还有 {len(projects) - 10} 个项目，详见PDF报告\n")

        if pdf_file:
            lines.append(f"📄 详细报告已生成")

        return self.send_markdown_message("\n".join(lines))


# 兼容旧代码的类名
class FeishuOpenAPI:
    """飞书开放平台API（已废弃，请使用 FeishuEnterpriseApp）"""
    pass

path: scripts/run_daily_collection.py
content: #!/usr/bin/env python3
"""
每日采集任务执行脚本（强制采集模式）
确保每次使用技能时都采集最新数据，避免定时任务失效导致数据中断
"""

import argparse
import logging
import os
import sqlite3
import sys
from datetime import datetime, timedelta

# 添加脚本目录到路径
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)

from crawler import DatabaseManager, YanchengGovCrawler, BigDataCrawler, JscnCrawler, DongfangCrawler, DushiCrawler, ChengnanCrawler, KfqaCrawler, YuedaCrawler, JingkaiCrawler
from sufu_crawler_final import SufuCrawlerNew

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('./招投标数据/logs/daily_collection_{}.log'.format(datetime.now().strftime('%Y-%m-%d'))),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


def get_last_collection_date(db_manager: DatabaseManager) -> datetime:
    """获取最后采集日期"""
    conn = sqlite3.connect(db_manager.db_path)
    cursor = conn.cursor()

    cursor.execute('SELECT MAX(publish_date) FROM bidding_projects')
    result = cursor.fetchone()
    conn.close()

    if result and result[0]:
        return datetime.strptime(result[0], '%Y-%m-%d')
    else:
        return datetime.min


def should_collect(db_manager: DatabaseManager, force: bool = False) -> tuple[bool, str]:
    """
    判断是否需要采集

    Args:
        db_manager: 数据库管理器
        force: 是否强制采集

    Returns:
        (是否需要采集, 原因说明)
    """
    if force:
        return True, "强制采集模式"

    last_date = get_last_collection_date(db_manager)
    today = datetime.now().date()
    last_collection_date = last_date.date()

    # 计算距离上次采集的天数
    days_since_last = (today - last_collection_date).days

    # 如果距离上次采集超过1天，需要采集
    if days_since_last >= 1:
        return True, f"距离上次采集已过 {days_since_last} 天，需要更新数据"

    # 检查今天是否已采集（crawl_time 在今天）
    conn = sqlite3.connect(db_manager.db_path)
    cursor = conn.cursor()
    cursor.execute('''
        SELECT COUNT(*) FROM bidding_projects
        WHERE DATE(crawl_time) = ?
    ''', (today,))
    today_count = cursor.fetchone()[0]
    conn.close()

    if today_count == 0:
        return True, "今天还未采集数据"

    # 检查是否有最新的数据（今天发布的）
    conn = sqlite3.connect(db_manager.db_path)
    cursor = conn.cursor()
    cursor.execute('''
        SELECT COUNT(*) FROM bidding_projects
        WHERE publish_date = ?
    ''', (today,))
    today_publish_count = cursor.fetchone()[0]
    conn.close()

    if today_publish_count == 0:
        return True, "今天还没有新发布的招投标信息，需要采集"

    return False, "数据已是最新（距离上次采集不足1天且今天已有数据）"


def collect_data(db_manager: DatabaseManager, days_back: int = 3) -> dict:
    """
    执行数据采集

    Args:
        db_manager: 数据库管理器
        days_back: 采集最近几天的数据（默认3天）

    Returns:
        采集结果统计
    """
    logger.info("=" * 80)
    logger.info("开始执行数据采集任务")
    logger.info("=" * 80)

    # 计算采集日期范围
    end_date = datetime.now().date()
    start_date = end_date - timedelta(days=days_back)

    logger.info(f"采集日期范围: {start_date} 至 {end_date}")

    # 定义所有采集器
    crawlers = [
        ('盐城市政府采购网', YanchengGovCrawler),
        ('盐城市大数据集团', BigDataCrawler),
        ('江苏世纪新城', JscnCrawler),
        ('盐城市东方集团', DongfangCrawler),
        ('盐城市都市建设投资集团', DushiCrawler),
        ('城南新区公共资源交易网', ChengnanCrawler),
        ('经开城发集团', KfqaCrawler),
        ('悦达集团', YuedaCrawler),
        ('开发区公共资源交易网', JingkaiCrawler),
        ('苏服采', SufuCrawlerNew),
    ]

    # 统计结果
    results = {
        'total': 0,
        'new': 0,
        'updated': 0,
        'errors': 0,
        'sites': {},
        'start_time': datetime.now()
    }

    # 逐个网站采集
    for site_name, crawler_class in crawlers:
        logger.info(f"\n{'=' * 80}")
        logger.info(f"开始采集: {site_name}")
        logger.info(f"{'=' * 80}")

        try:
            crawler = crawler_class(db_manager)
            site_result = crawler.crawl(str(start_date), str(end_date))

            # 统计结果
            results['sites'][site_name] = {
                'total': site_result.get('total', 0),
                'new': site_result.get('new', 0),
                'updated': site_result.get('updated', 0),
                'error': site_result.get('error', None)
            }

            results['total'] += site_result.get('total', 0)
            results['new'] += site_result.get('new', 0)
            results['updated'] += site_result.get('updated', 0)

            if site_result.get('error'):
                results['errors'] += 1
                logger.error(f"{site_name} 采集失败: {site_result['error']}")
            else:
                logger.info(f"{site_name} 采集成功: 新增 {site_result.get('new', 0)} 条，更新 {site_result.get('updated', 0)} 条")

        except Exception as e:
            logger.error(f"{site_name} 采集异常: {str(e)}", exc_info=True)
            results['sites'][site_name] = {
                'total': 0,
                'new': 0,
                'updated': 0,
                'error': str(e)
            }
            results['errors'] += 1

    results['end_time'] = datetime.now()
    results['duration'] = (results['end_time'] - results['start_time']).total_seconds()

    logger.info("\n" + "=" * 80)
    logger.info("采集任务完成")
    logger.info("=" * 80)
    logger.info(f"总采集数: {results['total']} 条")
    logger.info(f"新增数据: {results['new']} 条")
    logger.info(f"更新数据: {results['updated']} 条")
    logger.info(f"错误网站: {results['errors']} 个")
    logger.info(f"耗时: {results['duration']:.2f} 秒")
    logger.info("=" * 80)

    return results


def print_collection_report(results: dict):
    """打印采集报告"""
    print("\n" + "=" * 80)
    print("采集任务报告")
    print("=" * 80)
    print(f"执行时间: {results['start_time'].strftime('%Y-%m-%d %H:%M:%S')} - {results['end_time'].strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"总耗时: {results['duration']:.2f} 秒")
    print(f"\n采集统计:")
    print(f"  总处理数: {results['total']} 条")
    print(f"  新增数据: {results['new']} 条")
    print(f"  更新数据: {results['updated']} 条")
    print(f"  错误网站: {results['errors']} 个")

    if results['sites']:
        print(f"\n各网站采集详情:")
        for site_name, site_result in results['sites'].items():
            if site_result.get('error'):
                print(f"  ❌ {site_name}: 失败 - {site_result['error']}")
            else:
                print(f"  ✅ {site_name}: 新增 {site_result['new']} 条，更新 {site_result['updated']} 条")

    print("=" * 80)


def main():
    """主函数"""
    parser = argparse.ArgumentParser(description='每日采集任务执行脚本（强制采集模式）')
    parser.add_argument('--force', action='store_true', help='强制采集，不检查数据时效性')
    parser.add_argument('--days', type=int, default=3, help='采集最近几天的数据（默认3天）')
    parser.add_argument('--check-only', action='store_true', help='只检查是否需要采集，不执行采集')

    args = parser.parse_args()

    # 初始化数据库
    db_manager = DatabaseManager()

    # 检查是否需要采集
    should_collect_flag, reason = should_collect(db_manager, args.force)

    print(f"\n检查结果: {reason}")

    if args.check_only:
        print("仅检查模式，不执行采集")
        if should_collect_flag:
            print(f"建议采集: 是 - {reason}")
            return 1  # 需要采集
        else:
            print(f"建议采集: 否 - {reason}")
            return 0  # 不需要采集

    if not should_collect_flag:
        print("数据已是最新，跳过采集")
        return 0

    # 执行采集
    try:
        results = collect_data(db_manager, args.days)
        print_collection_report(results)

        # 返回码：0=成功，1=有错误，2=无新增数据
        if results['errors'] > 0:
            return 1
        elif results['new'] == 0:
            return 2
        else:
            return 0

    except Exception as e:
        logger.error(f"采集任务失败: {str(e)}", exc_info=True)
        print(f"\n❌ 采集任务失败: {str(e)}")
        return 3


if __name__ == '__main__':
    sys.exit(main())


path: scripts/generate_monthly_pdfs.py
content: #!/usr/bin/env python3
"""生成当月月报PDF"""

import os
import sys
from datetime import datetime

# 添加scripts目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from pdf_generator import PDFGenerator

# 设置正确的数据库路径（与采集器一致）
db_path = os.path.expanduser('~/.openclaw/workspace/skills/bidding-assistant/招投标数据/history.db')
output_dir = os.path.expanduser('~/.openclaw/workspace/skills/bidding-assistant/招投标数据/daily')

# 确保输出目录存在
os.makedirs(output_dir, exist_ok=True)

# 获取当前年月
now = datetime.now()
year = now.year
month = now.month

print("="*60)
print(f"招投标月报PDF生成 - {year}年{month:02d}月")
print("="*60)
print(f"\n数据库路径: {db_path}")
print(f"数据库存在: {os.path.exists(db_path)}")
print(f"输出目录: {output_dir}")

# 初始化PDF生成器
generator = PDFGenerator(db_path=db_path)
generator.output_dir = output_dir

# 生成当月月报
print("\n" + "="*60)
print(f"正在生成 {year}年{month:02d}月 月报...")
print("="*60)
pdf = generator.generate_monthly_report(year, month)
if pdf:
    print(f"[OK] 月报生成成功: {pdf}")
    print(f"文件大小: {os.path.getsize(pdf) / 1024:.1f} KB")
else:
    print("[ERROR] 月报生成失败")

print("\n" + "="*60)
print("完成!")
print("="*60)

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)