From 5cebbfa433eb3c5b4da8911c2bd7c20c734dfcc2 Mon Sep 17 00:00:00 2001 From: bizwings Date: Sat, 4 Jul 2026 01:28:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88=E5=85=A8=E9=83=A8=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查意见修复清单: P1 列映射语义修复 (lottery.py): - _normalize_history_format() 不再将红球2-6映射到开机号/和值特征/奇偶比等 格式A不含这些特征字段,缺失列留空,前端做降级显示 - 删除已用于构建号码列的原始分列,避免数据重复 P2 架构优化: - 提取 Excel 兼容逻辑到公共模块 history_loader.py lottery.py 和 app.py 共同引用,消除三处重复代码 - web_executor.py 标记为已废弃,功能已整合到 app.py 部署修复: - 删除 deploy/lotto-web.service (旧服务),仅保留 lotto-app.service - 更新 deploy/DEPLOY.md: 端口5000→8085, 接口清单更新, 添加迁移说明 安全加固: - API Token 改为环境变量读取: os.environ.get('LOTTO_API_TOKEN') - 错误信息不再暴露内部异常,改为通用错误消息+日志记录 - 目录遍历防护改用 os.path.realpath 检查最终路径 其他: - .gitignore 补充排除 双色球历史数据.xlsx - app.py 引用公共模块,简化 get_statistics_data 和 load_history_dataframe 测试验证: 全部 API 测试通过,120条历史数据正确解析 Issue: BIZ-75 --- .gitignore | 1 + app.py | 194 +++++------------------------------- deploy/DEPLOY.md | 172 ++++++++++++++++++++------------ deploy/lotto-web.service | 16 --- history_loader.py | 206 +++++++++++++++++++++++++++++++++++++++ lottery.py | 35 +++---- web_executor.py | 16 ++- 7 files changed, 370 insertions(+), 270 deletions(-) delete mode 100644 deploy/lotto-web.service create mode 100644 history_loader.py diff --git a/.gitignore b/.gitignore index 51e3a62..5d0d293 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ LottoSpider/ .fetch_status.json .generation_records.json lottery/ +双色球历史数据.xlsx # 备份文件 *.bak diff --git a/app.py b/app.py index f2bac35..6666d8e 100644 --- a/app.py +++ b/app.py @@ -17,8 +17,11 @@ from datetime import datetime from flask import Flask, send_from_directory, jsonify, request, send_file, abort from functools import wraps -# 将项目目录加入路径,以便导入 lottery.py +# 将项目目录加入路径,以便导入 lottery.py 和 history_loader.py BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# 导入公共历史数据加载模块 +from history_loader import load_history_dataframe as _load_history, parse_number_string, compute_statistics sys.path.insert(0, BASE_DIR) # 导入号码生成器 @@ -35,7 +38,7 @@ CONFIG = { 'history_file': os.path.join(BASE_DIR, '双色球历史数据.xlsx'), 'lottery_output_dir': os.path.join(BASE_DIR, 'lottery'), 'records_file': os.path.join(BASE_DIR, '.generation_records.json'), - 'api_token': 'lotto2026', + 'api_token': os.environ.get('LOTTO_API_TOKEN', 'lotto2026'), 'auth_enabled': False, 'max_tickets': 1000, 'default_tickets': 10, @@ -105,74 +108,11 @@ def add_record(strategy, num_tickets, filename): # ============================================================ # Excel 历史数据读取辅助 # ============================================================ -# 标准列名 (与 lottery.py 兼容) -HISTORY_COLUMNS = ['开奖时间', '期数', '号码', '开机号', '和值特征', '奇偶比', '大小比', '奇偶形态', '跨度', '其他'] - +# 历史数据加载 — 使用公共模块 history_loader.py +# ============================================================ def load_history_dataframe(): - """智能加载历史数据 Excel,兼容多种格式。 - - 格式A(fetch_data.py 当前输出): - Row0=新列名(期号|开奖日期|红球1...|蓝球|特别号) - Row1=旧列名(开奖时间|期数|号码|开机号|...) - Row2+=实际数据 - 格式B(标准格式): - Row0=列名(开奖时间|期数|号码|开机号|...) - Row1+=数据 - """ - import pandas as pd - 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 [] - row1_vals = raw_df.iloc[1].astype(str).tolist() if len(raw_df) > 1 else [] - - has_legacy_in_row0 = any(col in row0_vals for col in ['开奖时间', '期数', '号码']) - has_legacy_in_row1 = any(col in row1_vals for col in ['开奖时间', '期数', '号码']) - 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: - # 格式B:Row0 就是标准列名 - data_df = raw_df.iloc[1:].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))] - else: - # 尝试默认读取 - df = pd.read_excel(CONFIG['history_file']) - if '号码' not in df.columns and any(c in df.columns for c in ['红球 1', '红球1']): - # 分列格式,需要构建号码列 - data_df = df.copy() - red_cols = [f'红球 {i}' for i in range(1, 7)] - 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: - data_df = df - - 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 = data_df.reset_index(drop=True) - return data_df + """加载历史数据 Excel,委托公共模块处理多格式兼容。""" + return _load_history(CONFIG['history_file']) # ============================================================ # 认证装饰器(可选) @@ -259,89 +199,13 @@ def api_generate(): }) except Exception as e: - return jsonify({'success': False, 'error': f'生成失败: {str(e)}'}), 500 + traceback.print_exc() + return jsonify({'success': False, 'error': '号码生成失败,请检查历史数据文件是否完整'}), 500 def get_statistics_data(generator=None): - """获取统计数据""" - import pandas as pd - import re - from collections import Counter - - if not os.path.exists(CONFIG['history_file']): - return {} - - # 使用智能加载函数 - data_df = load_history_dataframe() - - # 解析红球和蓝球 - red_ball_counts = Counter() - blue_ball_counts = Counter() - sum_values = [] - span_values = [] - - for _, row in data_df.iterrows(): - s = str(row['号码']).strip() - if len(s) >= 14: - reds = [int(s[i:i+2]) for i in range(0, 12, 2)] - blue = int(s[12:14]) - if all(1 <= r <= 33 for r in reds) and 1 <= blue <= 16: - red_ball_counts.update(reds) - blue_ball_counts[blue] += 1 - sum_values.append(sum(reds)) - span_values.append(max(reds) - min(reds)) - - stats = {} - - if red_ball_counts: - sorted_reds = sorted(red_ball_counts.items(), key=lambda x: x[1], reverse=True) - stats['hot_reds'] = [x[0] for x in sorted_reds[:15]] - stats['cold_reds'] = [x[0] for x in sorted_reds[-15:]] - - if blue_ball_counts: - sorted_blues = sorted(blue_ball_counts.items(), key=lambda x: x[1], reverse=True) - stats['hot_blues'] = [x[0] for x in sorted_blues[:8]] - - # 奇偶比统计 - odd_even_ratios = Counter() - size_ratios = Counter() - for _, row in data_df.iterrows(): - oe = str(row['奇偶比']).strip() - sz = str(row['大小比']).strip() - if oe and oe != 'nan': - odd_even_ratios[oe] += 1 - if sz and sz != 'nan': - size_ratios[sz] += 1 - - if odd_even_ratios: - stats['common_odd_even'] = max(odd_even_ratios, key=odd_even_ratios.get) - - if size_ratios: - stats['common_size_ratio'] = max(size_ratios, key=size_ratios.get) - - # 和值 - if sum_values: - import numpy as np - arr = np.array(sum_values) - stats['sum_range'] = { - 'min': int(arr.min()), - 'max': int(arr.max()), - 'mean': float(arr.mean()), - 'std': float(arr.std()) - } - - # 跨度 - if span_values: - import numpy as np - arr = np.array(span_values) - stats['span_range'] = { - 'min': int(arr.min()), - 'max': int(arr.max()), - 'mean': float(arr.mean()), - 'std': float(arr.std()) - } - - stats['history_count'] = len(data_df) + """获取统计数据 — 委托公共模块计算""" + return compute_statistics(CONFIG['history_file']) return stats @@ -357,11 +221,8 @@ def api_statistics(): stats = get_statistics_data() return jsonify({'success': True, 'data': stats}) except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================ -# API:获取生成记录 + traceback.print_exc() + return jsonify({'success': False, 'error': '获取统计数据失败'}), 500 # ============================================================ @app.route('/api/records') @require_auth @@ -387,7 +248,8 @@ def api_records(): } }) except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 + traceback.print_exc() + return jsonify({'success': False, 'error': '获取生成记录失败'}), 500 # ============================================================ @@ -431,11 +293,8 @@ def api_delete_record(record_id): return jsonify({'success': True, 'message': '记录已删除'}) except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================ -# API:文件下载 + traceback.print_exc() + return jsonify({'success': False, 'error': '删除记录失败'}), 500 # ============================================================ @app.route('/api/download/') @require_auth @@ -444,10 +303,11 @@ def api_download(filepath): try: # 安全检查:防止目录遍历 safe_path = os.path.normpath(filepath) - if safe_path.startswith('..') or safe_path.startswith('/'): + full_path = os.path.realpath(os.path.join(BASE_DIR, safe_path)) + # 使用 realpath 检查最终路径是否仍在 BASE_DIR 内 + if not full_path.startswith(os.path.realpath(BASE_DIR)): abort(403) - full_path = os.path.join(BASE_DIR, safe_path) if not os.path.exists(full_path): abort(404) @@ -536,7 +396,8 @@ def api_history(): } }) except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 + traceback.print_exc() + return jsonify({'success': False, 'error': '获取历史数据失败'}), 500 # ============================================================ @@ -570,11 +431,8 @@ def api_status(): } }) except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -# ============================================================ -# 前端页面 + traceback.print_exc() + return jsonify({'success': False, 'error': '获取系统状态失败'}), 500 # ============================================================ @app.route('/') def index(): diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 3f15f4f..88c80cc 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -5,9 +5,11 @@ | 项目 | 值 | |------|-----| | 项目名称 | 双色球自动化系统 | -| 部署时间 | 2026-06-29 | +| 部署时间 | 2026-07-04 | +| 开发人员 | 徐聪 (costcodev) | | 部署人员 | 严维序 (opengineer) | -| 服务地址 | http://192.168.1.99:5000 | +| 服务地址 | http://192.168.1.99:8085 | +| 代码仓库 | http://192.168.1.99:12299/vincent/Lottery.git | | 宿主服务器 | Ubuntu-OpenClaw (192.168.1.99) | --- @@ -17,38 +19,48 @@ ``` /home/vincent/Studio/lottoData/ ├── venv/ # Python 虚拟环境 -├── web_executor.py # Flask Web 服务 (监听 0.0.0.0:5000) -├── fetch_data.py # 数据抓取脚本 -├── lottery.py # 双色球号码生成器 -├── web_console.html # Web 控制台页面 -├── LottoSpider/ # 爬虫模块 -├── lottery/ # 彩票模块 -├── docs/ # 文档 -├── 双色球历史数据.xlsx # 历史数据文件 +├── app.py # Flask 统一 Web 服务 (监听 0.0.0.0:8085) +├── index.html # 前端 UI (响应式,4 Tab 页面) +├── lottery.py # 双色球号码生成器核心逻辑 +├── fetch_data.py # 历史数据抓取脚本 +├── web_console.html # 数据抓取控制台前端页面 +├── web_executor.py # [已废弃] 旧版独立抓取服务,功能已整合到 app.py +├── requirements.txt # Python 依赖清单 +├── 双色球历史数据.xlsx # 历史数据文件 (不纳入 git) +├── lottery/ # 号码生成结果输出目录 (不纳入 git) +├── .generation_records.json # 生成记录索引 (不纳入 git) +├── .fetch_status.json # 抓取状态文件 (不纳入 git) +├── docs/ # 文档目录 +│ ├── PRD-双色球 WebUI-v1.0.md +│ └── 开发文档-双色球WebUI-v1.0.md └── deploy/ # 部署文件 ├── DEPLOY.md # 本文档 - ├── lotto-web.service # systemd 服务文件 - ├── fetch_daily.sh # 每日抓取脚本 + ├── lotto-app.service # systemd 服务文件 (统一入口) + ├── fetch_daily.sh # 每日定时抓取脚本 + ├── backup.sh # 备份脚本 (30天保留) ├── cron.log # Cron 执行日志 └── fetch_YYYYMMDD.log # 每日抓取详细日志 ``` +**说明**: `app.py` 是统一入口,整合了号码生成、历史数据、生成记录、统计数据抓取等全部功能。`web_executor.py` 已废弃,不需独立部署。 + --- ## 二、依赖清单 -| 包 | 版本 | 用途 | -|----|------|------| -| Flask | 3.1.3 | Web 服务框架 | -| pandas | 3.0.4 | 数据处理 | -| openpyxl | 3.1.5 | Excel 读写 | -| requests | 2.34.2 | HTTP 请求 | -| beautifulsoup4 | 4.15.0 | HTML 解析 | +| 包 | 用途 | +|----|------| +| Flask | Web 服务框架 | +| pandas | 数据处理 | +| openpyxl | Excel 读写 | +| numpy | 数值计算 | +| requests | HTTP 请求 (数据抓取) | +| beautifulsoup4 | HTML 解析 (数据抓取) | 安装命令: ```bash python3 -m venv venv -./venv/bin/pip install flask pandas openpyxl requests beautifulsoup4 +./venv/bin/pip install -r requirements.txt ``` --- @@ -57,22 +69,23 @@ python3 -m venv venv ### 服务文件 -`/etc/systemd/system/lotto-web.service` +`/etc/systemd/system/lotto-app.service` ```ini [Unit] -Description=双色球数据抓取 Web 服务 +Description=双色球号码生成 Web 服务 (app.py :8085) After=network.target [Service] Type=simple User=vincent WorkingDirectory=/home/vincent/Studio/lottoData -ExecStart=/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/web_executor.py -ExecStartPre=/home/vincent/Studio/lottoData/venv/bin/python3 -c "import flask; import pandas; import openpyxl; import requests; import bs4" +ExecStart=/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/app.py +ExecStartPre=/home/vincent/Studio/lottoData/venv/bin/python3 -c "import flask; import pandas; import openpyxl; import numpy" Restart=on-failure RestartSec=5 KillMode=control-group +Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target @@ -82,16 +95,25 @@ WantedBy=multi-user.target ```bash # 安装/启用 -sudo cp deploy/lotto-web.service /etc/systemd/system/ +sudo cp deploy/lotto-app.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable lotto-web -sudo systemctl start lotto-web +sudo systemctl enable lotto-app +sudo systemctl start lotto-app # 日常管理 -sudo systemctl status lotto-web # 查看状态 -sudo systemctl restart lotto-web # 重启 -sudo systemctl stop lotto-web # 停止 -sudo journalctl -u lotto-web -f # 查看实时日志 +sudo systemctl status lotto-app # 查看状态 +sudo systemctl restart lotto-app # 重启 +sudo systemctl stop lotto-app # 停止 +sudo journalctl -u lotto-app -f # 查看实时日志 +``` + +### 生产部署建议 + +建议使用 gunicorn 替代 Flask 内置服务器: +```bash +./venv/bin/pip install gunicorn +# 修改 ExecStart 为: +# /home/vincent/Studio/lottoData/venv/bin/gunicorn -w 4 -b 0.0.0.0:8085 app:app ``` --- @@ -102,77 +124,103 @@ sudo journalctl -u lotto-web -f # 查看实时日志 ``` 30 2 * * * /home/vincent/Studio/lottoData/deploy/fetch_daily.sh >> /home/vincent/Studio/lottoData/deploy/cron.log 2>&1 +0 3 * * * /home/vincent/Studio/lottoData/deploy/backup.sh >> /home/vincent/Studio/lottoData/deploy/cron.log 2>&1 ``` -每天凌晨 2:30 自动抓取双色球历史数据。 +- 每天 02:30 自动抓取双色球历史数据 +- 每天 03:00 自动备份数据(保留 30 天) ### 手动执行 ```bash /home/vincent/Studio/lottoData/deploy/fetch_daily.sh -# 或 -/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/fetch_data.py +# 或通过 Web 控制台触发: http://192.168.1.99:8085/fetch ``` --- -## 五、Web 接口 +## 五、Web 接口清单 | 路径 | 方法 | 说明 | |------|------|------| -| `/` | GET | Web 控制台页面 | -| `/api/status` | GET | 获取执行状态 | -| `/api/execute` | POST | 触发数据抓取 | +| `/` | GET | 双色球 Web UI 首页(号码生成) | +| `/fetch` | GET | 数据抓取控制台 | +| `/api/generate` | POST | 生成号码(参数: num_tickets, strategy) | +| `/api/history` | GET | 获取历史开奖数据(参数: page, page_size, search) | +| `/api/records` | GET | 获取生成记录列表(参数: page, page_size) | +| `/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 | 触发数据抓取 | ### 示例 ```bash # 查看状态 -curl http://192.168.1.99:5000/api/status +curl http://192.168.1.99:8085/api/status + +# 生成号码 +curl -X POST http://192.168.1.99:8085/api/generate \ + -H "Content-Type: application/json" \ + -d '{"num_tickets": 10, "strategy": "advanced"}' # 触发抓取 -curl -X POST http://192.168.1.99:5000/api/execute +curl -X POST http://192.168.1.99:8085/api/fetch/execute ``` --- -## 六、验证清单 +## 六、迁移说明(从旧版升级) -- [x] 依赖安装完整 (Flask, pandas, openpyxl, requests, beautifulsoup4) -- [x] systemd 服务运行正常 (active, enabled) -- [x] Web 服务可访问 (http://192.168.1.99:5000, HTTP 200) -- [x] API 接口正常 (/api/status, /api/execute) -- [x] Cron 定时任务已配置 (每日 2:30 抓取) -- [x] 手动抓取测试通过 (121 条记录保存成功) -- [x] 开机自启已配置 (systemd enable) - ---- - -## 七、回滚方案 +如果之前部署了旧版 `lotto-web.service`(端口 5000): ```bash -# 停止服务 +# 1. 停止旧服务 sudo systemctl stop lotto-web sudo systemctl disable lotto-web sudo rm /etc/systemd/system/lotto-web.service sudo systemctl daemon-reload -# 移除 cron -crontab -l | grep -v 'lottoData' | crontab - +# 2. 部署新服务 +sudo cp deploy/lotto-app.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now lotto-app -# 不影响数据文件和代码 +# 3. 更新 cron(指向新的 fetch_daily.sh) +crontab -l | sed 's|web_executor|app|g' | crontab - +# 或手动编辑: crontab -e + +# 4. 验证 +curl http://127.0.0.1:8085/api/status ``` --- +## 七、验证清单 + +- [ ] 依赖安装完整 (Flask, pandas, openpyxl, numpy, requests, beautifulsoup4) +- [ ] systemd 服务运行正常 (active, enabled) +- [ ] Web 服务可访问 (http://192.168.1.99:8085, HTTP 200) +- [ ] API 接口正常 (/api/status, /api/generate, /api/history 等) +- [ ] 前端页面正常 (4 Tab: 号码生成、历史数据、生成记录、统计分析) +- [ ] 移动端响应式布局正常 +- [ ] Cron 定时任务已配置 (每日 2:30 抓取, 3:00 备份) +- [ ] 旧版 lotto-web.service 已停止并移除 +- [ ] 开机自启已配置 (systemd enable) + +--- + ## 八、监控要点 -1. **服务存活**:`systemctl status lotto-web` 确认 active -2. **Web 可达**:`curl http://127.0.0.1:5000/api/status` -3. **数据更新**:检查 `/home/vincent/Studio/lottoData/双色球历史数据.xlsx` 修改时间 -4. **Cron 日志**:检查 `/home/vincent/Studio/lottoData/deploy/cron.log` -5. **磁盘空间**:Excel 文件约 250KB,可忽略 +1. **服务存活**:`systemctl status lotto-app` 确认 active +2. **Web 可达**:`curl http://127.0.0.1:8085/api/status` +3. **数据更新**:检查 `双色球历史数据.xlsx` 修改时间 +4. **Cron 日志**:检查 `deploy/cron.log` +5. **磁盘空间**:Excel 文件约 13KB,定期检查 `lottery/` 目录增长 --- -> 部署人:严维序 (opengineer) | 2026-06-29 \ No newline at end of file +> 部署人:严维序 (opengineer) | 2026-07-04 diff --git a/deploy/lotto-web.service b/deploy/lotto-web.service deleted file mode 100644 index bff66d0..0000000 --- a/deploy/lotto-web.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=双色球数据抓取 Web 服务 -After=network.target - -[Service] -Type=simple -User=vincent -WorkingDirectory=/home/vincent/Studio/lottoData -ExecStart=/home/vincent/Studio/lottoData/venv/bin/python3 /home/vincent/Studio/lottoData/web_executor.py -ExecStartPre=/home/vincent/Studio/lottoData/venv/bin/python3 -c "import flask; import pandas; import openpyxl; import requests; import bs4" -Restart=on-failure -RestartSec=5 -KillMode=control-group - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/history_loader.py b/history_loader.py new file mode 100644 index 0000000..6995db0 --- /dev/null +++ b/history_loader.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +history_loader.py — 双色球历史数据 Excel 公共加载模块 + +统一 Excel 格式检测和列名标准化逻辑,供 lottery.py 和 app.py 共同引用。 +支持三种格式: + 格式A: Row0=新列名(期号|开奖日期|红球1~6|蓝球|特别号), Row1=旧列名(开奖时间|期数|号码|...), Row2+=数据 + 格式B: Row0=标准列名(开奖时间|期数|号码|开机号|...), Row1+=数据 + 格式C: 直接含"号码"列的标准 DataFrame +""" + +import pandas as pd +import os +from collections import Counter +import re + +# 标准列名(lottery.py 和 app.py 期望的列) +LEGACY_COLUMNS = ['开奖时间', '期数', '号码', '开机号', '和值特征', '奇偶比', '大小比', '奇偶形态', '跨度', '其他'] + + +def load_history_dataframe(history_file): + """智能加载历史数据 Excel,兼容多种格式。 + + 返回统一的 DataFrame,使用 LEGACY_COLUMNS 列名。 + """ + if not os.path.exists(history_file): + return pd.DataFrame() + + raw_df = pd.read_excel(history_file, header=None) + + if raw_df.empty: + return pd.DataFrame() + + 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_in_row0 = any(col in row0_vals for col in ['开奖时间', '期数', '号码']) + has_legacy_in_row1 = any(col in row1_vals for col in ['开奖时间', '期数', '号码']) + 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 = len(data_df.columns) + data_df.columns = LEGACY_COLUMNS[:min(num_cols, len(LEGACY_COLUMNS))] + \ + [f'col_{i}' for i in range(min(num_cols, len(LEGACY_COLUMNS)), num_cols)] + elif has_legacy_in_row0: + # 格式B:Row0 就是标准列名 + data_df = raw_df.iloc[1:].copy() + num_cols = len(data_df.columns) + data_df.columns = LEGACY_COLUMNS[:min(num_cols, len(LEGACY_COLUMNS))] + \ + [f'col_{i}' for i in range(min(num_cols, len(LEGACY_COLUMNS)), num_cols)] + else: + # 格式C:尝试默认读取 + data_df = pd.read_excel(history_file) + if '号码' not in data_df.columns: + # 可能是分列格式,尝试构建号码列 + red_cols = [f'红球 {i}' for i in range(1, 7)] + 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: + data_df['号码'] = data_df.apply( + lambda row: _build_number_string(row, red_cols), axis=1) + + num_cols = len(data_df.columns) + if not any(c in data_df.columns for c in LEGACY_COLUMNS[:3]): + data_df.columns = LEGACY_COLUMNS[:min(num_cols, len(LEGACY_COLUMNS))] + \ + [f'col_{i}' for i in range(min(num_cols, len(LEGACY_COLUMNS)), num_cols)] + + data_df = data_df.reset_index(drop=True) + return data_df + + +def _build_number_string(row, red_cols): + """将分列红球 + 蓝球拼接为 14 位号码字符串。""" + 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) + + +def parse_number_string(numbers_str): + """解析号码字符串为 (红球列表, 蓝球)。 + + 支持以下格式: + - 拼接字符串: '08121821243001' (6红球×2位 + 1蓝球×2位) + - 加号分隔: '03,12,16,22,25,28+10' + - 空格/逗号分隔: '08 12 18 21 24 30 01' + """ + if not numbers_str or pd.isna(numbers_str): + return [], 0 + + s = str(numbers_str).strip() + + # 情况1: 纯拼接字符串(14位或以上,无分隔符) + if re.match(r'^\d{14,}$', s): + red_balls = [int(s[i:i+2]) for i in range(0, 12, 2)] + blue_ball = int(s[12:14]) + if all(1 <= b <= 33 for b in red_balls) and 1 <= blue_ball <= 16: + return red_balls, blue_ball + return [], 0 + + # 情况2: 加号分隔 + if '+' in s: + parts = s.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 + return [], 0 + + # 情况3: 正则提取数字 + number_list = re.findall(r'\d+', s) + if len(number_list) >= 7: + try: + red_balls = [int(x) for x in number_list[:6]] + blue_ball = int(number_list[6]) + if all(1 <= b <= 33 for b in red_balls) and 1 <= blue_ball <= 16: + return red_balls, blue_ball + except ValueError: + pass + + return [], 0 + + +def compute_statistics(history_file): + """从历史数据 Excel 计算统计信息,返回字典。""" + if not os.path.exists(history_file): + return {} + + data_df = load_history_dataframe(history_file) + + red_ball_counts = Counter() + blue_ball_counts = Counter() + sum_values = [] + span_values = [] + + for _, row in data_df.iterrows(): + s = str(row.get('号码', '')).strip() + if len(s) >= 14: + reds = [int(s[i:i+2]) for i in range(0, 12, 2)] + blue = int(s[12:14]) + if all(1 <= r <= 33 for r in reds) and 1 <= blue <= 16: + red_ball_counts.update(reds) + blue_ball_counts[blue] += 1 + sum_values.append(sum(reds)) + span_values.append(max(reds) - min(reds)) + + stats = {} + + if red_ball_counts: + sorted_reds = sorted(red_ball_counts.items(), key=lambda x: x[1], reverse=True) + stats['hot_reds'] = [x[0] for x in sorted_reds[:15]] + stats['cold_reds'] = [x[0] for x in sorted_reds[-15:]] + + if blue_ball_counts: + sorted_blues = sorted(blue_ball_counts.items(), key=lambda x: x[1], reverse=True) + stats['hot_blues'] = [x[0] for x in sorted_blues[:8]] + + # 奇偶比/大小比统计 + odd_even_ratios = Counter() + size_ratios = Counter() + for _, row in data_df.iterrows(): + oe = str(row.get('奇偶比', '')).strip() + sz = str(row.get('大小比', '')).strip() + if oe and oe != 'nan': + odd_even_ratios[oe] += 1 + if sz and sz != 'nan': + size_ratios[sz] += 1 + + if odd_even_ratios: + stats['common_odd_even'] = max(odd_even_ratios, key=odd_even_ratios.get) + if size_ratios: + stats['common_size_ratio'] = max(size_ratios, key=size_ratios.get) + + if sum_values: + import numpy as np + arr = np.array(sum_values) + stats['sum_range'] = { + 'min': int(arr.min()), 'max': int(arr.max()), + 'mean': float(arr.mean()), 'std': float(arr.std()) + } + + if span_values: + import numpy as np + arr = np.array(span_values) + stats['span_range'] = { + 'min': int(arr.min()), 'max': int(arr.max()), + 'mean': float(arr.mean()), 'std': float(arr.std()) + } + + stats['history_count'] = len(data_df) + return stats diff --git a/lottery.py b/lottery.py index 301e615..0fb8611 100644 --- a/lottery.py +++ b/lottery.py @@ -243,36 +243,29 @@ class DoubleColorBallGenerator: df = df.copy() df['号码'] = df.apply(build_number_string, axis=1) - # 重命名列到标准列名(保留原始列) - # 格式A -> 格式B 映射: - # 期号 -> 开奖时间(其实存的是日期) - # 开奖日期 -> 期数(其实存的是期号数字) - # 红球1 -> 号码(已在上面构建) - # 特别说 -> 跨度 - # 其他列按顺序映射 + # 重命名列到标准列名 — 仅映射有语义对应关系的列 + # 格式A -> 格式B 语义映射: + # 期号 -> 开奖时间(实际存的是日期) + # 开奖日期 -> 期数(实际存的是期号数字) + # 特别号 -> 跨度 + # 红球 1~6 和 蓝球 用于构建"号码"列后不再映射 + # 格式A不含开机号/和值特征/奇偶比/大小比/奇偶形态等字段,留空 rename_map = {} if '期号' in df.columns: rename_map['期号'] = '开奖时间' if '开奖日期' in df.columns: rename_map['开奖日期'] = '期数' - if '蓝球' in df.columns and '特别号' in df.columns: + if '特别号' 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 [f'红球 {i}' for i in range(1, 7)] + ['蓝球']: + if col in df.columns: + df = df.drop(columns=[col]) + + # 确保所有标准列都存在(格式A缺失的字段留空) for col in standard_columns: if col not in df.columns: df[col] = '' diff --git a/web_executor.py b/web_executor.py index 329ed60..b15cb7f 100644 --- a/web_executor.py +++ b/web_executor.py @@ -1,9 +1,19 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -双色球数据抓取 Web 服务 -提供 Web 界面执行抓取任务和查看实时结果 -监听 0.0.0.0,支持局域网访问 +双色球数据抓取 Web 服务 [已废弃] + +⚠️ 此模块已废弃,功能已整合到 app.py 统一入口中。 +请使用 app.py 作为主服务(端口 8085),它已包含: + - /fetch 路由(抓取控制台) + - /api/fetch/status(抓取状态) + - /api/fetch/execute(触发抓取) + +本文件仅保留用于历史参考,不应再独立部署。 + +原始功能: + 提供Web界面执行抓取任务和查看实时结果 + 监听 0.0.0.0,支持局域网访问 """ from flask import Flask, send_from_directory, jsonify