fix: 修复历史数据Excel格式兼容问题 + 完善开发文档

核心修复:
- lottery.py: load_history_data() 添加多格式Excel检测逻辑
  支持 格式A(双行header: 新列名+旧列名) 和 格式B(标准列名)
- lottery.py: parse_numbers() 新增拼接字符串(14位无分隔符)直接解析
  避免 re.findall 将整个号码串视为单个数字的问题
- app.py: load_history_dataframe() 同步修复多格式兼容逻辑

新增:
- docs/开发文档-双色球WebUI-v1.0.md: 完整开发文档
- deploy/backup.sh: 备份脚本

测试结果:
- 120条历史数据全部正确解析
- 号码生成API正常工作
- 全部API接口测试通过

Issue: BIZ-75
This commit is contained in:
2026-07-03 23:05:58 +08:00
parent cf4b5764b5
commit 5d5e77000e
4 changed files with 437 additions and 34 deletions
+49 -21
View File
@@ -109,38 +109,66 @@ def add_record(strategy, num_tickets, filename):
HISTORY_COLUMNS = ['开奖时间', '期数', '号码', '开机号', '和值特征', '奇偶比', '大小比', '奇偶形态', '跨度', '其他'] HISTORY_COLUMNS = ['开奖时间', '期数', '号码', '开机号', '和值特征', '奇偶比', '大小比', '奇偶形态', '跨度', '其他']
def load_history_dataframe(): def load_history_dataframe():
"""智能加载历史数据 Excel,兼容新旧两种格式。 """智能加载历史数据 Excel,兼容种格式。
格式 (fetch_data.py 修复后): 第一行是标准列名,数据从第二行开始。 格式Afetch_data.py 当前输出):
旧格式 (修复前): 两行 header,第一行英文列名,第二行中文描述行。 Row0=新列名(期号|开奖日期|红球1...|蓝球|特别号)
Row1=旧列名(开奖时间|期数|号码|开机号|...)
返回的 DataFrame 统一使用标准列名,数据已跳过所有 header 行。 Row2+=实际数据
格式B(标准格式):
Row0=列名(开奖时间|期数|号码|开机号|...)
Row1+=数据
""" """
import pandas as pd import pandas as pd
df = pd.read_excel(CONFIG['history_file'], header=None) raw_df = pd.read_excel(CONFIG['history_file'], header=None)
# 检测第一行是否包含标准列名 row0_vals = raw_df.iloc[0].astype(str).tolist() if len(raw_df) > 0 else []
first_row = df.iloc[0].astype(str).tolist() row1_vals = raw_df.iloc[1].astype(str).tolist() if len(raw_df) > 1 else []
is_standard_header = any(col in first_row for col in ['开奖时间', '期数', '号码'])
if is_standard_header: has_legacy_in_row0 = any(col in row0_vals for col in ['开奖时间', '期数', '号码'])
# 新格式: 第一行是标准列名,直接使用 has_legacy_in_row1 = any(col in row1_vals for col in ['开奖时间', '期数', '号码'])
data_df = df.iloc[1:].copy() has_new_cols_in_row0 = any(col in row0_vals for col in ['期号', '开奖日期', '红球 1'])
if has_new_cols_in_row0 and has_legacy_in_row1:
# 格式A:跳过 Row0(新列名) 和 Row1(旧列名),用旧列名,数据从 Row2 开始
data_df = raw_df.iloc[2:].copy()
num_cols = min(len(data_df.columns), len(HISTORY_COLUMNS))
data_df.columns = HISTORY_COLUMNS[:num_cols] + [f'col_{i}' for i in range(num_cols, len(data_df.columns))]
elif has_legacy_in_row0:
# 格式BRow0 就是标准列名
data_df = raw_df.iloc[1:].copy()
num_cols = min(len(data_df.columns), len(HISTORY_COLUMNS)) num_cols = min(len(data_df.columns), len(HISTORY_COLUMNS))
data_df.columns = HISTORY_COLUMNS[:num_cols] + [f'col_{i}' for i in range(num_cols, len(data_df.columns))] data_df.columns = HISTORY_COLUMNS[:num_cols] + [f'col_{i}' for i in range(num_cols, len(data_df.columns))]
else: else:
# 旧格式: 检查是否有两行 header # 尝试默认读取
second_row = df.iloc[1].astype(str).tolist() if len(df) > 1 else [] df = pd.read_excel(CONFIG['history_file'])
has_second_header = any(col in second_row for col in ['开奖时间', '期数', '号码']) if '号码' not in df.columns and any(c in df.columns for c in ['红球 1', '红球1']):
# 分列格式,需要构建号码列
if has_second_header: data_df = df.copy()
# 两行 header,跳过前两行 red_cols = [f'红球 {i}' for i in range(1, 7)]
data_df = df.iloc[2:].copy() if not all(c in data_df.columns for c in red_cols):
red_cols = [f'红球{i}' for i in range(1, 7)]
if all(c in data_df.columns for c in red_cols) and '蓝球' in data_df.columns:
def build_num(row):
parts = []
for c in red_cols:
val = row.get(c)
if pd.isna(val):
return None
s = str(int(val)) if isinstance(val, (int, float)) else str(val).strip()
parts.append(s.zfill(2))
blue_val = row.get('蓝球')
if pd.isna(blue_val):
return None
blue_s = str(int(blue_val)) if isinstance(blue_val, (int, float)) else str(blue_val).strip()
return ''.join(parts) + blue_s.zfill(2)
data_df['号码'] = data_df.apply(build_num, axis=1)
else: else:
# 只有一行 header,跳过第一行 data_df = df
data_df = df.iloc[1:].copy()
num_cols = min(len(data_df.columns), len(HISTORY_COLUMNS)) num_cols = min(len(data_df.columns), len(HISTORY_COLUMNS))
# 如果列名不匹配标准,重命名
if not any(c in data_df.columns for c in HISTORY_COLUMNS[:3]):
data_df.columns = HISTORY_COLUMNS[:num_cols] + [f'col_{i}' for i in range(num_cols, len(data_df.columns))] data_df.columns = HISTORY_COLUMNS[:num_cols] + [f'col_{i}' for i in range(num_cols, len(data_df.columns))]
data_df = data_df.reset_index(drop=True) data_df = data_df.reset_index(drop=True)
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# 双色球数据备份脚本 — 每日凌晨 3:00 执行(抓取完成后 30min)
BACKUP_DIR="/home/vincent/backups/lotto"
SOURCE_DIR="/home/vincent/Studio/lottoData"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
DATE=$(date +%Y%m%d)
cp "$SOURCE_DIR/双色球历史数据.xlsx" "$BACKUP_DIR/history_${DATE}.xlsx" 2>/dev/null
cp "$SOURCE_DIR/.generation_records.json" "$BACKUP_DIR/records_${DATE}.json" 2>/dev/null
# 保留最近 30 天
find "$BACKUP_DIR" -name 'history_*.xlsx' -mtime +${RETENTION_DAYS} -delete 2>/dev/null
find "$BACKUP_DIR" -name 'records_*.json' -mtime +${RETENTION_DAYS} -delete 2>/dev/null
echo "$(date '+%Y-%m-%d %H:%M:%S') backup complete"
+214
View File
@@ -0,0 +1,214 @@
# 开发文档:双色球 Web UI 系统
**版本**: v1.0
**开发人员**: 徐聪(costcodev
**日期**: 2026-07-03
**Issue**: BIZ-75
---
## 1. 项目概述
双色球自动化系统 Web UI,提供号码生成、历史数据查看、生成记录管理和统计分析功能。支持 PC 端和移动端响应式访问,监听 0.0.0.0:8085,局域网可访问。
## 2. 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 后端 | Python 3 + Flask | REST API 服务 |
| 前端 | 原生 HTML/CSS/JS | 单文件,响应式布局 |
| 数据分析 | Pandas + NumPy | 号码统计分析 |
| 数据存储 | Excel + JSON | 历史数据 + 生成记录 |
| 部署 | systemd / nohup | Linux 服务部署 |
## 3. 目录结构
```
lottoData/
├── app.py # Flask 主服务(统一入口)
├── index.html # 前端 UI(响应式,4 Tab 页面)
├── lottery.py # 号码生成核心逻辑
├── fetch_data.py # 历史数据抓取脚本
├── web_console.html # 数据抓取控制台前端
├── requirements.txt # Python 依赖
├── 双色球历史数据.xlsx # 历史数据文件
├── lottery/ # 号码生成结果输出目录
├── .generation_records.json # 生成记录索引(JSON
├── .fetch_status.json # 抓取状态文件
├── deploy/ # 部署相关文件
│ ├── DEPLOY.md # 部署说明
│ ├── lotto-app.service # systemd 服务文件
│ ├── fetch_daily.sh # 定时抓取脚本
│ └── backup.sh # 备份脚本
└── docs/ # 文档目录
├── PRD-双色球 WebUI-v1.0.md
└── 开发文档-双色球WebUI-v1.0.md ← 本文件
```
## 4. API 接口
### 4.1 接口清单
| 接口 | 方法 | 描述 | 认证 |
|------|------|------|------|
| `/api/generate` | POST | 生成号码 | 可选 |
| `/api/history` | GET | 获取历史开奖数据(分页+搜索) | 可选 |
| `/api/records` | GET | 获取生成记录列表(分页) | 可选 |
| `/api/records/:id` | DELETE | 删除生成记录 | 可选 |
| `/api/statistics` | GET | 获取统计分析数据 | 可选 |
| `/api/download/:filepath` | GET | 下载文件 | 可选 |
| `/api/status` | GET | 系统状态 | 无 |
| `/api/config` | GET | 前端配置 | 无 |
| `/api/fetch/status` | GET | 抓取执行状态 | 无 |
| `/api/fetch/execute` | POST | 触发数据抓取 | 无 |
### 4.2 关键接口参数
#### POST /api/generate
```json
// 请求
{
"num_tickets": 10,
"strategy": "advanced" // 或 "basic"
}
// 响应
{
"success": true,
"data": {
"tickets": [...],
"total": 10,
"filename": "lottery/xxx.xlsx",
"download_url": "/api/download/lottery/xxx.xlsx",
"record": {...},
"statistics": {...}
}
}
```
#### GET /api/history
参数: `page` (页码), `page_size` (每页条数), `search` (搜索关键词)
#### GET /api/records
参数: `page` (页码), `page_size` (每页条数)
## 5. 前端页面
### 5.1 页面结构
- **Header**: 标题 + 副标题
- **导航 Tab**: 号码生成 | 历史数据 | 生成记录 | 统计分析
- **移动端**: 底部固定导航栏
### 5.2 功能页面
#### 号码生成页(首页)
- 统计概览(历史期数、常见奇偶比、和值范围等)
- 策略选择(高级策略/基础策略)
- 注数输入(1-1000
- 生成结果展示(红球+蓝球+统计指标)
- Excel 下载按钮
#### 历史数据页
- 搜索框(500ms 防抖)
- 数据表格(期号、日期、红球、蓝球、统计字段)
- 分页控件
#### 生成记录页
- 记录列表(策略、注数、时间、文件大小)
- 下载/删除操作
- 分页控件
#### 统计分析页
- 历史开奖期数
- 红球热号 TOP15 / 冷号 TOP15
- 蓝球热号 TOP8
- 奇偶比/大小比/和值/跨度统计
## 6. 关键修复说明
### 6.1 数据格式兼容修复(核心 Bug 修复)
**问题**: `lottery.py` 期望 Excel 含"号码"列(拼接格式如 `08121821243001`),但 `fetch_data.py` 抓取的 Excel 使用分列格式("红球 1"~"红球 6"+"蓝球"),导致号码生成器无法加载历史数据。
**根因**: Excel 文件包含两行 header
- Row 0: 新格式列名(期号、开奖日期、红球 1~6、蓝球、特别号)
- Row 1: 旧格式列名(开奖时间、期数、号码、开机号、...)
- Row 2+: 实际数据
**修复方案**:
1. `lottery.py``load_history_data()`: 添加多格式检测逻辑,识别格式A(双行 header)并自动跳过,使用旧列名作为标准列名
2. `lottery.py``parse_numbers()`: 新增对拼接字符串格式(14位无分隔符)的直接解析,避免 `re.findall` 将整个字符串视为一个数字
3. `app.py``load_history_dataframe()`: 同步修复多格式兼容逻辑
### 6.2 线程安全
- 生成记录的读-改-写操作使用 `threading.Lock` 保护
- 文件写入使用临时文件+原子替换(`os.replace`),防止崩溃导致数据损坏
## 7. 部署方式
### 7.1 直接运行
```bash
cd /home/vincent/Studio/lottoData
source .venv/bin/activate
python3 app.py
# 访问 http://localhost:8085
```
### 7.2 systemd 服务
```bash
# 服务文件: deploy/lotto-app.service
sudo cp deploy/lotto-app.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable lotto-app
sudo systemctl start lotto-app
```
### 7.3 定时数据抓取
```bash
# 添加 cron 任务
crontab -e
# 每天 02:30 自动抓取最新数据
30 2 * * * /home/vincent/Studio/lottoData/deploy/fetch_daily.sh >> /home/vincent/Studio/lottoData/deploy/cron.log 2>&1
```
## 8. 测试验证
### 8.1 API 测试结果
| 接口 | 状态 | 说明 |
|------|------|------|
| GET /api/status | ✅ 通过 | 返回服务状态 |
| GET /api/statistics | ✅ 通过 | 120条历史数据统计正确 |
| GET /api/history | ✅ 通过 | 分页+红蓝球解析正确 |
| POST /api/generate | ✅ 通过 | 5注号码生成成功,含统计 |
| GET /api/records | ✅ 通过 | 生成记录列表正确 |
| GET / (前端页面) | ✅ 通过 | HTML 页面正常加载 |
### 8.2 数据格式验证
- 历史数据: 120 条记录全部成功解析 ✅
- 红球解析: 6个红球正确提取 ✅
- 蓝球解析: 1个蓝球正确提取 ✅
- 号码范围校验: 1-33(红) + 1-16(蓝) ✅
## 9. 已知限制
- 前端为单 HTML 文件,未使用构建工具
- 无用户登录系统(Token 认证为可选项,默认关闭)
- 历史数据来源为 55128.cn,如网站改版需更新 `fetch_data.py`
- 不支持 HTTPS(内网环境)
## 10. 后续优化建议
| 功能 | 优先级 | 说明 |
|------|--------|------|
| 数据可视化图表 | P2 | 走势图、分布图 |
| 用户登录系统 | P2 | 多用户权限管理 |
| 定时自动生成 | P2 | 定时生成+推送 |
| 微信推送 | P3 | 生成结果推送至微信 |
| 多彩种支持 | P3 | 大乐透、福彩 3D 等 |
---
**开发完成日期**: 2026-07-03
**代码仓库**: http://192.168.1.99:12299/vincent/Lottery.git
**开发人员**: 徐聪(costcodev
+156 -12
View File
@@ -48,35 +48,106 @@ class DoubleColorBallGenerator:
# 读取Excel文件 # 读取Excel文件
print(f"正在读取文件: {self.history_file}") print(f"正在读取文件: {self.history_file}")
try: try:
self.history_data = pd.read_excel(self.history_file) raw_df = pd.read_excel(self.history_file, header=None)
except Exception as excel_error: except Exception as excel_error:
print(f"读取Excel文件失败: {excel_error}") print(f"读取Excel文件失败: {excel_error}")
return False return False
# 检查数据是否为空 # 检查数据是否为空
if raw_df.empty:
print("错误: 历史数据文件为空")
return False
# 兼容多种 Excel 格式:
# 格式Afetch_data.py 当前输出): Row0=新列名(期号|开奖日期|红球1...|蓝球|特别号), Row1=旧列名(开奖时间|期数|号码|...), Row2+=数据
# 格式B(标准格式): Row0=列名(开奖时间|期数|号码|开机号|...), Row1+=数据
# 格式C(分列含旧 header: Row0=旧列名, Row1+=数据 但无"号码"列
# 标准列名(lottery.py 期望的列)
legacy_columns = ['开奖时间', '期数', '号码', '开机号', '和值特征', '奇偶比', '大小比', '奇偶形态', '跨度', '其他']
row0_vals = raw_df.iloc[0].astype(str).tolist() if len(raw_df) > 0 else []
row1_vals = raw_df.iloc[1].astype(str).tolist() if len(raw_df) > 1 else []
# 检测各类格式
has_legacy_header_in_row0 = any(col in row0_vals for col in ['开奖时间', '期数', '号码'])
has_legacy_header_in_row1 = any(col in row1_vals for col in ['开奖时间', '期数', '号码'])
has_new_header_in_row0 = any(col in row0_vals for col in ['期号', '开奖日期', '红球 1'])
if has_new_header_in_row0 and has_legacy_header_in_row1:
# 格式ARow0=新列名, Row1=旧列名, Row2+=数据
# 用旧列名(Row1)作为列名,因为 lottery.py 期望"号码"列
self.history_data = raw_df.iloc[2:].copy()
num_cols = len(self.history_data.columns)
self.history_data.columns = legacy_columns[:min(num_cols, len(legacy_columns))] + [f'col_{i}' for i in range(min(num_cols, len(legacy_columns)), num_cols)]
self.history_data = self.history_data.reset_index(drop=True)
print(f"加载成功(格式A: 新旧 header 双行),共{len(self.history_data)}条历史记录")
print(f"数据列: {list(self.history_data.columns)}")
elif has_legacy_header_in_row0:
# 格式BRow0=标准列名, Row1+=数据
self.history_data = raw_df.iloc[1:].copy()
num_cols = len(self.history_data.columns)
self.history_data.columns = legacy_columns[:min(num_cols, len(legacy_columns))] + [f'col_{i}' for i in range(min(num_cols, len(legacy_columns)), num_cols)]
self.history_data = self.history_data.reset_index(drop=True)
print(f"加载成功(格式B: 标准列名),共{len(self.history_data)}条历史记录")
print(f"数据列: {list(self.history_data.columns)}")
else:
# 格式C:检测不到旧列名,尝试直接用 pandas 读取
self.history_data = pd.read_excel(self.history_file)
print(f"加载成功(默认读取),共{len(self.history_data)}条历史记录")
print(f"数据列: {list(self.history_data.columns)}")
# 如果没有"号码"列但有分列红球,尝试标准化
if '号码' not in self.history_data.columns:
if any(c in self.history_data.columns for c in ['红球 1', '红球1']):
self._normalize_history_format()
if self.history_data.empty: if self.history_data.empty:
print("错误: 历史数据文件为空") print("错误: 历史数据文件为空")
return False return False
print(f"加载成功,共{len(self.history_data)}条历史记录")
print(f"数据列: {list(self.history_data.columns)}")
# 检查是否包含必要的列
if '号码' not in self.history_data.columns:
print("错误: 历史数据文件缺少'号码'")
return False
# 解析号码列 # 解析号码列
def parse_numbers(row): def parse_numbers(row):
"""解析单行号码数据""" """解析单行号码数据
支持以下格式:
- 拼接字符串: '08121821243001' (6红球×2位 + 1蓝球×2位)
- 空格/逗号分隔: '08 12 18 21 24 30 01'
- 加号分隔: '08,12,18,21,24,30+01'
"""
try: try:
# 处理号码字符串 - 直接转换为字符串然后分割 # 处理号码字符串 - 直接转换为字符串然后分割
if pd.isna(row['号码']): if pd.isna(row['号码']):
return [], 0 return [], 0
numbers_str = str(row['号码']) numbers_str = str(row['号码']).strip()
# 使用正则表达式提取所有数字 # 情况1: 纯拼接字符串(14位或以上,无分隔符)
# 例如 '08121821243001' = [08,12,18,21,24,30] + [01]
if re.match(r'^\d{14,}$', numbers_str):
red_balls = [int(numbers_str[i:i+2]) for i in range(0, 12, 2)]
blue_ball = int(numbers_str[12:14])
if all(1 <= b <= 33 for b in red_balls) and 1 <= blue_ball <= 16:
return red_balls, blue_ball
else:
print(f"警告: 号码范围异常: {red_balls} + {blue_ball}")
return [], 0
# 情况2: 加号分隔(如 '03,12,16,22,25,28+10'
if '+' in numbers_str:
parts = numbers_str.replace(',', ' ').replace('+', ' ').split()
if len(parts) >= 7:
try:
red_balls = [int(x) for x in parts[:6]]
blue_ball = int(parts[6])
if all(1 <= b <= 33 for b in red_balls) and 1 <= blue_ball <= 16:
return red_balls, blue_ball
except ValueError:
pass
# 情况3: 使用正则表达式提取所有数字组
number_list = re.findall(r'\d+', numbers_str) number_list = re.findall(r'\d+', numbers_str)
if len(number_list) >= 7: if len(number_list) >= 7:
@@ -140,6 +211,79 @@ class DoubleColorBallGenerator:
print(traceback.format_exc()) print(traceback.format_exc())
return False return False
def _normalize_history_format(self):
"""将格式A(分列红球)转换为格式B(统一号码列 + 标准列名)。
格式A列名: 期号 | 开奖日期 | 红球 1 | 红球 2 | 红球 3 | 红球 4 | 红球 5 | 红球 6 | 蓝球 | 特别号
格式B列名: 开奖时间 | 期数 | 号码 | 开机号 | 和值特征 | 奇偶比 | 大小比 | 奇偶形态 | 跨度 | 其他
在 self.history_data 上原地操作,构建 '号码' 列和标准列名。
"""
df = self.history_data
standard_columns = ['开奖时间', '期数', '号码', '开机号', '和值特征', '奇偶比', '大小比', '奇偶形态', '跨度', '其他']
# 构建号码列:将 红球1~6 + 蓝球 拼接为 14 位字符串
red_cols = [f'红球 {i}' for i in range(1, 7)]
blue_col = '蓝球'
def build_number_string(row):
parts = []
for c in red_cols:
val = row.get(c)
if pd.isna(val):
return None
s = str(int(val)) if isinstance(val, (int, float)) else str(val).strip()
parts.append(s.zfill(2))
blue_val = row.get(blue_col)
if pd.isna(blue_val):
return None
blue_s = str(int(blue_val)) if isinstance(blue_val, (int, float)) else str(blue_val).strip()
return ''.join(parts) + blue_s.zfill(2)
df = df.copy()
df['号码'] = df.apply(build_number_string, axis=1)
# 重命名列到标准列名(保留原始列)
# 格式A -> 格式B 映射:
# 期号 -> 开奖时间(其实存的是日期)
# 开奖日期 -> 期数(其实存的是期号数字)
# 红球1 -> 号码(已在上面构建)
# 特别说 -> 跨度
# 其他列按顺序映射
rename_map = {}
if '期号' in df.columns:
rename_map['期号'] = '开奖时间'
if '开奖日期' in df.columns:
rename_map['开奖日期'] = '期数'
if '蓝球' in df.columns and '特别号' in df.columns:
rename_map['特别号'] = '跨度'
# 蓝球在格式B中不单独存在,尽量复用
# 但不开机号无直接对应
if '红球 2' in df.columns:
rename_map['红球 2'] = '开机号'
if '红球 3' in df.columns:
rename_map['红球 3'] = '和值特征'
if '红球 4' in df.columns:
rename_map['红球 4'] = '奇偶比'
if '红球 5' in df.columns:
rename_map['红球 5'] = '大小比'
if '红球 6' in df.columns:
rename_map['红球 6'] = '奇偶形态'
df = df.rename(columns=rename_map)
# 确保所有标准列都存在(补缺失列)
for col in standard_columns:
if col not in df.columns:
df[col] = ''
# 调整列顺序
df = df[[c for c in standard_columns if c in df.columns] + [c for c in df.columns if c not in standard_columns]]
self.history_data = df.reset_index(drop=True)
print(f"已标准化数据格式,共 {len(df)} 条记录")
print(f"标准化后列名: {list(df.columns)}")
def _calculate_statistics(self): def _calculate_statistics(self):
"""计算统计数据""" """计算统计数据"""
if self.history_data is None or len(self.history_data) == 0: if self.history_data is None or len(self.history_data) == 0: