Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b61f0ba38 | ||
|
|
84a5fb43f5 | ||
|
|
ad597d0a79 | ||
|
|
76ef84aa8f | ||
|
|
b584a8f98e | ||
|
|
34ff61d1ee | ||
|
|
3362e1e072 | ||
|
|
66fd64ef0f | ||
|
|
6d2302875b | ||
|
|
299d508d7c | ||
|
|
f641276b9d | ||
|
|
1954948445 | ||
|
|
074aa7ab3e | ||
|
|
ffa60e6b26 | ||
|
|
9f85b559b0 | ||
|
|
354b100558 | ||
|
|
675246cde2 | ||
|
|
6081254d63 | ||
|
|
2de27db0e6 | ||
|
|
5a888a730e | ||
|
|
311c6c6224 | ||
|
|
204cb361c6 | ||
|
|
b1539d9baf | ||
|
|
6229b98076 | ||
|
|
33664adbe0 | ||
|
|
8c358dc376 | ||
|
|
bbb6e9ab69 | ||
|
|
a88764162d | ||
|
|
affb0a4002 | ||
|
|
7c260b1f99 | ||
|
|
61310de5a0 | ||
|
|
b8203e6168 | ||
|
|
a281b5fa8e | ||
|
|
0d985506ba | ||
|
|
2ec3b2dab5 | ||
|
|
27334d8589 | ||
|
|
51718e11f3 | ||
|
|
ee9a5a3d03 | ||
|
|
9994c71374 | ||
|
|
166740a74c | ||
|
|
f6d967869d | ||
|
|
980b019809 | ||
|
|
bb1a2ebb9e | ||
|
|
5435fe3686 | ||
|
|
a51ed4bb95 | ||
|
|
f654081e8f | ||
|
|
6722f6f7cc | ||
|
|
875a770c5a | ||
|
|
229b70c1be | ||
|
|
75a529be35 | ||
|
|
08c1affb14 | ||
|
|
4ab24a9ae8 | ||
|
|
173871cf6a | ||
|
|
a8a678a5f6 | ||
|
|
c59b7ade29 | ||
|
|
5f2e2e6c00 | ||
|
|
750ef91e29 | ||
|
|
a9281f27c7 | ||
|
|
ecef509037 | ||
|
|
11b06be9b1 | ||
|
|
bc9aef5a2f | ||
|
|
7ebd563134 | ||
|
|
cd9530686c | ||
|
|
80e0e6a8a6 | ||
|
|
8d528ba5fe | ||
|
|
b133fd36ee | ||
|
|
dbdb418e15 | ||
|
|
2496050ee7 | ||
|
|
ee4f2504bd | ||
|
|
5a8585d027 | ||
|
|
20f0b4b111 | ||
|
|
e501393b66 | ||
|
|
127475e539 | ||
|
|
ed1733e49d | ||
|
|
d246ab44e2 | ||
|
|
6b3bbab5ce | ||
|
|
93474019a4 | ||
|
|
50f0b6aff5 | ||
|
|
186521392d | ||
|
|
c3385b0e04 | ||
|
|
16b8d0365a | ||
|
|
0d656e8076 | ||
|
|
cd44cd6e47 | ||
|
|
7381097582 | ||
|
|
8dacaea803 | ||
|
|
30d768a3e6 | ||
|
|
4509279ac7 |
@@ -106,6 +106,17 @@
|
||||
| 7 | 幂等性 | SQL 初始化必须可重复执行 |
|
||||
| 8 | 文档同步 | 功能完成后更新 `业务进度管理.md` |
|
||||
|
||||
## 代码变更后部署
|
||||
|
||||
- 只要本项目有代码变更,完成必要验证后,必须执行以下命令,将最新代码更新到测试服运行环境:
|
||||
|
||||
```powershell
|
||||
cd D:\www\sport-era
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net
|
||||
```
|
||||
|
||||
- 默认将这条部署命令视为 Sport Era 项目代码变更后的必做步骤,除非用户在当前任务中明确说明“不需要部署”。
|
||||
|
||||
## 设计稿映射
|
||||
|
||||
设计稿与页面/组件映射统一维护在:
|
||||
|
||||
@@ -14,11 +14,15 @@ Docker 只负责运行 Python 爬虫和容器内 cron。MySQL、Redis、Nginx
|
||||
配置源:
|
||||
|
||||
- Docker Compose:`docker/docker-compose.crawler.yml`
|
||||
- Compose 路径变量示例:`docker/.env.example`
|
||||
- 环境变量示例:`docker/.env.docker.example`
|
||||
- 任务清单:`server/public/dongqiudi-crawler/config/crawler_tasks.yaml`
|
||||
- 爬虫源码:`docker/crawler`
|
||||
- 任务清单:`docker/crawler/config/crawler_tasks.yaml`
|
||||
- 任务日志:管理后台 `系统设置 -> 系统维护 -> 爬虫任务日志`
|
||||
- 错误日志:管理后台 `系统设置 -> 系统维护 -> 爬虫错误日志`
|
||||
|
||||
`docker/docker-compose.crawler.yml` 默认使用 `./crawler` 作为构建上下文和 `logs/data` 挂载目录。如果服务器上的 `docker/.env` 已存在,需要确认里面是 `CRAWLER_DIR=./crawler`,或者删除该变量让 Compose 使用默认值。
|
||||
|
||||
禁用的定时任务不要放进 Docker 执行清单;如需临时停用某个 Docker 爬虫任务,将 `crawler_tasks.yaml` 对应任务的 `active` 改为 `false`,然后重建或重启 crawler 容器。
|
||||
|
||||
### 当前 Docker 定时任务列表
|
||||
@@ -137,17 +141,26 @@ cd D:\www\sport-era
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net
|
||||
```
|
||||
|
||||
全量模式默认只打包以下目录:
|
||||
全量模式默认以仓库根目录为源,只打包以下四个子目录:
|
||||
|
||||
```text
|
||||
server/app
|
||||
server/config
|
||||
server/extend
|
||||
server/public/dongqiudi-crawler
|
||||
server/route
|
||||
server/
|
||||
uniapp/
|
||||
admin/
|
||||
docker/
|
||||
```
|
||||
|
||||
默认会排除运行态或较大目录,例如 `.env`、`vendor`、`runtime`、`public/uploads`、`public/admin`、`public/mobile`、爬虫 `data/logs/venv`。如确实需要包含,可追加参数:
|
||||
也就是说,远端 `RemoteDir` 下最终会形成:
|
||||
|
||||
```text
|
||||
<RemoteDir>/server
|
||||
<RemoteDir>/uniapp
|
||||
<RemoteDir>/admin
|
||||
<RemoteDir>/docker
|
||||
<RemoteDir>/docker/crawler
|
||||
```
|
||||
|
||||
默认会排除运行态、密钥或较大目录,例如 `server/.env`、`docker/.env`、`docker/.env.docker`、`server/vendor`、`server/runtime`、`server/public/uploads`、`server/public/admin`、`server/public/mobile`、爬虫 `data/logs/venv`,以及 `uniapp/node_modules`、`uniapp/dist`、`admin/node_modules`、`admin/dist`。如确实需要包含,可追加参数:
|
||||
|
||||
```bash
|
||||
--include-env
|
||||
@@ -197,11 +210,11 @@ cd D:\www\sport-era
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun
|
||||
```
|
||||
|
||||
脚本会在打包和上传前先检查 `server` 工作区;如果 `server` 内存在未提交、删除或未跟踪文件,会先自动提交这些变更,再继续计算增量范围。可以通过 `-AutoCommitMessage "提交说明"` 自定义这次自动提交的说明。若存在合并冲突,需要先手动解决。
|
||||
脚本会在打包和上传前先检查默认打包范围(`server` + `uniapp` + `admin` + `docker`);如果这些目录内存在未提交、删除或未跟踪文件,会先自动提交这些变更,再继续计算增量范围。可以通过 `-AutoCommitMessage "提交说明"` 自定义这次自动提交的说明。若存在合并冲突,需要先手动解决。
|
||||
|
||||
注意:自动增量只包含指定提交范围内已经提交到 Git 的差异,不会包含工作区未提交文件。需要指定任意起止提交时,继续使用 `-From <old_commit> -To <new_commit>`。
|
||||
|
||||
如果当前根目录 Git 只把 `server` 记录为 gitlink/submodule,且本机没有 `server/.git` 子仓库元数据,脚本无法拿到 `server` 内部的文件级差异;此时会自动降级为打包当前工作区默认的 5 个 server 目录,保证可以继续发布。
|
||||
如果当前根目录 Git 只把 `server`、`uniapp` 或 `admin` 记录为 gitlink/submodule,脚本无法拿到对应目录内部的文件级差异;此时会跳过该 gitlink 的自动提交,并把该目录从当前工作区按源码目录补进增量包,保证可以继续发布。
|
||||
|
||||
### 只演练不上传
|
||||
|
||||
@@ -225,14 +238,34 @@ cd D:\www\sport-era
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net -PostInstall "php think optimize:clear"
|
||||
```
|
||||
|
||||
### 发布 uniapp 源码到服务器并在服务器构建
|
||||
|
||||
默认发布命令会把 `server/`、`uniapp/`、`admin/` 和 `docker/` 一起同步到远端根目录;如果需要在服务器端构建 `uniapp`,建议在远端根目录执行构建,再把 H5 产物写回同一套代码下的 `server/public/mobile`:
|
||||
|
||||
```powershell
|
||||
cd D:\www\sport-era
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 `
|
||||
-Sudo `
|
||||
-RemoteDir /www/wwwroot/test-server.sbnews.net `
|
||||
-UniappReleasePath ../server/public/mobile `
|
||||
-PostInstall "cd uniapp && npm install && npm run build:h5"
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `-RemoteDir` 指向服务器上的项目根目录,解压后里面会同时有 `server/`、`uniapp/`、`admin/` 和 `docker/`。
|
||||
- `-UniappReleasePath` 会通过环境变量 `UNIAPP_RELEASE_PATH` 传给 `uniapp/scripts/release.mjs`;这里传 `../server/public/mobile`,是因为构建命令会先 `cd uniapp`,再把 H5 产物回写到兄弟目录 `server/public/mobile`。
|
||||
- 服务器需要预先安装 `node >= 18.19.0` 和 `npm`,否则 `npm install` / `npm run build:h5` 无法执行。
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. `ssh: Could not resolve hostname sbnews`:检查 Windows 的 `C:\Users\Administrator\.ssh\config` 是否存在 `Host sbnews`,或直接用 `-HostName <服务器IP>`。
|
||||
2. PowerShell 禁止执行脚本:使用示例中的 `-ExecutionPolicy Bypass -File`,或以管理员身份调整当前用户执行策略。
|
||||
3. `tar: Cannot utime` 或 `Cannot change mode`:远端目录权限不足,请加 `-Sudo`。脚本会使用 `sudo tar` 覆盖解压,并默认跳过还原 owner/permission,减少权限冲突。
|
||||
4. 增量发布提示 commit 无效:确认 commit id 属于当前根目录 Git 可识别的历史。
|
||||
5. `server is tracked as a root gitlink`:脚本检测到根 Git 只记录了 `server` 指针,无法做文件级增量,会自动改为打包当前工作区默认 5 个 server 目录。
|
||||
5. `root gitlink include paths will be copied from working tree`:脚本检测到默认发布范围里仍有 gitlink/submodule 目录,会把这些目录从当前工作区源码补进增量包。
|
||||
6. 仍需使用 Bash 兼容版时,可继续运行 `bash ./scripts/deploy-server.sh ...`。
|
||||
7. 服务器运行 `uniapp` 报 `node: command not found` 或 `npm: command not found`:先在服务器安装 Node.js 18.19+ 与 npm,再执行 `-SourceDir uniapp` 的发布命令。
|
||||
|
||||
<a href="https://www.likeshop.cn"></a><br>
|
||||
<a href="https://www.likeshop.cn"></a><br>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
INSERT INTO `la_community_tag` (`name`, `icon`, `sort`, `post_count`, `is_hot`, `status`, `create_time`)
|
||||
SELECT '世界杯', '', 90, 0, 1, 1, UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_tag` WHERE `name` = '世界杯'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post` (`user_id`, `content`, `images`, `post_type`, `match_id`, `is_paid`, `price_points`, `free_content_len`, `status`, `create_time`, `update_time`)
|
||||
SELECT 1, '世界杯开赛氛围越来越浓了,大家觉得本届最大的黑马会是谁?', '[]', 0, 0, 0, 0, 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_post` WHERE `content` = '世界杯开赛氛围越来越浓了,大家觉得本届最大的黑马会是谁?'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post` (`user_id`, `content`, `images`, `post_type`, `match_id`, `is_paid`, `price_points`, `free_content_len`, `status`, `create_time`, `update_time`)
|
||||
SELECT 1, '我更看好防守稳一点的球队,世界杯这种大赛还是先稳住再说。', '[]', 0, 0, 0, 0, 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_post` WHERE `content` = '我更看好防守稳一点的球队,世界杯这种大赛还是先稳住再说。'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post` (`user_id`, `content`, `images`, `post_type`, `match_id`, `is_paid`, `price_points`, `free_content_len`, `status`, `create_time`, `update_time`)
|
||||
SELECT 1, '这届世界杯的球员状态起伏很大,关键还是看临场调整和替补深度。', '[]', 0, 0, 0, 0, 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_post` WHERE `content` = '这届世界杯的球员状态起伏很大,关键还是看临场调整和替补深度。'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post_tag` (`post_id`, `tag_id`)
|
||||
SELECT p.id, t.id
|
||||
FROM `la_community_post` p
|
||||
JOIN `la_community_tag` t ON t.`name` = '世界杯'
|
||||
WHERE p.`content` IN (
|
||||
'世界杯开赛氛围越来越浓了,大家觉得本届最大的黑马会是谁?',
|
||||
'我更看好防守稳一点的球队,世界杯这种大赛还是先稳住再说。',
|
||||
'这届世界杯的球员状态起伏很大,关键还是看临场调整和替补深度。'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `post_id` = `post_id`;
|
||||
|
||||
UPDATE `la_community_tag` t
|
||||
SET `post_count` = (
|
||||
SELECT COUNT(*)
|
||||
FROM `la_community_post_tag` pt
|
||||
JOIN `la_community_post` p ON p.id = pt.post_id AND p.delete_time IS NULL
|
||||
WHERE pt.tag_id = t.id
|
||||
)
|
||||
WHERE t.`name` = '世界杯';
|
||||
@@ -1,29 +0,0 @@
|
||||
INSERT INTO `la_community_post` (`user_id`, `content`, `images`, `post_type`, `match_id`, `is_paid`, `price_points`, `free_content_len`, `status`, `create_time`, `update_time`)
|
||||
SELECT 1, '世界杯开赛氛围越来越浓了,大家觉得本届最大的黑马会是谁?', '[]', 0, 0, 0, 0, 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_post` WHERE `content` = '世界杯开赛氛围越来越浓了,大家觉得本届最大的黑马会是谁?'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post` (`user_id`, `content`, `images`, `post_type`, `match_id`, `is_paid`, `price_points`, `free_content_len`, `status`, `create_time`, `update_time`)
|
||||
SELECT 1, '我更看好防守稳一点的球队,世界杯这种大赛还是先稳住再说。', '[]', 0, 0, 0, 0, 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_post` WHERE `content` = '我更看好防守稳一点的球队,世界杯这种大赛还是先稳住再说。'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post` (`user_id`, `content`, `images`, `post_type`, `match_id`, `is_paid`, `price_points`, `free_content_len`, `status`, `create_time`, `update_time`)
|
||||
SELECT 1, '这届世界杯的球员状态起伏很大,关键还是看临场调整和替补深度。', '[]', 0, 0, 0, 0, 100, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `la_community_post` WHERE `content` = '这届世界杯的球员状态起伏很大,关键还是看临场调整和替补深度。'
|
||||
);
|
||||
|
||||
INSERT INTO `la_community_post_tag` (`post_id`, `tag_id`)
|
||||
SELECT p.id, 17
|
||||
FROM `la_community_post` p
|
||||
WHERE p.`content` IN (
|
||||
'世界杯开赛氛围越来越浓了,大家觉得本届最大的黑马会是谁?',
|
||||
'我更看好防守稳一点的球队,世界杯这种大赛还是先稳住再说。',
|
||||
'这届世界杯的球员状态起伏很大,关键还是看临场调整和替补深度。'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE `post_id` = `post_id`;
|
||||
|
||||
UPDATE `la_community_tag` SET `post_count` = (SELECT COUNT(*) FROM `la_community_post_tag` WHERE tag_id = 17) WHERE id = 17;
|
||||
@@ -21,6 +21,14 @@ AI_KB_REDIS_DB=4
|
||||
DQD_LOG_LEVEL=INFO
|
||||
DQD_LOG_FILE=/app/logs/crawler.log
|
||||
|
||||
# Third-party match odds crawler. Keep the real account in docker/.env.docker only.
|
||||
ODDS_USERNAME=change-me
|
||||
ODDS_PASSWORD=change-me
|
||||
ODDS_BASE_URLS=https://205.201.0.120/,https://mos022.com/,https://hga039.com/,https://mos100.com/
|
||||
ODDS_SPORT_TYPES=FT,BK,ES,TN,VB,BM,TT,BS,SK,OP
|
||||
ODDS_SHOW_TYPES=live,today,early
|
||||
ODDS_VERIFY_TLS=0
|
||||
|
||||
# AI KB worker embedding config. Keep real key in docker/.env.docker only.
|
||||
EMBEDDING_API_KEY=change-me
|
||||
EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CRAWLER_DIR=./crawler
|
||||
@@ -1,4 +1,8 @@
|
||||
/data
|
||||
/log
|
||||
/logs
|
||||
.env
|
||||
.env.docker
|
||||
crawler/venv/
|
||||
crawler/logs/
|
||||
crawler/data/
|
||||
|
||||
@@ -3,8 +3,8 @@ __pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
logs/*
|
||||
data/locks/*
|
||||
logs/
|
||||
data/
|
||||
test_output.txt
|
||||
tmp_*.py
|
||||
tmp_*.sql
|
||||
@@ -10,9 +10,9 @@ build/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
logs/*.log
|
||||
data/*.json
|
||||
data/*.csv
|
||||
logs/
|
||||
data/
|
||||
test_output.txt
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
@@ -27,7 +27,8 @@ RUN python -m pip install --upgrade pip setuptools wheel \
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN mkdir -p /app/logs /app/data/locks /etc/sport-era-crawler \
|
||||
RUN sed -i 's/\r$//' /app/docker/entrypoint.sh \
|
||||
&& mkdir -p /app/logs /app/data/locks /etc/sport-era-crawler \
|
||||
&& chmod +x /app/docker/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
@@ -68,7 +68,7 @@ python main.py cron
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
dongqiudi-crawler/
|
||||
docker/crawler/
|
||||
├── config/
|
||||
│ └── settings.yaml # 配置文件
|
||||
├── src/
|
||||
@@ -25,7 +25,40 @@ DEFAULT_REQUEST_TIMEOUT = 180
|
||||
DEFAULT_RETRY_TIMES = 2
|
||||
DEFAULT_RETRY_SLEEP_MS = 800
|
||||
BOT_ACCOUNT_PREFIX = "ai_comment_"
|
||||
BOT_NICKNAME_PREFIX = "AI评论员"
|
||||
BOT_HANDLE_PREFIXES = [
|
||||
"看台",
|
||||
"深夜",
|
||||
"边线",
|
||||
"北区",
|
||||
"南看台",
|
||||
"东门",
|
||||
"西街",
|
||||
"慢热",
|
||||
"小酒馆",
|
||||
"补时",
|
||||
"角旗",
|
||||
"传中",
|
||||
"临场",
|
||||
"半场",
|
||||
"球门后",
|
||||
"旧球鞋",
|
||||
"赛点",
|
||||
"风向",
|
||||
"追球",
|
||||
"回防",
|
||||
]
|
||||
BOT_HANDLE_SUFFIXES = [
|
||||
"阿泽",
|
||||
"小顾",
|
||||
"老韩",
|
||||
"阿凯",
|
||||
"小唐",
|
||||
"阿林",
|
||||
"阿松",
|
||||
"阿北",
|
||||
"阿岳",
|
||||
"阿诚",
|
||||
]
|
||||
|
||||
DEFAULT_ARTICLE_PROMPT = (
|
||||
"你是一名专业、克制、真实的体育资讯评论员。请根据给定资讯内容生成1条中文评论,"
|
||||
@@ -592,15 +625,10 @@ class AiCommentRepository:
|
||||
for index in range(1, target_count + 1):
|
||||
persona = self._persona_for_index(index)
|
||||
account = f"{BOT_ACCOUNT_PREFIX}{index:04d}"
|
||||
nickname = self._build_bot_nickname(index, persona["label"])
|
||||
row = indexed.get(index)
|
||||
if row:
|
||||
if row["nickname"] != nickname:
|
||||
cursor.execute(
|
||||
f"UPDATE `{self.prefix}user` SET nickname=%s, update_time=%s WHERE id=%s",
|
||||
(nickname, now, row["id"]),
|
||||
)
|
||||
continue
|
||||
nickname = self._build_bot_nickname(index)
|
||||
password = self._create_password(self._random_password(), salt)
|
||||
sn = self._create_user_sn(cursor)
|
||||
cursor.execute(
|
||||
@@ -618,7 +646,8 @@ class AiCommentRepository:
|
||||
for row in rows:
|
||||
index = self._parse_account_index(row["account"])
|
||||
persona = self._persona_for_index(index)
|
||||
users.append(VirtualUser(int(row["id"]), row["account"], self._build_bot_nickname(index, persona["label"]), persona["key"]))
|
||||
nickname = str(row.get("nickname") or "").strip() or self._build_bot_nickname(index)
|
||||
users.append(VirtualUser(int(row["id"]), row["account"], nickname, persona["key"]))
|
||||
self._virtual_users_cache = list(users)
|
||||
return users[:target_count]
|
||||
|
||||
@@ -991,9 +1020,11 @@ class AiCommentRepository:
|
||||
return hashlib.md5(str(random.random()).encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
@staticmethod
|
||||
def _build_bot_nickname(index: int, persona_label: str) -> str:
|
||||
number = f"{index:02d}" if index < 100 else str(index)
|
||||
return f"{BOT_NICKNAME_PREFIX}{number}·{persona_label}"
|
||||
def _build_bot_nickname(index: int) -> str:
|
||||
zero_based = max(0, index - 1)
|
||||
prefix = BOT_HANDLE_PREFIXES[zero_based % len(BOT_HANDLE_PREFIXES)]
|
||||
suffix = BOT_HANDLE_SUFFIXES[(zero_based // len(BOT_HANDLE_PREFIXES)) % len(BOT_HANDLE_SUFFIXES)]
|
||||
return f"{prefix}{suffix}"
|
||||
|
||||
@staticmethod
|
||||
def _persona_for_index(index: int) -> Dict[str, str]:
|
||||
@@ -154,3 +154,13 @@ tasks:
|
||||
action: ai_comment_dispatch
|
||||
cron: "*/10 * * * *"
|
||||
active: true
|
||||
- key: match_live_stream
|
||||
name: 赛事直播流与主播信息抓取
|
||||
action: match_live_stream
|
||||
cron: "*/1 * * * *"
|
||||
active: true
|
||||
- key: match_odds
|
||||
name: 第三方赛事赔率抓取
|
||||
action: match_odds
|
||||
cron: "*/5 * * * *"
|
||||
active: true
|
||||
@@ -0,0 +1,526 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- saved from url=(0046)https://38.11.29.1:50001/historys/hk/2026.html -->
|
||||
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="applicable-device" content="pc,mobile">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<title>2026年香港搅珠记录</title>
|
||||
<meta name="keywords" content="">
|
||||
<meta name="description" content="">
|
||||
<link href="./2026年香港搅珠记录_files/style.css" rel="stylesheet" type="text/css">
|
||||
<link href="./2026年香港搅珠记录_files/kj.css" rel="stylesheet" type="text/css">
|
||||
<script type="text/javascript" src="./2026年香港搅珠记录_files/jquery.js.下载"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cgi-tan-box-container" id="tipDialogContainer">
|
||||
<div class="cgi-tan-box">
|
||||
<div class="cgi-tan-content" id="tipDialogContainerInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cgi-container">
|
||||
<script type="text/javascript" src="./2026年香港搅珠记录_files/menu-bottom-hk.js.下载"></script><div class="cgi-foot-links"> <ul class="clearfix"> <li><a id="fixedNavIndex" target="_blank" href="https://4.194.218.6:14155/#openkj"><span class="cfl1"></span>网址导航</a></li> <li><a id="fixedNavKjZs" href="https://38.11.29.1:50001/historys/mo/2026.html"><span class="cfl3"></span>[新]澳门记录</a></li> <li><a id="fixedNavKjZl" href="https://38.11.29.1:50001/historys/hk/2026.html" class="hover"><span class="cfl3"></span>香港记录</a></li> <li><a id="fixedNavTk" href="https://38.11.29.6:50010/historys/lam/2026.html"><span class="cfl3"></span>澳门记录</a></li> </ul></div>
|
||||
<div class="xz-black-bg selectYearDialog"></div>
|
||||
<header class="cgi-head">
|
||||
<a class="icon-back" href="javascript:history.back(-1)"></a>
|
||||
<span class="cgi-head-tit tit-center">2026年香港搅珠记录</span>
|
||||
<div id="btn-select-year" class="cgi-head-btn">选择年份</div>
|
||||
<div class="menu02 dn" id="menu" style="display: none;">
|
||||
<ul>
|
||||
<script type="text/javascript" src="./2026年香港搅珠记录_files/year-hk.js.下载"></script><li><a href="https://38.11.29.1:50001/historys/hk/2026.html">2026</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2025.html">2025</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2024.html">2024</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2023.html">2023</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2022.html">2022</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2021.html">2021</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2020.html">2020</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2019.html">2019</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2018.html">2018</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2017.html">2017</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2016.html">2016</a></li><li><a href="https://38.11.29.1:50001/historys/hk/2015.html">2015</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
$("#menu").hide();
|
||||
$("#btn-select-year").click(function() {
|
||||
$("#menu").toggle();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<div class="cgi-zs">
|
||||
<div class="tempWrap" style="overflow:hidden; position:relative;">
|
||||
<div class="bd" id="tabBox1-bd">
|
||||
<div class="con">
|
||||
<div class="kj-tit">香港⑥彩 第<font color="#fe0000">039</font>期 (2026年04月11日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>牛<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">40</dt><dd>兔<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">11</dt><dd>猴<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">17</dt><dd>虎<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">038</font>期 (2026年04月09日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">24</dt><dd>羊<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">43</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>猪<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">16</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">40</dt><dd>兔<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">037</font>期 (2026年04月07日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">33</dt><dd>狗<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">23</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">25</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">34</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">29</dt><dd>虎<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">08</dt><dd>猪<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">49</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">036</font>期 (2026年04月04日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">32</dt><dd>猪<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">40</dt><dd>兔<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">20</dt><dd>猪<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">35</dt><dd>猴<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">43</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">035</font>期 (2026年03月31日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">20</dt><dd>猪<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">32</dt><dd>猪<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>狗<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">19</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">18</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>猪<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">034</font>期 (2026年03月28日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>牛<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">26</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">27</dt><dd>龙<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">11</dt><dd>猴<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">21</dt><dd>狗<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">19</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">033</font>期 (2026年03月26日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">27</dt><dd>龙<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>羊<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">23</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">17</dt><dd>虎<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">03</dt><dd>龙<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">032</font>期 (2026年03月24日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">26</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">06</dt><dd>牛<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">22</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>猪<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">49</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>狗<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">07</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">031</font>期 (2026年03月21日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>狗<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">47</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">18</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">11</dt><dd>猴<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">05</dt><dd>虎<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">23</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>羊<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">030</font>期 (2026年03月19日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">06</dt><dd>牛<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">08</dt><dd>猪<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">41</dt><dd>虎<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">33</dt><dd>狗<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">48</dt><dd>羊<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">029</font>期 (2026年03月17日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">22</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">18</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">16</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">49</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">028</font>期 (2026年03月12日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">07</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">26</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">16</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">30</dt><dd>牛<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">34</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">027</font>期 (2026年03月10日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">34</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">16</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">25</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">35</dt><dd>猴<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">49</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">026</font>期 (2026年03月07日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">18</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>羊<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">22</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">31</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">025</font>期 (2026年03月05日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">04</dt><dd>兔<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">31</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>羊<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">18</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>牛<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">11</dt><dd>猴<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">024</font>期 (2026年03月03日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">20</dt><dd>猪<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">32</dt><dd>猪<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">31</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>猪<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">06</dt><dd>牛<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">023</font>期 (2026年02月28日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">47</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">39</dt><dd>龙<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">05</dt><dd>虎<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">29</dt><dd>虎<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">022</font>期 (2026年02月26日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">06</dt><dd>牛<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">19</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">38</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>牛<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">34</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">021</font>期 (2026年02月24日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">10</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">04</dt><dd>兔<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">03</dt><dd>龙<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">23</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>羊<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">020</font>期 (2026年02月21日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">34</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">18</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">35</dt><dd>猴<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">49</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">33</dt><dd>狗<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">019</font>期 (2026年02月15日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">08</dt><dd>狗<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>虎<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">36</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">33</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">04</dt><dd>虎<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">018</font>期 (2026年02月12日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">03</dt><dd>兔<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">25</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>龙<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">10</dt><dd>猴<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">017</font>期 (2026年02月10日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">39</dt><dd>兔<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">04</dt><dd>虎<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">26</dt><dd>龙<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">03</dt><dd>兔<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">18</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">40</dt><dd>虎<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">016</font>期 (2026年02月07日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">32</dt><dd>狗<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">22</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>虎<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">20</dt><dd>狗<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">015</font>期 (2026年02月05日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">35</dt><dd>羊<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">36</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">01</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">17</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">014</font>期 (2026年02月03日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">39</dt><dd>兔<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">23</dt><dd>羊<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">27</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">05</dt><dd>牛<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">013</font>期 (2026年01月31日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">30</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">35</dt><dd>羊<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">32</dt><dd>狗<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">36</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">25</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">47</dt><dd>羊<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">012</font>期 (2026年01月29日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">06</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">35</dt><dd>羊<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">11</dt><dd>羊<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">011</font>期 (2026年01月27日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">38</dt><dd>龙<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">47</dt><dd>羊<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">25</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">07</dt><dd>猪<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">010</font>期 (2026年01月24日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">16</dt><dd>虎<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">17</dt><dd>牛<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">48</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">22</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>虎<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">06</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">009</font>期 (2026年01月22日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">31</dt><dd>猪<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">04</dt><dd>虎<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">27</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">45</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">008</font>期 (2026年01月20日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">06</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">44</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">09</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">01</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">04</dt><dd>虎<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">27</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">007</font>期 (2026年01月17日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">27</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">12</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">05</dt><dd>牛<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">23</dt><dd>羊<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">006</font>期 (2026年01月15日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">44</dt><dd>狗<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">06</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">03</dt><dd>兔<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">38</dt><dd>龙<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">39</dt><dd>兔<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">48</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">005</font>期 (2026年01月13日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-green">38</dt><dd>龙<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">48</dt><dd>马<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">49</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">34</dt><dd>猴<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">27</dt><dd>兔<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">004</font>期 (2026年01月10日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">37</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">20</dt><dd>狗<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">22</dt><dd>猴<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">16</dt><dd>虎<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">03</dt><dd>兔<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">42</dt><dd>鼠<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">003</font>期 (2026年01月08日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">45</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">15</dt><dd>兔<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">46</dt><dd>猴<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">21</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">24</dt><dd>马<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">40</dt><dd>虎<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">002</font>期 (2026年01月06日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-red">12</dt><dd>马<font class="grey-txt">/</font><font class="wx-jin">金</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>龙<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">08</dt><dd>狗<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">19</dt><dd>猪<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">28</dt><dd>虎<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">36</dt><dd>马<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">01</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
</ul>
|
||||
</div><div class="kj-tit">香港⑥彩 第<font color="#fe0000">001</font>期 (2026年01月03日)</div>
|
||||
<div class="kj-box">
|
||||
<ul class="clearfix">
|
||||
<li><dl><dt class="ball-blue">20</dt><dd>狗<font class="grey-txt">/</font><font class="wx-tu">土</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">16</dt><dd>虎<font class="grey-txt">/</font><font class="wx-mu">木</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">02</dt><dd>龙<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-green">21</dt><dd>鸡<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-red">13</dt><dd>蛇<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">10</dt><dd>猴<font class="grey-txt">/</font><font class="wx-huo">火</font></dd></dl></li>
|
||||
<li class="kj-jia"><dl><dt></dt><dd></dd></dl></li>
|
||||
<li><dl><dt class="ball-blue">14</dt><dd>龙<font class="grey-txt">/</font><font class="wx-shui">水</font></dd></dl></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="cgi-foot">© 2026 Hong Kong Lotto Reserved.</footer>
|
||||
<div class="cgi-foot-blank"></div>
|
||||
</div>
|
||||
|
||||
|
||||
</body></html>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -19,7 +19,7 @@ from pathlib import Path
|
||||
env_file = Path("/etc/sport-era-crawler/env.sh")
|
||||
with env_file.open("w", encoding="utf-8") as fp:
|
||||
for key, value in sorted(os.environ.items()):
|
||||
if key.startswith("DQD_") or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}:
|
||||
if key.startswith(("DQD_", "ODDS_", "HG_ODDS_")) or key in {"TZ", "PYTHONPATH", "PYTHONUNBUFFERED"}:
|
||||
fp.write(f"export {key}={shlex.quote(value)}\n")
|
||||
env_file.chmod(0o600)
|
||||
PY
|
||||
@@ -30,6 +30,8 @@
|
||||
python main.py nba_news # 采集NBA新闻资讯
|
||||
python main.py cba_news # 采集CBA新闻资讯
|
||||
python main.py ai_comment_dispatch # Docker AI 评论调度
|
||||
python main.py match_live_stream # 抓取赛事直播线路播放流
|
||||
python main.py match_odds # 抓取第三方赛事赔率
|
||||
python main.py article_content_fetch # 抓取资讯文章正文内容
|
||||
python main.py all # 采集积分榜 + 赛程
|
||||
python main.py single <season_id> # 采集单个联赛积分榜
|
||||
@@ -59,6 +61,8 @@ TASK_ALERT_POLICY = {
|
||||
"lottery_draw": "saved_if_candidates",
|
||||
"lottery_draw_force": "saved_if_candidates",
|
||||
"ai_comment_dispatch": "saved_if_candidates",
|
||||
"match_live_stream": "saved_if_candidates",
|
||||
"match_odds": "saved_if_candidates",
|
||||
}
|
||||
|
||||
DEFAULT_ACTION_TIMEOUT = 600
|
||||
@@ -86,6 +90,8 @@ ACTION_TIMEOUTS = {
|
||||
"fifa_worldcup_news": 600,
|
||||
"dqd_worldcup": 600,
|
||||
"ai_comment_dispatch": 1800,
|
||||
"match_live_stream": 180,
|
||||
"match_odds": 300,
|
||||
}
|
||||
|
||||
|
||||
@@ -522,6 +528,47 @@ async def cmd_ai_comment_dispatch():
|
||||
return await asyncio.to_thread(run, db_config)
|
||||
|
||||
|
||||
async def cmd_match_live_stream():
|
||||
import asyncio
|
||||
from match_live_stream import run
|
||||
from src.core.config import load_config
|
||||
|
||||
cfg = load_config().database
|
||||
db_config = {
|
||||
"host": cfg.host,
|
||||
"port": cfg.port,
|
||||
"user": cfg.username,
|
||||
"password": cfg.password,
|
||||
"database": cfg.database,
|
||||
"charset": cfg.charset,
|
||||
"prefix": cfg.prefix,
|
||||
}
|
||||
return await asyncio.to_thread(run, db_config)
|
||||
|
||||
|
||||
async def cmd_match_odds():
|
||||
import asyncio
|
||||
from match_odds import run
|
||||
from src.core.config import load_config
|
||||
|
||||
cfg = load_config().database
|
||||
db_config = {
|
||||
"host": cfg.host,
|
||||
"port": cfg.port,
|
||||
"user": cfg.username,
|
||||
"password": cfg.password,
|
||||
"database": cfg.database,
|
||||
"charset": cfg.charset,
|
||||
"prefix": cfg.prefix,
|
||||
}
|
||||
result = await asyncio.to_thread(run, db_config)
|
||||
prefix = "✅" if result.get("success") else "❌"
|
||||
print(f"{prefix} {result.get('summary_text', '第三方赛事赔率抓取完成')}")
|
||||
if result.get("error"):
|
||||
print(f" {result['error']}")
|
||||
return result
|
||||
|
||||
|
||||
async def cmd_match_data():
|
||||
from src.scheduler.task_runner import TaskRunner
|
||||
async with TaskRunner() as runner:
|
||||
@@ -837,6 +884,8 @@ def main():
|
||||
"nba_news": cmd_nba_news,
|
||||
"cba_news": cmd_cba_news,
|
||||
"ai_comment_dispatch": cmd_ai_comment_dispatch,
|
||||
"match_live_stream": cmd_match_live_stream,
|
||||
"match_odds": cmd_match_odds,
|
||||
"article_content_fetch": cmd_article_content_fetch,
|
||||
"match_finish": cmd_match_finish,
|
||||
"live_detail": cmd_live_detail,
|
||||
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pymysql
|
||||
import requests
|
||||
|
||||
|
||||
DETAIL_URL_TEMPLATE = "https://json.ncctrials.com/room/{room_id}/detail.json?v={ts}"
|
||||
LIVE_ROOMS_URL_TEMPLATE = "https://json.ncctrials.com/all_live_rooms.json?v={ts}"
|
||||
YUYAN_SOURCE_SITE = "yuyan"
|
||||
YUYAN_SOURCE_DOMAIN = "https://yuyantv.cn"
|
||||
YUYAN_ROOM_URL_TEMPLATE = YUYAN_SOURCE_DOMAIN + "/pages/liveRoom.html?roomNum={room_num}"
|
||||
FETCH_BATCH_SIZE = 30
|
||||
FETCH_TIMEOUT = 15
|
||||
FETCH_ERROR_LIMIT = 500
|
||||
ROOM_ID_RE = re.compile(r"(?:/room/|[?&]roomNum=)(\d+)")
|
||||
DETAIL_JSONP_RE = re.compile(r"detail\((\{.*\})\)\s*;?\s*$", re.S)
|
||||
LIVE_ROOMS_JSONP_RE = re.compile(r"all_live_rooms\((\{.*\})\)\s*;?\s*$", re.S)
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/137.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json,text/javascript,*/*;q=0.1",
|
||||
"Referer": "https://yuyantv.cn/liveType.html",
|
||||
}
|
||||
|
||||
|
||||
def _should_use_internal_lock() -> bool:
|
||||
flag = str(os.environ.get("DQD_RUNNING_UNDER_TASK_RUNNER") or "").strip().lower()
|
||||
return flag not in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def parse_match_live_detail_html(body: str) -> dict:
|
||||
match = DETAIL_JSONP_RE.search((body or "").strip())
|
||||
if not match:
|
||||
raise ValueError("detail jsonp not found")
|
||||
|
||||
payload = json.loads(match.group(1))
|
||||
stream = payload.get("data", {}).get("stream", {})
|
||||
parsed = {
|
||||
"play_url_flv": str(stream.get("flv") or "").strip(),
|
||||
"play_url_hd_flv": str(stream.get("hdFlv") or "").strip(),
|
||||
"play_url_m3u8": str(stream.get("m3u8") or "").strip(),
|
||||
"play_url_hd_m3u8": str(stream.get("hdM3u8") or "").strip(),
|
||||
}
|
||||
if not any(parsed.values()):
|
||||
raise ValueError("stream urls empty")
|
||||
return parsed
|
||||
|
||||
|
||||
def parse_yuyan_live_rooms_jsonp(body: str) -> List[Dict[str, Any]]:
|
||||
match = LIVE_ROOMS_JSONP_RE.search((body or "").strip())
|
||||
if not match:
|
||||
raise ValueError("live rooms jsonp not found")
|
||||
|
||||
payload = json.loads(match.group(1))
|
||||
if _safe_int(payload.get("code"), default=0) != 200:
|
||||
raise ValueError(str(payload.get("msg") or "live rooms request failed"))
|
||||
|
||||
data = payload.get("data") or {}
|
||||
groups = data.values() if isinstance(data, dict) else data
|
||||
rooms: List[Dict[str, Any]] = []
|
||||
seen_room_nums = set()
|
||||
for group in groups:
|
||||
if not isinstance(group, list):
|
||||
continue
|
||||
for room in group:
|
||||
if not isinstance(room, dict):
|
||||
continue
|
||||
room_num = str(room.get("roomNum") or "").strip()
|
||||
if not room_num or room_num in seen_room_nums:
|
||||
continue
|
||||
if _safe_int(room.get("liveStatus"), default=1) != 1:
|
||||
continue
|
||||
normalized = dict(room)
|
||||
normalized["roomNum"] = room_num
|
||||
rooms.append(normalized)
|
||||
seen_room_nums.add(room_num)
|
||||
return rooms
|
||||
|
||||
|
||||
def choose_next_fetch_at(now_ts: int, has_existing_stream: bool) -> int:
|
||||
return int(now_ts) + (600 if has_existing_stream else 60)
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _trim_error(message: Any) -> str:
|
||||
return str(message or "").strip()[:FETCH_ERROR_LIMIT]
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def _limit_text(value: Any, limit: int) -> str:
|
||||
return _normalize_text(value)[:limit]
|
||||
|
||||
|
||||
def _extract_anchor_info(room: Dict[str, Any]) -> Dict[str, Any]:
|
||||
anchor = room.get("anchor") if isinstance(room.get("anchor"), dict) else {}
|
||||
grow_dto = anchor.get("growDto") if isinstance(anchor.get("growDto"), dict) else {}
|
||||
avatar = str(anchor.get("cutOutIcon") or anchor.get("icon") or "").strip()
|
||||
return {
|
||||
"anchor_id": _safe_int(anchor.get("uid"), default=0),
|
||||
"anchor_name": _limit_text(anchor.get("nickName"), 100),
|
||||
"anchor_avatar": avatar[:500],
|
||||
"anchor_level": _limit_text(grow_dto.get("name"), 50),
|
||||
"room_view_count": _safe_int(room.get("viewCount"), default=0),
|
||||
"room_focus_count": _safe_int(room.get("focusCount"), default=0),
|
||||
"room_notice": _limit_text(room.get("notice"), 500),
|
||||
"room_detail": _limit_text(room.get("detail"), 255),
|
||||
}
|
||||
|
||||
|
||||
def _build_yuyan_room_source_url(room_num: Any) -> str:
|
||||
return YUYAN_ROOM_URL_TEMPLATE.format(room_num=str(room_num or "").strip())
|
||||
|
||||
|
||||
def _derive_room_id(source_url: str) -> str:
|
||||
match = ROOM_ID_RE.search(str(source_url or "").strip())
|
||||
if not match:
|
||||
raise ValueError("room id not found in source_url")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _build_detail_url(source_url: str, now_ts: int) -> str:
|
||||
room_id = _derive_room_id(source_url)
|
||||
return DETAIL_URL_TEMPLATE.format(room_id=room_id, ts=int(now_ts))
|
||||
|
||||
|
||||
def _has_existing_stream(row: Dict[str, Any]) -> bool:
|
||||
return any(
|
||||
str(row.get(field) or "").strip()
|
||||
for field in ("play_url_m3u8", "play_url_hd_m3u8", "play_url_flv", "play_url_hd_flv")
|
||||
)
|
||||
|
||||
|
||||
def _fetch_stream_payload(source_url: str, now_ts: int) -> Dict[str, str]:
|
||||
detail_url = _build_detail_url(source_url, now_ts)
|
||||
response = requests.get(detail_url, timeout=FETCH_TIMEOUT, headers=DEFAULT_HEADERS)
|
||||
response.raise_for_status()
|
||||
return parse_match_live_detail_html(response.text)
|
||||
|
||||
|
||||
def _fetch_yuyan_live_rooms(now_ts: int) -> List[Dict[str, Any]]:
|
||||
url = LIVE_ROOMS_URL_TEMPLATE.format(ts=int(now_ts))
|
||||
response = requests.get(url, timeout=FETCH_TIMEOUT, headers=DEFAULT_HEADERS)
|
||||
response.raise_for_status()
|
||||
return parse_yuyan_live_rooms_jsonp(response.text)
|
||||
|
||||
|
||||
def _build_run_result(
|
||||
candidate_count: int,
|
||||
success_count: int,
|
||||
failed_count: int,
|
||||
room_count: int = 0,
|
||||
upserted_room_count: int = 0,
|
||||
stale_room_count: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
summary_text = (
|
||||
f"赛事直播流与主播信息抓取完成: 同步直播间 {room_count} 个, 入库 {upserted_room_count} 个, 下播 {stale_room_count} 个, 待处理 {candidate_count} 条, "
|
||||
f"成功 {success_count} 条, 失败 {failed_count} 条"
|
||||
)
|
||||
result = {
|
||||
"success": failed_count == 0,
|
||||
"room_count": room_count,
|
||||
"upserted_room_count": upserted_room_count,
|
||||
"stale_room_count": stale_room_count,
|
||||
"candidate_count": candidate_count,
|
||||
"saved_count": success_count,
|
||||
"count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"summary_text": summary_text,
|
||||
}
|
||||
if failed_count > 0:
|
||||
result["error"] = f"赛事直播流与主播信息抓取存在失败线路: 失败 {failed_count} 条"
|
||||
return result
|
||||
|
||||
|
||||
class TaskFileLock:
|
||||
def __init__(self, name: str, lock_dir: Path | None = None):
|
||||
safe_name = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(name or "task"))
|
||||
self.lock_dir = Path(lock_dir or os.environ.get("DQD_LOCK_DIR") or (Path(__file__).resolve().parent / "data" / "locks"))
|
||||
self.path = self.lock_dir / f"{safe_name}.lock"
|
||||
self._fp = None
|
||||
self._locked = False
|
||||
|
||||
def acquire(self) -> bool:
|
||||
self.lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._fp = open(self.path, "a+b")
|
||||
try:
|
||||
self._fp.seek(0)
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
msvcrt.locking(self._fp.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
self._locked = True
|
||||
self._fp.seek(0)
|
||||
self._fp.truncate()
|
||||
self._fp.write(f"pid={os.getpid()} started_at={int(time.time())}\n".encode("utf-8"))
|
||||
self._fp.flush()
|
||||
return True
|
||||
except (BlockingIOError, OSError):
|
||||
self.release()
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
if not self._fp:
|
||||
return
|
||||
try:
|
||||
if self._locked and os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
self._fp.seek(0)
|
||||
msvcrt.locking(self._fp.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
elif self._locked:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
self._fp.close()
|
||||
self._fp = None
|
||||
self._locked = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchLiveStreamRepository:
|
||||
db_config: Dict[str, Any]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.prefix = str(self.db_config.get("prefix") or "la_")
|
||||
self.conn = pymysql.connect(
|
||||
host=self.db_config["host"],
|
||||
port=int(self.db_config["port"]),
|
||||
user=self.db_config["user"],
|
||||
password=self.db_config["password"],
|
||||
database=self.db_config["database"],
|
||||
charset=self.db_config.get("charset") or "utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
def load_due_rows(self, now_ts: int, limit: int = FETCH_BATCH_SIZE) -> List[Dict[str, Any]]:
|
||||
sql = (
|
||||
f"SELECT id, match_id, title, source_url, play_url_flv, play_url_hd_flv, play_url_m3u8, play_url_hd_m3u8 "
|
||||
f"FROM `{self.prefix}match_live` "
|
||||
f"WHERE `delete_time` IS NULL AND `next_fetch_at` <= %s AND `play_type` <> 'html' "
|
||||
f"ORDER BY `next_fetch_at` ASC, `id` ASC LIMIT %s"
|
||||
)
|
||||
with self.conn.cursor() as cur:
|
||||
cur.execute(sql, (int(now_ts), int(limit)))
|
||||
return list(cur.fetchall() or [])
|
||||
|
||||
def upsert_live_rooms(self, rooms: List[Dict[str, Any]], now_ts: int) -> int:
|
||||
sql = (
|
||||
f"INSERT INTO `{self.prefix}match_live` ("
|
||||
"`match_id`, `title`, `source_url`, `play_type`, `iframe_url`, "
|
||||
"`source_site`, `source_domain`, `source_match_key`, `source_line_key`, `source_is_hot`, `source_match_time`, "
|
||||
"`anchor_id`, `anchor_name`, `anchor_avatar`, `anchor_level`, "
|
||||
"`room_view_count`, `room_focus_count`, `room_notice`, `room_detail`, "
|
||||
"`play_url_m3u8`, `play_url_hd_m3u8`, `play_url_flv`, `play_url_hd_flv`, "
|
||||
"`fetch_status`, `fetch_error`, `last_fetch_at`, `next_fetch_at`, `create_time`, `update_time`, `delete_time`"
|
||||
") VALUES ("
|
||||
+ ", ".join(["%s"] * 30)
|
||||
+ ") ON DUPLICATE KEY UPDATE "
|
||||
"`title`=VALUES(`title`), "
|
||||
"`play_type`=VALUES(`play_type`), "
|
||||
"`iframe_url`=VALUES(`iframe_url`), "
|
||||
"`source_site`=VALUES(`source_site`), "
|
||||
"`source_domain`=VALUES(`source_domain`), "
|
||||
"`source_match_key`=VALUES(`source_match_key`), "
|
||||
"`source_line_key`=VALUES(`source_line_key`), "
|
||||
"`source_is_hot`=VALUES(`source_is_hot`), "
|
||||
"`source_match_time`=VALUES(`source_match_time`), "
|
||||
"`anchor_id`=VALUES(`anchor_id`), "
|
||||
"`anchor_name`=VALUES(`anchor_name`), "
|
||||
"`anchor_avatar`=VALUES(`anchor_avatar`), "
|
||||
"`anchor_level`=VALUES(`anchor_level`), "
|
||||
"`room_view_count`=VALUES(`room_view_count`), "
|
||||
"`room_focus_count`=VALUES(`room_focus_count`), "
|
||||
"`room_notice`=VALUES(`room_notice`), "
|
||||
"`room_detail`=VALUES(`room_detail`), "
|
||||
"`next_fetch_at`=VALUES(`next_fetch_at`), "
|
||||
"`update_time`=VALUES(`update_time`), "
|
||||
"`delete_time`=NULL"
|
||||
)
|
||||
saved_count = 0
|
||||
with self.conn.cursor() as cur:
|
||||
for room in rooms:
|
||||
room_num = str(room.get("roomNum") or "").strip()
|
||||
if not room_num:
|
||||
continue
|
||||
title = _limit_text(room.get("title"), 255) or f"雨燕直播间 {room_num}"
|
||||
anchor_info = _extract_anchor_info(room)
|
||||
params = (
|
||||
0,
|
||||
title,
|
||||
_build_yuyan_room_source_url(room_num),
|
||||
"stream",
|
||||
"",
|
||||
YUYAN_SOURCE_SITE,
|
||||
YUYAN_SOURCE_DOMAIN,
|
||||
f"room:{room_num}",
|
||||
room_num[:100],
|
||||
1 if _safe_int(room.get("markType"), default=0) > 0 else 0,
|
||||
0,
|
||||
anchor_info["anchor_id"],
|
||||
anchor_info["anchor_name"],
|
||||
anchor_info["anchor_avatar"],
|
||||
anchor_info["anchor_level"],
|
||||
anchor_info["room_view_count"],
|
||||
anchor_info["room_focus_count"],
|
||||
anchor_info["room_notice"],
|
||||
anchor_info["room_detail"],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
"",
|
||||
0,
|
||||
int(now_ts),
|
||||
int(now_ts),
|
||||
int(now_ts),
|
||||
None,
|
||||
)
|
||||
cur.execute(sql, params)
|
||||
saved_count += 1
|
||||
if saved_count > 0:
|
||||
self.conn.commit()
|
||||
return saved_count
|
||||
|
||||
def cleanup_stale_live_rooms(self, active_room_nums: List[str], now_ts: int) -> int:
|
||||
active_room_nums = [str(room_num).strip() for room_num in active_room_nums if str(room_num or "").strip()]
|
||||
params: List[Any] = [
|
||||
"直播间已下播或不在雨燕直播列表",
|
||||
int(now_ts),
|
||||
choose_next_fetch_at(now_ts, has_existing_stream=True),
|
||||
int(now_ts),
|
||||
int(now_ts),
|
||||
YUYAN_SOURCE_SITE,
|
||||
]
|
||||
where = [
|
||||
"`delete_time` IS NULL",
|
||||
"`source_site`=%s",
|
||||
"`play_type`='stream'",
|
||||
]
|
||||
if active_room_nums:
|
||||
placeholders = ", ".join(["%s"] * len(active_room_nums))
|
||||
where.append(f"`source_line_key` NOT IN ({placeholders})")
|
||||
params.extend(active_room_nums)
|
||||
|
||||
sql = (
|
||||
f"UPDATE `{self.prefix}match_live` SET "
|
||||
"`fetch_status`=2, `fetch_error`=%s, `last_fetch_at`=%s, `next_fetch_at`=%s, "
|
||||
"`update_time`=%s, `delete_time`=%s "
|
||||
"WHERE " + " AND ".join(where)
|
||||
)
|
||||
with self.conn.cursor() as cur:
|
||||
cur.execute(sql, tuple(params))
|
||||
stale_count = int(getattr(cur, "rowcount", 0) or 0)
|
||||
if stale_count > 0:
|
||||
self.conn.commit()
|
||||
return stale_count
|
||||
|
||||
def mark_success(self, row_id: int, stream_urls: Dict[str, str], now_ts: int) -> None:
|
||||
sql = (
|
||||
f"UPDATE `{self.prefix}match_live` SET "
|
||||
"`play_url_flv`=%s, `play_url_hd_flv`=%s, `play_url_m3u8`=%s, `play_url_hd_m3u8`=%s, "
|
||||
"`fetch_status`=1, `fetch_error`='', `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s "
|
||||
"WHERE `id`=%s"
|
||||
)
|
||||
next_fetch_at = choose_next_fetch_at(now_ts, has_existing_stream=True)
|
||||
with self.conn.cursor() as cur:
|
||||
cur.execute(
|
||||
sql,
|
||||
(
|
||||
stream_urls["play_url_flv"],
|
||||
stream_urls["play_url_hd_flv"],
|
||||
stream_urls["play_url_m3u8"],
|
||||
stream_urls["play_url_hd_m3u8"],
|
||||
int(now_ts),
|
||||
next_fetch_at,
|
||||
int(now_ts),
|
||||
int(row_id),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def mark_failed(self, row_id: int, error_message: str, now_ts: int, has_existing_stream: bool) -> None:
|
||||
sql = (
|
||||
f"UPDATE `{self.prefix}match_live` SET "
|
||||
"`fetch_status`=2, `fetch_error`=%s, `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s "
|
||||
"WHERE `id`=%s"
|
||||
)
|
||||
with self.conn.cursor() as cur:
|
||||
cur.execute(
|
||||
sql,
|
||||
(
|
||||
_trim_error(error_message),
|
||||
int(now_ts),
|
||||
choose_next_fetch_at(now_ts, has_existing_stream=has_existing_stream),
|
||||
int(now_ts),
|
||||
int(row_id),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
|
||||
def run(db_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
lock = None
|
||||
if _should_use_internal_lock():
|
||||
lock = TaskFileLock("match_live_stream")
|
||||
if not lock.acquire():
|
||||
return {
|
||||
"success": True,
|
||||
"candidate_count": 0,
|
||||
"saved_count": 0,
|
||||
"summary_text": "match_live_stream 已在执行中,跳过本次",
|
||||
}
|
||||
|
||||
repo = MatchLiveStreamRepository(db_config)
|
||||
now_ts = int(time.time())
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
room_count = 0
|
||||
upserted_room_count = 0
|
||||
stale_room_count = 0
|
||||
rows: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
live_rooms = _fetch_yuyan_live_rooms(now_ts)
|
||||
room_count = len(live_rooms)
|
||||
upserted_room_count = repo.upsert_live_rooms(live_rooms, now_ts)
|
||||
stale_room_count = repo.cleanup_stale_live_rooms(
|
||||
[str(room.get("roomNum") or "").strip() for room in live_rooms],
|
||||
now_ts,
|
||||
)
|
||||
fetch_limit = max(FETCH_BATCH_SIZE, room_count)
|
||||
rows = repo.load_due_rows(now_ts, limit=fetch_limit)
|
||||
for row in rows:
|
||||
fetch_ts = int(time.time())
|
||||
has_existing_stream = _has_existing_stream(row)
|
||||
try:
|
||||
stream_urls = _fetch_stream_payload(str(row.get("source_url") or ""), fetch_ts)
|
||||
repo.mark_success(_safe_int(row.get("id")), stream_urls, fetch_ts)
|
||||
success_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
repo.mark_failed(_safe_int(row.get("id")), str(exc), fetch_ts, has_existing_stream)
|
||||
failed_count += 1
|
||||
|
||||
candidate_count = len(rows)
|
||||
return _build_run_result(
|
||||
candidate_count,
|
||||
success_count,
|
||||
failed_count,
|
||||
room_count=room_count,
|
||||
upserted_room_count=upserted_room_count,
|
||||
stale_room_count=stale_room_count,
|
||||
)
|
||||
finally:
|
||||
repo.close()
|
||||
if lock is not None:
|
||||
lock.release()
|
||||
@@ -0,0 +1,643 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from urllib.parse import urljoin
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import pymysql
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
|
||||
DEFAULT_BASE_URLS = [
|
||||
"https://205.201.0.120/",
|
||||
"https://mos022.com/",
|
||||
"https://hga039.com/",
|
||||
"https://mos100.com/",
|
||||
]
|
||||
DEFAULT_SPORT_TYPES = ["FT", "BK", "ES", "TN", "VB", "BM", "TT", "BS", "SK", "OP"]
|
||||
DEFAULT_SHOW_TYPES = ["live", "today", "early"]
|
||||
SHOW_TYPE_RTYPE = {
|
||||
"live": "rb",
|
||||
"today": "r",
|
||||
"early": "r",
|
||||
}
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/137.0.0.0 Safari/537.36"
|
||||
)
|
||||
REQUEST_DELAY_SECONDS = 0.4
|
||||
|
||||
|
||||
class OddsGameListUnavailable(Exception):
|
||||
"""Raised when the account/menu combination has no accessible game list."""
|
||||
|
||||
|
||||
def _split_env_list(value: str, default: Iterable[str]) -> List[str]:
|
||||
if not str(value or "").strip():
|
||||
return list(default)
|
||||
parts = re.split(r"[\s,]+", str(value).strip())
|
||||
return [item.strip() for item in parts if item.strip()]
|
||||
|
||||
|
||||
def _normalize_base_url(url: str) -> str:
|
||||
normalized = str(url or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("empty odds base url")
|
||||
if not normalized.startswith(("http://", "https://")):
|
||||
normalized = "https://" + normalized
|
||||
return normalized.rstrip("/") + "/"
|
||||
|
||||
|
||||
def _bool_env(value: Any, default: bool = False) -> bool:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _text(raw: Dict[str, str], key: str) -> str:
|
||||
return str(raw.get(key) or "").strip()
|
||||
|
||||
|
||||
def _limit(value: Any, size: int) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()[:size]
|
||||
|
||||
|
||||
def _dump_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
def _extract_version(html: str) -> str:
|
||||
match = re.search(r"top\.ver\s*=\s*'([^']+)'", html or "")
|
||||
if not match:
|
||||
raise ValueError("odds site version not found")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _parse_login_xml(body: str) -> Dict[str, str]:
|
||||
root = ET.fromstring((body or "").strip())
|
||||
status = (root.findtext("status") or "").strip()
|
||||
msg = (root.findtext("msg") or "").strip()
|
||||
uid = (root.findtext("uid") or "").strip()
|
||||
if status != "200" or not uid:
|
||||
code_message = (root.findtext("code_message") or root.findtext("msg") or "login failed").strip()
|
||||
raise ValueError(f"odds login failed: {code_message or msg or status}")
|
||||
return {child.tag: (child.text or "").strip() for child in list(root)}
|
||||
|
||||
|
||||
def _skippable_game_list_reason(body: str) -> str:
|
||||
prefix = re.sub(r"\s+", " ", str(body or "").strip())[:120]
|
||||
if prefix.startswith("CheckEMNU"):
|
||||
return "菜单不可用"
|
||||
if prefix.startswith("coupon_date error"):
|
||||
return "早盘日期不可用"
|
||||
return ""
|
||||
|
||||
|
||||
def _build_markets(raw: Dict[str, str]) -> Dict[str, Any]:
|
||||
return {
|
||||
"handicap": {
|
||||
"line": _text(raw, "RATIO_R"),
|
||||
"home": _text(raw, "IOR_RH"),
|
||||
"away": _text(raw, "IOR_RC"),
|
||||
"strong": _text(raw, "STRONG"),
|
||||
},
|
||||
"total": {
|
||||
"over_line": _text(raw, "RATIO_OUO"),
|
||||
"under_line": _text(raw, "RATIO_OUU"),
|
||||
"over": _text(raw, "IOR_OUC"),
|
||||
"under": _text(raw, "IOR_OUH"),
|
||||
},
|
||||
"moneyline": {
|
||||
"home": _text(raw, "IOR_MH"),
|
||||
"away": _text(raw, "IOR_MC"),
|
||||
"draw": _text(raw, "IOR_MN"),
|
||||
},
|
||||
"first_half": {
|
||||
"handicap_line": _text(raw, "RATIO_HR"),
|
||||
"handicap_home": _text(raw, "IOR_HRH"),
|
||||
"handicap_away": _text(raw, "IOR_HRC"),
|
||||
"total_over_line": _text(raw, "RATIO_HOUO"),
|
||||
"total_under_line": _text(raw, "RATIO_HOUU"),
|
||||
"total_over": _text(raw, "IOR_HOUC"),
|
||||
"total_under": _text(raw, "IOR_HOUH"),
|
||||
"home_win": _text(raw, "IOR_HMH"),
|
||||
"away_win": _text(raw, "IOR_HMC"),
|
||||
"draw": _text(raw, "IOR_HMN"),
|
||||
},
|
||||
"both_teams_to_score": {
|
||||
"yes": _text(raw, "IOR_TSY"),
|
||||
"no": _text(raw, "IOR_TSN"),
|
||||
},
|
||||
"kickoff": {
|
||||
"home": _text(raw, "IOR_TKH"),
|
||||
"away": _text(raw, "IOR_TKC"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_game_list_xml(
|
||||
body: str,
|
||||
*,
|
||||
source_site: str,
|
||||
source_url: str,
|
||||
sport_type: str,
|
||||
show_type: str,
|
||||
odds_type: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
root = ET.fromstring((body or "").strip())
|
||||
items: List[Dict[str, Any]] = []
|
||||
ec_nodes = root.findall(".//ec")
|
||||
if not ec_nodes:
|
||||
ec_nodes = [root]
|
||||
|
||||
for ec_node in ec_nodes:
|
||||
ec_id = str(ec_node.attrib.get("id") or "").replace("ec", "", 1)
|
||||
for game in ec_node.findall(".//game"):
|
||||
raw = {child.tag: (child.text or "").strip() for child in list(game)}
|
||||
source_match_id = _text(raw, "GID") or str(game.attrib.get("id") or "").strip()
|
||||
if not source_match_id:
|
||||
continue
|
||||
if not _text(raw, "ECID") and ec_id:
|
||||
raw["ECID"] = ec_id
|
||||
markets = _build_markets(raw)
|
||||
item = {
|
||||
"source_site": _limit(source_site, 50),
|
||||
"source_url": _limit(source_url, 255),
|
||||
"source_match_id": _limit(source_match_id, 64),
|
||||
"source_parent_id": _limit(_text(raw, "GIDM"), 64),
|
||||
"source_league_id": _limit(_text(raw, "LID"), 64),
|
||||
"source_event_id": _limit(_text(raw, "ECID") or ec_id, 64),
|
||||
"sport_type": _limit(str(sport_type).upper(), 20),
|
||||
"show_type": _limit(str(show_type).lower(), 20),
|
||||
"odds_type": _limit(str(odds_type).upper(), 20),
|
||||
"league_name": _limit(_text(raw, "LEAGUE"), 150),
|
||||
"match_time_text": _limit(_text(raw, "DATETIME"), 50),
|
||||
"home_team_id": _limit(_text(raw, "TEAM_H_ID"), 64),
|
||||
"away_team_id": _limit(_text(raw, "TEAM_C_ID"), 64),
|
||||
"home_team": _limit(_text(raw, "TEAM_H"), 150),
|
||||
"away_team": _limit(_text(raw, "TEAM_C"), 150),
|
||||
"strong_side": _limit(_text(raw, "STRONG"), 10),
|
||||
"more_count": _safe_int(_text(raw, "MORE")),
|
||||
"is_running": 1 if _text(raw, "IS_RB").upper() == "Y" or str(show_type).lower() == "live" else 0,
|
||||
"handicap_line": _limit(markets["handicap"]["line"], 32),
|
||||
"handicap_home_odds": _limit(markets["handicap"]["home"], 32),
|
||||
"handicap_away_odds": _limit(markets["handicap"]["away"], 32),
|
||||
"total_line": _limit(markets["total"]["over_line"], 32),
|
||||
"total_over_odds": _limit(markets["total"]["over"], 32),
|
||||
"total_under_odds": _limit(markets["total"]["under"], 32),
|
||||
"home_win_odds": _limit(markets["moneyline"]["home"], 32),
|
||||
"draw_odds": _limit(markets["moneyline"]["draw"], 32),
|
||||
"away_win_odds": _limit(markets["moneyline"]["away"], 32),
|
||||
"half_handicap_line": _limit(markets["first_half"]["handicap_line"], 32),
|
||||
"half_home_win_odds": _limit(markets["first_half"]["home_win"], 32),
|
||||
"half_draw_odds": _limit(markets["first_half"]["draw"], 32),
|
||||
"half_away_win_odds": _limit(markets["first_half"]["away_win"], 32),
|
||||
"raw_data": raw,
|
||||
"markets": markets,
|
||||
}
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
@dataclass
|
||||
class OddsLoginSession:
|
||||
base_url: str
|
||||
transform_url: str
|
||||
version: str
|
||||
uid: str
|
||||
ltype: str
|
||||
odds_type: str
|
||||
|
||||
|
||||
class HgOddsClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: int = 20,
|
||||
verify_tls: bool = False,
|
||||
langx: str = "zh-cn",
|
||||
odds_type: str = "H",
|
||||
) -> None:
|
||||
self.base_url = _normalize_base_url(base_url)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.user_agent = user_agent
|
||||
self.timeout = int(timeout)
|
||||
self.verify_tls = bool(verify_tls)
|
||||
self.langx = langx
|
||||
self.odds_type = odds_type
|
||||
self.session = requests.Session()
|
||||
self.session.verify = self.verify_tls
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Referer": self.base_url,
|
||||
}
|
||||
)
|
||||
if not self.verify_tls:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
self.login_session: Optional[OddsLoginSession] = None
|
||||
|
||||
def login(self) -> OddsLoginSession:
|
||||
bootstrap = self.session.post(
|
||||
self.base_url,
|
||||
data={"detection": "Y", "sub_doubleLogin": "", "isapp": "", "q": "", "appversion": ""},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
bootstrap.raise_for_status()
|
||||
version = _extract_version(bootstrap.text)
|
||||
transform_url = urljoin(self.base_url, "transform_nl.php") + f"?ver={version}"
|
||||
user_agent_b64 = base64.b64encode(self.user_agent.encode("utf-8")).decode("ascii")
|
||||
login_response = self.session.post(
|
||||
transform_url,
|
||||
data={
|
||||
"p": "chk_login",
|
||||
"langx": self.langx,
|
||||
"ver": version,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"app": "N",
|
||||
"auto": "EDACFD",
|
||||
"userAgent": user_agent_b64,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
login_response.raise_for_status()
|
||||
parsed = _parse_login_xml(login_response.text)
|
||||
self.login_session = OddsLoginSession(
|
||||
base_url=self.base_url,
|
||||
transform_url=transform_url,
|
||||
version=version,
|
||||
uid=parsed["uid"],
|
||||
ltype=parsed.get("ltype") or "3",
|
||||
odds_type=parsed.get("odd_f_type") or self.odds_type,
|
||||
)
|
||||
return self.login_session
|
||||
|
||||
def fetch_game_list(self, sport_type: str, show_type: str) -> List[Dict[str, Any]]:
|
||||
login_session = self.login_session or self.login()
|
||||
normalized_show_type = str(show_type).lower()
|
||||
normalized_sport_type = str(sport_type).upper()
|
||||
now_ms = int(time.time() * 1000)
|
||||
response = self.session.post(
|
||||
login_session.transform_url,
|
||||
data={
|
||||
"uid": login_session.uid,
|
||||
"ver": login_session.version,
|
||||
"langx": self.langx,
|
||||
"p": "get_game_list",
|
||||
"p3type": "",
|
||||
"date": "",
|
||||
"gtype": normalized_sport_type.lower(),
|
||||
"showtype": normalized_show_type,
|
||||
"rtype": SHOW_TYPE_RTYPE.get(normalized_show_type, "r"),
|
||||
"ltype": login_session.ltype,
|
||||
"filter": normalized_sport_type,
|
||||
"cupFantasy": "N",
|
||||
"sorttype": "L",
|
||||
"specialClick": "",
|
||||
"isFantasy": "N",
|
||||
"ts": str(now_ms),
|
||||
"chgSortTS": str(now_ms),
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.text or ""
|
||||
skip_reason = _skippable_game_list_reason(body)
|
||||
if skip_reason:
|
||||
raise OddsGameListUnavailable(skip_reason)
|
||||
if not body.lstrip().startswith("<"):
|
||||
prefix = re.sub(r"\s+", " ", body.strip())[:120]
|
||||
raise ValueError(f"unexpected odds response: {prefix}")
|
||||
return parse_game_list_xml(
|
||||
body,
|
||||
source_site="hg",
|
||||
source_url=login_session.base_url,
|
||||
sport_type=normalized_sport_type,
|
||||
show_type=normalized_show_type,
|
||||
odds_type=login_session.odds_type,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchOddsRepository:
|
||||
db_config: Dict[str, Any]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.prefix = str(self.db_config.get("prefix") or "la_")
|
||||
self.conn = pymysql.connect(
|
||||
host=self.db_config["host"],
|
||||
port=int(self.db_config["port"]),
|
||||
user=self.db_config["user"],
|
||||
password=self.db_config["password"],
|
||||
database=self.db_config["database"],
|
||||
charset=self.db_config.get("charset") or "utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
def ensure_table(self) -> None:
|
||||
sql = f"""
|
||||
CREATE TABLE IF NOT EXISTS `{self.prefix}match_odds` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`source_site` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '赔率来源',
|
||||
`source_url` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '来源线路',
|
||||
`source_match_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源比赛ID',
|
||||
`source_parent_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源主比赛ID',
|
||||
`source_league_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源联赛ID',
|
||||
`source_event_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '来源事件ID',
|
||||
`sport_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '运动类型',
|
||||
`show_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '盘口类型 live/today/early',
|
||||
`odds_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '赔率格式',
|
||||
`league_name` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '联赛名称',
|
||||
`match_time_text` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '来源比赛时间文本',
|
||||
`home_team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主队来源ID',
|
||||
`away_team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客队来源ID',
|
||||
`home_team` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '主队名称',
|
||||
`away_team` VARCHAR(150) NOT NULL DEFAULT '' COMMENT '客队名称',
|
||||
`strong_side` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '让球强弱方',
|
||||
`more_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更多玩法数量',
|
||||
`is_running` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否滚球',
|
||||
`handicap_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '让球盘口',
|
||||
`handicap_home_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主队让球赔率',
|
||||
`handicap_away_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客队让球赔率',
|
||||
`total_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '大小球盘口',
|
||||
`total_over_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '大球赔率',
|
||||
`total_under_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '小球赔率',
|
||||
`home_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主胜赔率',
|
||||
`draw_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平局赔率',
|
||||
`away_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客胜赔率',
|
||||
`half_handicap_line` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场让球盘口',
|
||||
`half_home_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场主胜赔率',
|
||||
`half_draw_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场平局赔率',
|
||||
`half_away_win_odds` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上半场客胜赔率',
|
||||
`create_time` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`raw_data` JSON NULL COMMENT '来源原始game节点',
|
||||
`markets_json` JSON NULL COMMENT '规范化盘口JSON',
|
||||
`update_time` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_source_match` (`source_site`, `source_match_id`),
|
||||
KEY `idx_sport_show` (`sport_type`, `show_type`),
|
||||
KEY `idx_league` (`source_league_id`),
|
||||
KEY `idx_update_time` (`update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方赛事赔率表'
|
||||
"""
|
||||
with self.conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
self.conn.commit()
|
||||
|
||||
def upsert_latest(self, items: List[Dict[str, Any]], now_ts: Optional[int] = None) -> int:
|
||||
if not items:
|
||||
return 0
|
||||
now_ts = int(now_ts or time.time())
|
||||
sql = (
|
||||
f"INSERT INTO `{self.prefix}match_odds` ("
|
||||
"`source_site`, `source_url`, `source_match_id`, `source_parent_id`, `source_league_id`, `source_event_id`, "
|
||||
"`sport_type`, `show_type`, `odds_type`, `league_name`, `match_time_text`, "
|
||||
"`home_team_id`, `away_team_id`, `home_team`, `away_team`, `strong_side`, `more_count`, `is_running`, "
|
||||
"`handicap_line`, `handicap_home_odds`, `handicap_away_odds`, "
|
||||
"`total_line`, `total_over_odds`, `total_under_odds`, "
|
||||
"`home_win_odds`, `draw_odds`, `away_win_odds`, "
|
||||
"`half_handicap_line`, `half_home_win_odds`, `half_draw_odds`, `half_away_win_odds`, "
|
||||
"`create_time`, `raw_data`, `markets_json`, `update_time`"
|
||||
") VALUES ("
|
||||
+ ", ".join(["%s"] * 35)
|
||||
+ ") ON DUPLICATE KEY UPDATE "
|
||||
"`source_url`=VALUES(`source_url`), "
|
||||
"`source_parent_id`=VALUES(`source_parent_id`), "
|
||||
"`source_league_id`=VALUES(`source_league_id`), "
|
||||
"`source_event_id`=VALUES(`source_event_id`), "
|
||||
"`sport_type`=VALUES(`sport_type`), "
|
||||
"`show_type`=VALUES(`show_type`), "
|
||||
"`odds_type`=VALUES(`odds_type`), "
|
||||
"`league_name`=VALUES(`league_name`), "
|
||||
"`match_time_text`=VALUES(`match_time_text`), "
|
||||
"`home_team_id`=VALUES(`home_team_id`), "
|
||||
"`away_team_id`=VALUES(`away_team_id`), "
|
||||
"`home_team`=VALUES(`home_team`), "
|
||||
"`away_team`=VALUES(`away_team`), "
|
||||
"`strong_side`=VALUES(`strong_side`), "
|
||||
"`more_count`=VALUES(`more_count`), "
|
||||
"`is_running`=VALUES(`is_running`), "
|
||||
"`handicap_line`=VALUES(`handicap_line`), "
|
||||
"`handicap_home_odds`=VALUES(`handicap_home_odds`), "
|
||||
"`handicap_away_odds`=VALUES(`handicap_away_odds`), "
|
||||
"`total_line`=VALUES(`total_line`), "
|
||||
"`total_over_odds`=VALUES(`total_over_odds`), "
|
||||
"`total_under_odds`=VALUES(`total_under_odds`), "
|
||||
"`home_win_odds`=VALUES(`home_win_odds`), "
|
||||
"`draw_odds`=VALUES(`draw_odds`), "
|
||||
"`away_win_odds`=VALUES(`away_win_odds`), "
|
||||
"`half_handicap_line`=VALUES(`half_handicap_line`), "
|
||||
"`half_home_win_odds`=VALUES(`half_home_win_odds`), "
|
||||
"`half_draw_odds`=VALUES(`half_draw_odds`), "
|
||||
"`half_away_win_odds`=VALUES(`half_away_win_odds`), "
|
||||
"`raw_data`=VALUES(`raw_data`), "
|
||||
"`markets_json`=VALUES(`markets_json`), "
|
||||
"`update_time`=VALUES(`update_time`)"
|
||||
)
|
||||
saved_count = 0
|
||||
with self.conn.cursor() as cur:
|
||||
for item in items:
|
||||
params = (
|
||||
item["source_site"],
|
||||
item["source_url"],
|
||||
item["source_match_id"],
|
||||
item["source_parent_id"],
|
||||
item["source_league_id"],
|
||||
item["source_event_id"],
|
||||
item["sport_type"],
|
||||
item["show_type"],
|
||||
item["odds_type"],
|
||||
item["league_name"],
|
||||
item["match_time_text"],
|
||||
item["home_team_id"],
|
||||
item["away_team_id"],
|
||||
item["home_team"],
|
||||
item["away_team"],
|
||||
item["strong_side"],
|
||||
int(item["more_count"]),
|
||||
int(item["is_running"]),
|
||||
item["handicap_line"],
|
||||
item["handicap_home_odds"],
|
||||
item["handicap_away_odds"],
|
||||
item["total_line"],
|
||||
item["total_over_odds"],
|
||||
item["total_under_odds"],
|
||||
item["home_win_odds"],
|
||||
item["draw_odds"],
|
||||
item["away_win_odds"],
|
||||
item["half_handicap_line"],
|
||||
item["half_home_win_odds"],
|
||||
item["half_draw_odds"],
|
||||
item["half_away_win_odds"],
|
||||
now_ts,
|
||||
_dump_json(item["raw_data"]),
|
||||
_dump_json(item["markets"]),
|
||||
now_ts,
|
||||
)
|
||||
cur.execute(sql, params)
|
||||
saved_count += 1
|
||||
self.conn.commit()
|
||||
return saved_count
|
||||
|
||||
|
||||
def _build_result(
|
||||
*,
|
||||
success: bool,
|
||||
candidate_count: int,
|
||||
saved_count: int,
|
||||
source_url: str = "",
|
||||
skipped_count: int = 0,
|
||||
errors: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
errors = errors or []
|
||||
summary_text = (
|
||||
f"第三方赛事赔率抓取完成: 来源 {source_url or '-'}, 待写入 {candidate_count} 场, "
|
||||
f"入库 {saved_count} 场, 跳过 {skipped_count} 项, 失败 {len(errors)} 项"
|
||||
)
|
||||
result = {
|
||||
"success": bool(success),
|
||||
"candidate_count": int(candidate_count),
|
||||
"saved_count": int(saved_count),
|
||||
"count": int(saved_count),
|
||||
"source_url": source_url,
|
||||
"skipped_count": int(skipped_count),
|
||||
"failed_count": len(errors),
|
||||
"summary_text": summary_text,
|
||||
}
|
||||
if errors:
|
||||
result["error"] = "; ".join(errors[:5])
|
||||
return result
|
||||
|
||||
|
||||
def run(db_config: Dict[str, Any], env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
env = env if env is not None else os.environ
|
||||
username = (env.get("ODDS_USERNAME") or env.get("HG_ODDS_USERNAME") or "").strip()
|
||||
password = (env.get("ODDS_PASSWORD") or env.get("HG_ODDS_PASSWORD") or "").strip()
|
||||
if not username or not password:
|
||||
return _build_result(
|
||||
success=False,
|
||||
candidate_count=0,
|
||||
saved_count=0,
|
||||
errors=["ODDS_USERNAME/ODDS_PASSWORD 未配置"],
|
||||
)
|
||||
|
||||
base_urls = [_normalize_base_url(url) for url in _split_env_list(env.get("ODDS_BASE_URLS", ""), DEFAULT_BASE_URLS)]
|
||||
sport_types = [item.upper() for item in _split_env_list(env.get("ODDS_SPORT_TYPES", ""), DEFAULT_SPORT_TYPES)]
|
||||
show_types = [item.lower() for item in _split_env_list(env.get("ODDS_SHOW_TYPES", ""), DEFAULT_SHOW_TYPES)]
|
||||
timeout = _safe_int(env.get("ODDS_TIMEOUT"), 20)
|
||||
verify_tls = _bool_env(env.get("ODDS_VERIFY_TLS"), default=False)
|
||||
langx = (env.get("ODDS_LANGX") or "zh-cn").strip()
|
||||
odds_type = (env.get("ODDS_TYPE") or "H").strip().upper()
|
||||
request_delay = float(env.get("ODDS_REQUEST_DELAY_SECONDS") or REQUEST_DELAY_SECONDS)
|
||||
|
||||
client: Optional[HgOddsClient] = None
|
||||
login_errors: List[str] = []
|
||||
for base_url in base_urls:
|
||||
try:
|
||||
candidate = HgOddsClient(
|
||||
base_url=base_url,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=timeout,
|
||||
verify_tls=verify_tls,
|
||||
langx=langx,
|
||||
odds_type=odds_type,
|
||||
)
|
||||
candidate.login()
|
||||
client = candidate
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
login_errors.append(f"{base_url}: {exc}")
|
||||
|
||||
if client is None:
|
||||
return _build_result(success=False, candidate_count=0, saved_count=0, errors=login_errors)
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
fetch_errors: List[str] = []
|
||||
skipped_count = 0
|
||||
for sport_type in sport_types:
|
||||
for show_type in show_types:
|
||||
try:
|
||||
items.extend(client.fetch_game_list(sport_type, show_type))
|
||||
except OddsGameListUnavailable:
|
||||
skipped_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
fetch_errors.append(f"{sport_type}/{show_type}: {exc}")
|
||||
if request_delay > 0:
|
||||
time.sleep(request_delay)
|
||||
|
||||
repo = None
|
||||
try:
|
||||
repo = MatchOddsRepository(db_config)
|
||||
repo.ensure_table()
|
||||
saved_count = repo.upsert_latest(items, now_ts=int(time.time()))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _build_result(
|
||||
success=False,
|
||||
candidate_count=len(items),
|
||||
saved_count=0,
|
||||
source_url=client.base_url,
|
||||
skipped_count=skipped_count,
|
||||
errors=fetch_errors + [f"database: {exc}"],
|
||||
)
|
||||
finally:
|
||||
if repo is not None:
|
||||
repo.close()
|
||||
|
||||
return _build_result(
|
||||
success=len(fetch_errors) == 0,
|
||||
candidate_count=len(items),
|
||||
saved_count=saved_count,
|
||||
source_url=client.base_url,
|
||||
skipped_count=skipped_count,
|
||||
errors=fetch_errors,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.core.config import load_config
|
||||
|
||||
cfg = load_config().database
|
||||
print(
|
||||
json.dumps(
|
||||
run(
|
||||
{
|
||||
"host": cfg.host,
|
||||
"port": cfg.port,
|
||||
"user": cfg.username,
|
||||
"password": cfg.password,
|
||||
"database": cfg.database,
|
||||
"charset": cfg.charset,
|
||||
"prefix": cfg.prefix,
|
||||
}
|
||||
),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
@@ -259,6 +259,7 @@ def run_main_action(action: str, timeout: int, output_callback: Callable[[str],
|
||||
start = time.monotonic()
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["DQD_RUNNING_UNDER_TASK_RUNNER"] = "1"
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "main.py", action],
|
||||
cwd=str(APP_DIR),
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 爬虫错误日志表
|
||||
CREATE TABLE IF NOT EXISTS `la_crawl_error_log` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '任务名称(schedule/standings/match_finish等)',
|
||||
`request_url` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '请求URL',
|
||||
`request_method` VARCHAR(10) NOT NULL DEFAULT 'GET' COMMENT '请求方法',
|
||||
`request_params` TEXT COMMENT '请求参数(JSON)',
|
||||
`http_status` INT NOT NULL DEFAULT 0 COMMENT 'HTTP状态码(0=网络异常)',
|
||||
`error_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '错误类型(NetworkError/404/timeout等)',
|
||||
`error_message` TEXT COMMENT '错误信息',
|
||||
`response_body` TEXT COMMENT '响应内容(截断)',
|
||||
`channel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '请求通道(api/tls/playwright/page)',
|
||||
`notified` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已发送邮件通知(0=未通知,1=已通知)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY `idx_notified` (`notified`),
|
||||
KEY `idx_task_created` (`task_name`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='爬虫请求错误日志';
|
||||
|
||||
-- 定时任务:错误报告邮件(每5分钟)
|
||||
INSERT INTO `la_dev_crontab` (`name`, `type`, `command`, `params`, `status`, `expression`, `create_time`, `update_time`)
|
||||
VALUES ('爬虫错误报告邮件', 1, 'crawler', 'error_report', 1, '*/5 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS `la_lottery_draw_result` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`code` varchar(30) NOT NULL DEFAULT '' COMMENT '彩种编码(关联la_lottery_game.code)',
|
||||
`issue` varchar(30) NOT NULL DEFAULT '' COMMENT '期号',
|
||||
`draw_time` datetime DEFAULT NULL COMMENT '开奖时间',
|
||||
`draw_code` varchar(255) NOT NULL DEFAULT '' COMMENT '开奖号码(逗号分隔)',
|
||||
`draw_ext` varchar(500) NOT NULL DEFAULT '' COMMENT '扩展号码(台湾宾果等)',
|
||||
`status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '开奖状态 1已开奖 4停售等',
|
||||
`idx` int unsigned NOT NULL DEFAULT 0 COMMENT '当日期数索引(index)',
|
||||
`next_issue` varchar(30) NOT NULL DEFAULT '' COMMENT '下期期号',
|
||||
`next_time` datetime DEFAULT NULL COMMENT '下期开奖时间',
|
||||
`trend` json DEFAULT NULL COMMENT '走势数据(JSON)',
|
||||
`s_time` datetime DEFAULT NULL COMMENT '数据同步时间(API sTime)',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_code_issue` (`code`, `issue`),
|
||||
KEY `idx_code_draw_time` (`code`, `draw_time`),
|
||||
KEY `idx_draw_time` (`draw_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='彩种开奖结果表';
|
||||
@@ -0,0 +1,2 @@
|
||||
INSERT INTO `la_dev_crontab` (`name`, `type`, `command`, `params`, `status`, `expression`, `create_time`, `update_time`)
|
||||
VALUES ('超时比赛收尾', 1, 'crawler', 'match_finish', 1, '*/1 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||
@@ -10,6 +10,7 @@ if str(ROOT) not in sys.path:
|
||||
from ai_comment_dispatch import (
|
||||
AiCommentDispatcher,
|
||||
AiCommentSettings,
|
||||
AiCommentRepository,
|
||||
CommentCandidate,
|
||||
DispatchTask,
|
||||
VirtualUser,
|
||||
@@ -79,6 +80,32 @@ class FakeClient:
|
||||
)
|
||||
|
||||
|
||||
class RecordingCursor:
|
||||
def __init__(self):
|
||||
self.executed = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.executed.append((sql, params))
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
def __init__(self):
|
||||
self.cursor_obj = RecordingCursor()
|
||||
self.commits = 0
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_obj
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class AiCommentDispatchTest(unittest.TestCase):
|
||||
def test_merge_candidates_merges_selection_sources_by_target(self):
|
||||
merged = merge_candidates(
|
||||
@@ -209,6 +236,36 @@ class AiCommentDispatchTest(unittest.TestCase):
|
||||
self.assertEqual(settings.max_tokens, 300)
|
||||
self.assertAlmostEqual(settings.temperature, 0.65)
|
||||
|
||||
def test_generated_bot_nickname_uses_normal_handle(self):
|
||||
nickname = AiCommentRepository._build_bot_nickname(17)
|
||||
|
||||
self.assertNotIn("AI评论员", nickname)
|
||||
self.assertNotIn("·", nickname)
|
||||
self.assertGreaterEqual(len(nickname), 3)
|
||||
|
||||
def test_existing_virtual_user_nickname_comes_from_user_table(self):
|
||||
repo = AiCommentRepository.__new__(AiCommentRepository)
|
||||
repo.prefix = "la_"
|
||||
repo.conn = RecordingConnection()
|
||||
repo._virtual_users_cache = None
|
||||
repo._settings_cache = None
|
||||
existing_rows = [
|
||||
{
|
||||
"id": 83,
|
||||
"account": "ai_comment_0001",
|
||||
"nickname": "赛点阿泽已手动改名",
|
||||
"avatar": "/avatar.png",
|
||||
}
|
||||
]
|
||||
repo._load_virtual_users = lambda limit=None: list(existing_rows)
|
||||
repo._load_site_config = lambda group, name: ""
|
||||
|
||||
users = repo.ensure_virtual_users(1)
|
||||
|
||||
self.assertEqual(users[0].nickname, "赛点阿泽已手动改名")
|
||||
executed_sql = "\n".join(sql for sql, _ in repo.conn.cursor_obj.executed)
|
||||
self.assertNotIn("UPDATE `la_user` SET nickname", executed_sql)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from match_live_stream import (
|
||||
_build_detail_url,
|
||||
_build_run_result,
|
||||
choose_next_fetch_at,
|
||||
parse_match_live_detail_html,
|
||||
MatchLiveStreamRepository,
|
||||
run,
|
||||
)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, fetchall_result=None):
|
||||
self.fetchall_result = list(fetchall_result or [])
|
||||
self.executed = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params):
|
||||
self.executed.append((sql, params))
|
||||
|
||||
def fetchall(self):
|
||||
return list(self.fetchall_result)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, fetchall_result=None):
|
||||
self.cursor_instance = FakeCursor(fetchall_result=fetchall_result)
|
||||
self.commit_count = 0
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_instance
|
||||
|
||||
def commit(self):
|
||||
self.commit_count += 1
|
||||
|
||||
|
||||
class MatchLiveStreamTest(unittest.TestCase):
|
||||
def _build_repo(self, *, prefix="la_", fetchall_result=None):
|
||||
repo = object.__new__(MatchLiveStreamRepository)
|
||||
repo.prefix = prefix
|
||||
repo.conn = FakeConnection(fetchall_result=fetchall_result)
|
||||
return repo
|
||||
|
||||
def test_parse_json_ncctrials_detail_extracts_four_urls(self):
|
||||
payload = {
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"stream": {
|
||||
"flv": "http://a/flv",
|
||||
"hdFlv": "http://a/hdflv",
|
||||
"m3u8": "http://a/m3u8",
|
||||
"hdM3u8": "http://a/hdm3u8",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed = parse_match_live_detail_html(f"detail({json.dumps(payload, ensure_ascii=False)})")
|
||||
|
||||
self.assertEqual(parsed["play_url_m3u8"], "http://a/m3u8")
|
||||
self.assertEqual(parsed["play_url_hd_m3u8"], "http://a/hdm3u8")
|
||||
self.assertEqual(parsed["play_url_flv"], "http://a/flv")
|
||||
self.assertEqual(parsed["play_url_hd_flv"], "http://a/hdflv")
|
||||
|
||||
def test_parse_json_ncctrials_detail_accepts_trailing_semicolon(self):
|
||||
payload = {
|
||||
"code": 200,
|
||||
"msg": "ok",
|
||||
"data": {
|
||||
"stream": {
|
||||
"flv": "http://b/flv",
|
||||
"hdFlv": "http://b/hdflv",
|
||||
"m3u8": "http://b/m3u8",
|
||||
"hdM3u8": "http://b/hdm3u8",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed = parse_match_live_detail_html(f"detail({json.dumps(payload, ensure_ascii=False)});")
|
||||
|
||||
self.assertEqual(parsed["play_url_m3u8"], "http://b/m3u8")
|
||||
self.assertEqual(parsed["play_url_hd_m3u8"], "http://b/hdm3u8")
|
||||
self.assertEqual(parsed["play_url_flv"], "http://b/flv")
|
||||
self.assertEqual(parsed["play_url_hd_flv"], "http://b/hdflv")
|
||||
|
||||
def test_build_detail_url_derives_room_id_from_source_url(self):
|
||||
detail_url = _build_detail_url(
|
||||
"https://yyzb1.tv/room/7988511?scheduleId=undefined",
|
||||
now_ts=123456,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
detail_url,
|
||||
"https://json.ncctrials.com/room/7988511/detail.json?v=123456",
|
||||
)
|
||||
|
||||
def test_build_run_result_exposes_top_level_error_for_partial_failure(self):
|
||||
result = _build_run_result(candidate_count=3, success_count=2, failed_count=1)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["candidate_count"], 3)
|
||||
self.assertEqual(result["saved_count"], 2)
|
||||
self.assertEqual(result["count"], 2)
|
||||
self.assertEqual(result["failed_count"], 1)
|
||||
self.assertIn("失败 1 条", result["summary_text"])
|
||||
self.assertEqual(result["error"], "赛事直播流抓取存在失败线路: 失败 1 条")
|
||||
|
||||
def test_choose_next_fetch_at_uses_60s_for_empty_streams(self):
|
||||
self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=False), 1060)
|
||||
|
||||
def test_choose_next_fetch_at_uses_600s_for_existing_streams(self):
|
||||
self.assertEqual(choose_next_fetch_at(now_ts=1000, has_existing_stream=True), 1600)
|
||||
|
||||
def test_load_due_rows_orders_by_due_time_and_honors_limit(self):
|
||||
expected_rows = [{"id": 12, "source_url": "https://yyzb1.tv/room/7988511"}]
|
||||
repo = self._build_repo(fetchall_result=expected_rows)
|
||||
|
||||
rows = repo.load_due_rows(now_ts=1711111111, limit=7)
|
||||
|
||||
self.assertEqual(rows, expected_rows)
|
||||
self.assertEqual(repo.conn.commit_count, 0)
|
||||
self.assertEqual(len(repo.conn.cursor_instance.executed), 1)
|
||||
sql, params = repo.conn.cursor_instance.executed[0]
|
||||
self.assertIn("FROM `la_match_live`", sql)
|
||||
self.assertIn("`next_fetch_at` <= %s", sql)
|
||||
self.assertIn("ORDER BY `next_fetch_at` ASC, `id` ASC LIMIT %s", sql)
|
||||
self.assertEqual(params, (1711111111, 7))
|
||||
|
||||
def test_mark_failed_only_updates_fetch_fields_and_preserves_stream_urls(self):
|
||||
repo = self._build_repo()
|
||||
error_message = "x" * 600
|
||||
|
||||
repo.mark_failed(row_id=88, error_message=error_message, now_ts=2000, has_existing_stream=True)
|
||||
|
||||
self.assertEqual(repo.conn.commit_count, 1)
|
||||
self.assertEqual(len(repo.conn.cursor_instance.executed), 1)
|
||||
sql, params = repo.conn.cursor_instance.executed[0]
|
||||
self.assertIn("UPDATE `la_match_live` SET", sql)
|
||||
self.assertIn("`fetch_status`=2", sql)
|
||||
self.assertIn("`fetch_error`=%s, `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s", sql)
|
||||
self.assertNotIn("`play_url_flv`=%s", sql)
|
||||
self.assertNotIn("`play_url_hd_flv`=%s", sql)
|
||||
self.assertNotIn("`play_url_m3u8`=%s", sql)
|
||||
self.assertNotIn("`play_url_hd_m3u8`=%s", sql)
|
||||
self.assertEqual(params, ("x" * 500, 2000, 2600, 2000, 88))
|
||||
|
||||
def test_mark_success_updates_all_stream_urls_and_resets_fetch_fields(self):
|
||||
repo = self._build_repo()
|
||||
stream_urls = {
|
||||
"play_url_flv": "http://stream/flv",
|
||||
"play_url_hd_flv": "http://stream/hdflv",
|
||||
"play_url_m3u8": "http://stream/m3u8",
|
||||
"play_url_hd_m3u8": "http://stream/hdm3u8",
|
||||
}
|
||||
|
||||
repo.mark_success(row_id=66, stream_urls=stream_urls, now_ts=3000)
|
||||
|
||||
self.assertEqual(repo.conn.commit_count, 1)
|
||||
self.assertEqual(len(repo.conn.cursor_instance.executed), 1)
|
||||
sql, params = repo.conn.cursor_instance.executed[0]
|
||||
self.assertIn("UPDATE `la_match_live` SET", sql)
|
||||
self.assertIn("`play_url_flv`=%s, `play_url_hd_flv`=%s, `play_url_m3u8`=%s, `play_url_hd_m3u8`=%s", sql)
|
||||
self.assertIn("`fetch_status`=1, `fetch_error`='', `last_fetch_at`=%s, `next_fetch_at`=%s, `update_time`=%s", sql)
|
||||
self.assertEqual(
|
||||
params,
|
||||
(
|
||||
"http://stream/flv",
|
||||
"http://stream/hdflv",
|
||||
"http://stream/m3u8",
|
||||
"http://stream/hdm3u8",
|
||||
3000,
|
||||
3600,
|
||||
3000,
|
||||
66,
|
||||
),
|
||||
)
|
||||
|
||||
def test_run_does_not_skip_due_rows_when_invoked_by_docker_task_runner(self):
|
||||
class FakeRepo:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.mark_success_calls = []
|
||||
|
||||
def load_due_rows(self, now_ts, limit=30):
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"source_url": "https://yyzb1.tv/room/190118?scheduleId=undefined",
|
||||
"play_url_flv": "",
|
||||
"play_url_hd_flv": "",
|
||||
"play_url_m3u8": "",
|
||||
"play_url_hd_m3u8": "",
|
||||
}
|
||||
]
|
||||
|
||||
def mark_success(self, row_id, stream_urls, now_ts):
|
||||
self.mark_success_calls.append((row_id, stream_urls, now_ts))
|
||||
|
||||
def mark_failed(self, row_id, error_message, now_ts, has_existing_stream):
|
||||
raise AssertionError("docker task runner path should not fall into mark_failed for this test")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class FakeLock:
|
||||
def acquire(self):
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
return None
|
||||
|
||||
repo = FakeRepo()
|
||||
stream_urls = {
|
||||
"play_url_flv": "http://stream/flv",
|
||||
"play_url_hd_flv": "http://stream/hdflv",
|
||||
"play_url_m3u8": "http://stream/m3u8",
|
||||
"play_url_hd_m3u8": "http://stream/hdm3u8",
|
||||
}
|
||||
|
||||
with mock.patch.dict("os.environ", {"DQD_RUNNING_UNDER_TASK_RUNNER": "1"}, clear=False):
|
||||
with mock.patch("match_live_stream.MatchLiveStreamRepository", return_value=repo):
|
||||
with mock.patch("match_live_stream.TaskFileLock", return_value=FakeLock()):
|
||||
with mock.patch("match_live_stream._fetch_stream_payload", return_value=stream_urls):
|
||||
result = run({"prefix": "la_"})
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["candidate_count"], 1)
|
||||
self.assertEqual(result["saved_count"], 1)
|
||||
self.assertEqual(len(repo.mark_success_calls), 1)
|
||||
self.assertTrue(repo.closed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import match_odds
|
||||
from match_odds import MatchOddsRepository, OddsGameListUnavailable, parse_game_list_xml, run
|
||||
|
||||
|
||||
SAMPLE_GAME_LIST_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<serverresponse sip="14.39">
|
||||
<dataCount>1</dataCount>
|
||||
<totalDataCount>1</totalDataCount>
|
||||
<pageCount>1</pageCount>
|
||||
<ec id="ec11290788" hasEC="Y" myGame="ft">
|
||||
<game id="8892295">
|
||||
<GID>8892295</GID>
|
||||
<DATETIME>07-09 04:00p</DATETIME>
|
||||
<LID>104036</LID>
|
||||
<LEAGUE>世界杯2026(美加墨)</LEAGUE>
|
||||
<GIDM>7972917</GIDM>
|
||||
<GNUM_H>402008</GNUM_H>
|
||||
<GNUM_C>402007</GNUM_C>
|
||||
<TEAM_H_ID>122821</TEAM_H_ID>
|
||||
<TEAM_C_ID>122841</TEAM_C_ID>
|
||||
<TEAM_H>法国</TEAM_H>
|
||||
<TEAM_C>摩洛哥</TEAM_C>
|
||||
<STRONG>H</STRONG>
|
||||
<MORE>162</MORE>
|
||||
<ECID>11290788</ECID>
|
||||
<RATIO_R>1</RATIO_R>
|
||||
<IOR_RH>1.030</IOR_RH>
|
||||
<IOR_RC>0.860</IOR_RC>
|
||||
<RATIO_OUO>2.5</RATIO_OUO>
|
||||
<RATIO_OUU>2.5</RATIO_OUU>
|
||||
<IOR_OUH>0.850</IOR_OUH>
|
||||
<IOR_OUC>1.030</IOR_OUC>
|
||||
<IOR_MH>1.57</IOR_MH>
|
||||
<IOR_MC>6.60</IOR_MC>
|
||||
<IOR_MN>3.90</IOR_MN>
|
||||
<RATIO_HR>0 / 0.5</RATIO_HR>
|
||||
<IOR_HRH>0.810</IOR_HRH>
|
||||
<IOR_HRC>1.070</IOR_HRC>
|
||||
<IOR_HMH>2.28</IOR_HMH>
|
||||
<IOR_HMC>5.90</IOR_HMC>
|
||||
<IOR_HMN>2.11</IOR_HMN>
|
||||
<SYSTIME>2026-07-09 10:29:39</SYSTIME>
|
||||
<IS_RB>N</IS_RB>
|
||||
</game>
|
||||
</ec>
|
||||
</serverresponse>
|
||||
"""
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self):
|
||||
self.executed = []
|
||||
self.rowcount = 1
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.executed.append((sql, params))
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.cursor_instance = FakeCursor()
|
||||
self.commit_count = 0
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_instance
|
||||
|
||||
def commit(self):
|
||||
self.commit_count += 1
|
||||
|
||||
|
||||
class MatchOddsTest(unittest.TestCase):
|
||||
def _build_repo(self, prefix="la_"):
|
||||
repo = object.__new__(MatchOddsRepository)
|
||||
repo.prefix = prefix
|
||||
repo.conn = FakeConnection()
|
||||
return repo
|
||||
|
||||
def test_parse_game_list_xml_extracts_main_markets_and_raw_data(self):
|
||||
items = parse_game_list_xml(
|
||||
SAMPLE_GAME_LIST_XML,
|
||||
source_site="hg",
|
||||
source_url="https://mos022.com/",
|
||||
sport_type="FT",
|
||||
show_type="today",
|
||||
odds_type="H",
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
item = items[0]
|
||||
self.assertEqual(item["source_match_id"], "8892295")
|
||||
self.assertEqual(item["league_name"], "世界杯2026(美加墨)")
|
||||
self.assertEqual(item["home_team"], "法国")
|
||||
self.assertEqual(item["away_team"], "摩洛哥")
|
||||
self.assertEqual(item["match_time_text"], "07-09 04:00p")
|
||||
self.assertEqual(item["handicap_line"], "1")
|
||||
self.assertEqual(item["handicap_home_odds"], "1.030")
|
||||
self.assertEqual(item["handicap_away_odds"], "0.860")
|
||||
self.assertEqual(item["total_line"], "2.5")
|
||||
self.assertEqual(item["total_over_odds"], "1.030")
|
||||
self.assertEqual(item["total_under_odds"], "0.850")
|
||||
self.assertEqual(item["home_win_odds"], "1.57")
|
||||
self.assertEqual(item["away_win_odds"], "6.60")
|
||||
self.assertEqual(item["draw_odds"], "3.90")
|
||||
self.assertEqual(item["raw_data"]["GID"], "8892295")
|
||||
self.assertEqual(item["markets"]["first_half"]["home_win"], "2.28")
|
||||
|
||||
def test_repository_upserts_latest_odds_by_source_match_id(self):
|
||||
items = parse_game_list_xml(
|
||||
SAMPLE_GAME_LIST_XML,
|
||||
source_site="hg",
|
||||
source_url="https://mos022.com/",
|
||||
sport_type="FT",
|
||||
show_type="today",
|
||||
odds_type="H",
|
||||
)
|
||||
repo = self._build_repo()
|
||||
|
||||
saved = repo.upsert_latest(items, now_ts=1783607379)
|
||||
|
||||
self.assertEqual(saved, 1)
|
||||
self.assertEqual(repo.conn.commit_count, 1)
|
||||
sql, params = repo.conn.cursor_instance.executed[0]
|
||||
self.assertIn("INSERT INTO `la_match_odds`", sql)
|
||||
self.assertIn("ON DUPLICATE KEY UPDATE", sql)
|
||||
self.assertIn("`source_match_id`", sql)
|
||||
self.assertEqual(params[0], "hg")
|
||||
self.assertEqual(params[2], "8892295")
|
||||
self.assertEqual(json.loads(params[-3])["GID"], "8892295")
|
||||
self.assertEqual(json.loads(params[-2])["moneyline"]["draw"], "3.90")
|
||||
|
||||
def test_run_skips_without_credentials(self):
|
||||
result = run({"prefix": "la_"}, env={})
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["candidate_count"], 0)
|
||||
self.assertIn("ODDS_USERNAME", result["error"])
|
||||
|
||||
def test_run_returns_structured_failure_when_database_is_unavailable(self):
|
||||
class FakeClient:
|
||||
base_url = "https://mos022.com/"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
def login(self):
|
||||
return None
|
||||
|
||||
def fetch_game_list(self, sport_type, show_type):
|
||||
return parse_game_list_xml(
|
||||
SAMPLE_GAME_LIST_XML,
|
||||
source_site="hg",
|
||||
source_url="https://mos022.com/",
|
||||
sport_type=sport_type,
|
||||
show_type=show_type,
|
||||
odds_type="H",
|
||||
)
|
||||
|
||||
with mock.patch.object(match_odds, "HgOddsClient", FakeClient):
|
||||
with mock.patch.object(match_odds, "MatchOddsRepository", side_effect=RuntimeError("db down")):
|
||||
result = run(
|
||||
{"prefix": "la_"},
|
||||
env={
|
||||
"ODDS_USERNAME": "u",
|
||||
"ODDS_PASSWORD": "p",
|
||||
"ODDS_SPORT_TYPES": "FT",
|
||||
"ODDS_SHOW_TYPES": "today",
|
||||
"ODDS_REQUEST_DELAY_SECONDS": "0",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertEqual(result["candidate_count"], 1)
|
||||
self.assertIn("db down", result["error"])
|
||||
|
||||
def test_run_skips_unavailable_game_list_combinations_without_failing(self):
|
||||
class FakeClient:
|
||||
base_url = "https://mos022.com/"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
def login(self):
|
||||
return None
|
||||
|
||||
def fetch_game_list(self, sport_type, show_type):
|
||||
if show_type == "early":
|
||||
raise OddsGameListUnavailable("早盘日期不可用")
|
||||
return parse_game_list_xml(
|
||||
SAMPLE_GAME_LIST_XML,
|
||||
source_site="hg",
|
||||
source_url="https://mos022.com/",
|
||||
sport_type=sport_type,
|
||||
show_type=show_type,
|
||||
odds_type="H",
|
||||
)
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, db_config):
|
||||
pass
|
||||
|
||||
def ensure_table(self):
|
||||
pass
|
||||
|
||||
def upsert_latest(self, items, now_ts=None):
|
||||
return len(items)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with mock.patch.object(match_odds, "HgOddsClient", FakeClient):
|
||||
with mock.patch.object(match_odds, "MatchOddsRepository", FakeRepo):
|
||||
result = run(
|
||||
{"prefix": "la_"},
|
||||
env={
|
||||
"ODDS_USERNAME": "u",
|
||||
"ODDS_PASSWORD": "p",
|
||||
"ODDS_SPORT_TYPES": "FT",
|
||||
"ODDS_SHOW_TYPES": "today,early",
|
||||
"ODDS_REQUEST_DELAY_SECONDS": "0",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["candidate_count"], 1)
|
||||
self.assertEqual(result["saved_count"], 1)
|
||||
self.assertEqual(result["skipped_count"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||