Compare commits

...
97 Commits
Author SHA1 Message Date
hajimi 2b2e3197be no message 2026-07-31 16:13:31 +08:00
hajimi c01bd53ea1 deploy: auto commit repo-root changes 2026-07-14 11:24:59 2026-07-14 11:24:59 +08:00
hajimi 98d2a23589 deploy: auto commit repo-root changes 2026-07-14 09:42:08 2026-07-14 09:42:08 +08:00
hajimi 33687e31b7 deploy: auto commit repo-root changes 2026-07-13 22:38:58 2026-07-13 22:38:58 +08:00
hajimi af8941e880 deploy: auto commit repo-root changes 2026-07-13 13:26:55 2026-07-13 13:26:56 +08:00
hajimi 8ebe30a077 deploy: auto commit repo-root changes 2026-07-12 23:46:58 2026-07-12 23:46:58 +08:00
hajimi 89d41de3dd deploy: auto commit repo-root changes 2026-07-11 13:14:53 2026-07-11 13:14:54 +08:00
hajimi 42d243971e deploy: auto commit repo-root changes 2026-07-11 12:03:08 2026-07-11 12:03:08 +08:00
hajimi 3a5d571e53 添加赔率 2026-07-11 11:19:43 +08:00
hajimi c9ac46faa7 deploy: auto commit repo-root changes 2026-07-10 10:47:39 2026-07-10 10:47:39 +08:00
hajimi 3b61f0ba38 deploy: auto commit repo-root changes 2026-07-10 01:01:12 2026-07-10 01:01:12 +08:00
hajimi 84a5fb43f5 deploy: auto commit server changes 2026-07-10 00:46:43 2026-07-10 00:46:43 +08:00
hajimi ad597d0a79 deploy: auto commit server changes 2026-07-10 00:14:05 2026-07-10 00:14:05 +08:00
hajimi 76ef84aa8f deploy: auto commit server changes 2026-07-10 00:08:55 2026-07-10 00:08:55 +08:00
hajimi b584a8f98e deploy: auto commit server changes 2026-07-09 23:59:25 2026-07-09 23:59:25 +08:00
hajimi 34ff61d1ee deploy: auto commit server changes 2026-07-09 23:26:59 2026-07-09 23:26:59 +08:00
hajimi 3362e1e072 deploy: auto commit server changes 2026-07-09 23:26:00 2026-07-09 23:26:00 +08:00
hajimi 66fd64ef0f deploy: auto commit docker changes 2026-07-09 22:54:47 2026-07-09 22:54:47 +08:00
hajimi 6d2302875b deploy: auto commit repo-root changes 2026-07-05 15:53:58 2026-07-05 15:53:58 +08:00
hajimi 299d508d7c deploy: auto commit repo-root changes 2026-07-05 13:47:57 2026-07-05 13:47:57 +08:00
hajimi f641276b9d deploy: auto commit repo-root changes 2026-07-05 12:33:57 2026-07-05 12:33:57 +08:00
hajimi 1954948445 deploy: auto commit repo-root changes 2026-07-05 12:03:49 2026-07-05 12:03:49 +08:00
hajimi 074aa7ab3e deploy: auto commit repo-root changes 2026-07-05 11:43:29 2026-07-05 11:43:30 +08:00
hajimi ffa60e6b26 deploy: auto commit repo-root changes 2026-07-05 00:07:03 2026-07-05 00:07:04 +08:00
hajimi 9f85b559b0 deploy: auto commit repo-root changes 2026-07-05 00:04:07 2026-07-05 00:04:07 +08:00
hajimi 354b100558 deploy: auto commit uniapp changes 2026-07-05 00:00:02 2026-07-05 00:00:02 +08:00
hajimi 675246cde2 deploy: auto commit uniapp changes 2026-07-04 23:59:31 2026-07-04 23:59:31 +08:00
hajimi 6081254d63 deploy: auto commit repo-root changes 2026-07-04 23:57:27 2026-07-04 23:57:28 +08:00
hajimi 2de27db0e6 deploy: auto commit repo-root changes 2026-07-03 22:36:29 2026-07-03 22:36:29 +08:00
hajimi 5a888a730e deploy: auto commit repo-root changes 2026-07-03 22:33:26 2026-07-03 22:33:26 +08:00
hajimi 311c6c6224 deploy: auto commit repo-root changes 2026-07-03 21:52:42 2026-07-03 21:52:42 +08:00
hajimi 204cb361c6 deploy: auto commit repo-root changes 2026-07-03 21:49:44 2026-07-03 21:49:44 +08:00
hajimi b1539d9baf deploy: auto commit repo-root changes 2026-07-03 21:46:58 2026-07-03 21:46:58 +08:00
hajimi 6229b98076 deploy: auto commit repo-root changes 2026-07-03 21:32:43 2026-07-03 21:32:43 +08:00
hajimi 33664adbe0 fix: sync yuyan anchor live stream task 2026-07-03 21:10:05 +08:00
hajimi 8c358dc376 deploy: auto commit repo-root changes 2026-07-03 19:33:14 2026-07-03 19:33:14 +08:00
hajimi bbb6e9ab69 deploy: auto commit repo-root changes 2026-07-03 19:05:07 2026-07-03 19:05:07 +08:00
hajimi a88764162d deploy: auto commit repo-root changes 2026-07-03 18:56:25 2026-07-03 18:56:25 +08:00
hajimi affb0a4002 fix: keep worldcup live cards above tabbar 2026-07-03 17:51:08 +08:00
hajimi 7c260b1f99 deploy: auto commit server/public/mobile changes 2026-07-03 17:48:34 2026-07-03 17:48:34 +08:00
hajimi 61310de5a0 deploy: auto commit repo-root changes 2026-07-03 17:27:21 2026-07-03 17:27:22 +08:00
hajimi b8203e6168 deploy: auto commit repo-root changes 2026-06-24 16:55:29 2026-06-24 16:55:29 +08:00
hajimi a281b5fa8e deploy: auto commit repo-root changes 2026-06-24 16:53:33 2026-06-24 16:53:33 +08:00
hajimi 0d985506ba deploy: auto commit repo-root changes 2026-06-24 00:25:10 2026-06-24 00:25:10 +08:00
hajimi 2ec3b2dab5 整理临时文件 2026-06-22 10:57:07 +08:00
hajimi 27334d8589 deploy: auto commit repo-root changes 2026-06-22 01:45:37 2026-06-22 01:45:37 +08:00
hajimi 51718e11f3 deploy: auto commit repo-root changes 2026-06-22 01:44:20 2026-06-22 01:44:20 +08:00
hajimi ee9a5a3d03 no message 2026-06-22 01:31:20 +08:00
hajimi 9994c71374 deploy: auto commit repo-root changes 2026-06-22 01:20:41 2026-06-22 01:20:41 +08:00
hajimi 166740a74c deploy: auto commit repo-root changes 2026-06-21 22:05:33 2026-06-21 22:05:33 +08:00
hajimi f6d967869d deploy: auto commit repo-root changes 2026-06-21 19:30:16 2026-06-21 19:30:17 +08:00
hajimi 980b019809 deploy: auto commit repo-root changes 2026-06-21 17:56:21 2026-06-21 17:56:21 +08:00
hajimi bb1a2ebb9e deploy: auto commit repo-root changes 2026-06-21 17:13:24 2026-06-21 17:13:25 +08:00
hajimi 5435fe3686 Merge branch 'codex/match-live-streams' into dev.1.0.0 2026-06-21 16:50:16 +08:00
hajimi a51ed4bb95 deploy: auto commit repo-root changes 2026-06-21 16:39:29 2026-06-21 16:39:29 +08:00
hajimi f654081e8f refactor: move match live admin ui to dedicated page 2026-06-21 16:30:28 +08:00
hajimi 6722f6f7cc deploy: auto commit repo-root changes 2026-06-21 16:10:02 2026-06-21 16:10:02 +08:00
hajimi 875a770c5a fix: correct match live menu seed sql 2026-06-21 13:49:35 +08:00
hajimi 229b70c1be fix: require match live menu parent 2026-06-21 13:47:07 +08:00
hajimi 75a529be35 fix: preserve live streams on title edits 2026-06-21 13:44:05 +08:00
hajimi 08c1affb14 test: cover match live stream repository updates 2026-06-21 13:41:56 +08:00
hajimi 4ab24a9ae8 Strengthen match live admin static QA 2026-06-21 13:21:55 +08:00
hajimi 173871cf6a Add static admin QA for match live table 2026-06-21 13:10:33 +08:00
hajimi a8a678a5f6 fix: map live fetch status failure code 2026-06-21 13:02:22 +08:00
hajimi c59b7ade29 fix: align uniapp live stream metadata fields 2026-06-21 12:57:27 +08:00
hajimi 5f2e2e6c00 feat: add uniapp match live stream switching 2026-06-21 12:46:53 +08:00
hajimi 750ef91e29 fix: expose match live stream task error on failure 2026-06-21 12:32:49 +08:00
hajimi a9281f27c7 fix: surface match live stream partial failures 2026-06-21 12:29:36 +08:00
hajimi ecef509037 fix: accept semicolon jsonp in live stream parser 2026-06-21 12:22:19 +08:00
hajimi 11b06be9b1 test: cover semicolon jsonp in match live stream parser 2026-06-21 12:21:48 +08:00
hajimi bc9aef5a2f feat: add docker match live stream crawler 2026-06-21 12:15:40 +08:00
hajimi 7ebd563134 fix: guard match live validator scalars 2026-06-21 12:08:40 +08:00
hajimi cd9530686c fix: harden match live validation 2026-06-21 12:04:01 +08:00
hajimi 80e0e6a8a6 feat: wire match live backend api 2026-06-21 11:52:02 +08:00
hajimi 8d528ba5fe test: enforce exact match live snippets 2026-06-21 11:43:29 +08:00
hajimi b133fd36ee test: harden match live static contract 2026-06-21 11:40:49 +08:00
hajimi dbdb418e15 feat: add match live service helpers 2026-06-21 11:32:08 +08:00
hajimi 2496050ee7 fix: align match live schema foundation 2026-06-21 11:21:21 +08:00
hajimi ee4f2504bd feat: add match live table foundation 2026-06-21 11:11:48 +08:00
hajimi 5a8585d027 docs: add match live streams design spec 2026-06-21 11:05:00 +08:00
hajimi 20f0b4b111 deploy: auto commit repo-root changes 2026-06-16 01:39:08 2026-06-16 01:39:09 +08:00
hajimi e501393b66 deploy: auto commit repo-root changes 2026-06-15 14:09:04 2026-06-15 14:09:04 +08:00
hajimi 127475e539 deploy: auto commit repo-root changes 2026-06-13 12:42:06 2026-06-13 12:42:06 +08:00
hajimi ed1733e49d deploy: auto commit repo-root changes 2026-06-13 11:53:47 2026-06-13 11:53:47 +08:00
hajimi d246ab44e2 deploy: auto commit repo-root changes 2026-06-13 11:43:39 2026-06-13 11:43:39 +08:00
hajimi 6b3bbab5ce docs: update lottery ai image localization progress 2026-06-13 11:36:18 +08:00
hajimi 93474019a4 deploy: auto commit repo-root changes 2026-06-13 11:35:15 2026-06-13 11:35:15 +08:00
hajimi 50f0b6aff5 no message 2026-06-13 10:47:34 +08:00
hajimi 186521392d deploy: auto commit repo-root changes 2026-06-13 00:33:38 2026-06-13 00:33:38 +08:00
hajimi c3385b0e04 deploy: auto commit repo-root changes 2026-06-13 00:17:26 2026-06-13 00:17:26 +08:00
hajimi 16b8d0365a deploy: auto commit repo-root changes 2026-06-12 23:16:53 2026-06-12 23:16:53 +08:00
hajimi 0d656e8076 deploy: auto commit repo-root changes 2026-06-12 23:11:28 2026-06-12 23:11:28 +08:00
hajimi cd44cd6e47 迁移目录 2026-06-12 22:27:18 +08:00
hajimi 7381097582 no message 2026-06-12 22:23:47 +08:00
hajimi 8dacaea803 deploy: auto commit repo-root changes 2026-06-12 21:09:54 2026-06-12 21:09:54 +08:00
hajimi 30d768a3e6 deploy: auto commit server changes 2026-06-12 18:38:59 2026-06-12 18:39:00 +08:00
hajimi 4509279ac7 deploy: auto commit server changes 2026-06-12 17:51:57 2026-06-12 17:51:58 +08:00
600 changed files with 15304 additions and 2120 deletions
+4
View File
@@ -28,3 +28,7 @@ bin-release/
**/.hbuilderx/
**/unpackage/
qa/
.code-graph/
./vscode/
.tmp*
.codex/
@@ -0,0 +1 @@
[ 9545ms] [WARNING] Manifest: property 'start_url' ignored, should be same origin as document. @ https://git.zones.sbnews.net/assets/site-manifest.json:0
@@ -0,0 +1 @@
[ 11298ms] [WARNING] Manifest: property 'start_url' ignored, should be same origin as document. @ https://git.zones.sbnews.net/assets/site-manifest.json:0
@@ -0,0 +1,75 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- navigation "导航栏" [ref=e3]:
- generic [ref=e4]:
- link "首页" [ref=e5] [cursor=pointer]:
- /url: /
- link "探索" [ref=e7] [cursor=pointer]:
- /url: /explore/repos
- link "帮助" [ref=e8] [cursor=pointer]:
- /url: https://docs.gitea.com
- generic [ref=e9]:
- link "注册" [ref=e10] [cursor=pointer]:
- /url: /user/sign_up
- link "登录" [ref=e14] [cursor=pointer]:
- /url: /user/login
- main "首页" [ref=e18]:
- generic [ref=e20]:
- img "Logo" [ref=e21]
- generic [ref=e22]:
- heading "HAHA" [level=1] [ref=e23]
- heading "一款极易搭建的自托管 Git 服务" [level=2] [ref=e24]
- generic [ref=e25]:
- generic [ref=e26]:
- heading "易安装" [level=1] [ref=e27]
- paragraph [ref=e30]:
- text: 直接
- link "运行适合您平台的二进制文件" [ref=e31] [cursor=pointer]:
- /url: https://docs.gitea.com/installation/install-from-binary
- text: ,通过
- link "Docker" [ref=e32] [cursor=pointer]:
- /url: https://github.com/go-gitea/gitea/tree/master/docker
- text: 部署,或使用
- link "软件包" [ref=e33] [cursor=pointer]:
- /url: https://docs.gitea.com/installation/install-from-package
- text: 安装。
- generic [ref=e34]:
- heading "跨平台" [level=1] [ref=e35]
- paragraph [ref=e38]:
- text: 任何
- link "Go 语言" [ref=e39] [cursor=pointer]:
- /url: https://go.dev/
- text: 支持的平台都可以运行 Gitea,包括 Windows、Mac、Linux 以及 ARM。挑一个您喜欢的就行!
- generic [ref=e40]:
- generic [ref=e41]:
- heading "轻量级" [level=1] [ref=e42]
- paragraph [ref=e45]: 一个廉价的树莓派就足以满足 Gitea 的最低系统硬件要求。最大程度节省您的服务器资源!
- generic [ref=e46]:
- heading "开源化" [level=1] [ref=e47]
- paragraph [ref=e50]:
- text: 所有代码都开源在
- link "gitea.dev" [ref=e51] [cursor=pointer]:
- /url: https://gitea.dev
- text: ,还等什么?赶快加入我们来
- link "共同发展" [ref=e52] [cursor=pointer]:
- /url: https://github.com/go-gitea/gitea
- text: 这个伟大的项目,成为贡献者吧!
- group "页脚" [ref=e53]:
- contentinfo "关于软件" [ref=e54]:
- link "由 Gitea 强力驱动" [ref=e55] [cursor=pointer]:
- /url: https://about.gitea.com
- generic [ref=e56]: "版本: 1.27.1"
- generic [ref=e57]:
- text: "页面:"
- strong [ref=e58]: 1ms
- text: "模板:"
- strong [ref=e59]: 1ms
- group "链接" [ref=e60]:
- menu [ref=e61] [cursor=pointer]:
- generic [ref=e62]: Auto
- menu [ref=e66] [cursor=pointer]:
- generic [ref=e67]: 简体中文
- link "许可证" [ref=e70] [cursor=pointer]:
- /url: /assets/licenses.txt
- link "API" [ref=e71] [cursor=pointer]:
- /url: /api/swagger
@@ -0,0 +1,75 @@
- generic [active] [ref=f1e1]:
- generic [ref=f1e2]:
- navigation "导航栏" [ref=f1e3]:
- generic [ref=f1e4]:
- link "首页" [ref=f1e5] [cursor=pointer]:
- /url: /
- link "探索" [ref=f1e7] [cursor=pointer]:
- /url: /explore/repos
- link "帮助" [ref=f1e8] [cursor=pointer]:
- /url: https://docs.gitea.com
- generic [ref=f1e9]:
- link "注册" [ref=f1e10] [cursor=pointer]:
- /url: /user/sign_up
- link "登录" [ref=f1e14] [cursor=pointer]:
- /url: /user/login
- main "首页" [ref=f1e18]:
- generic [ref=f1e20]:
- img "Logo" [ref=f1e21]
- generic [ref=f1e22]:
- heading "HAHA" [level=1] [ref=f1e23]
- heading "一款极易搭建的自托管 Git 服务" [level=2] [ref=f1e24]
- generic [ref=f1e25]:
- generic [ref=f1e26]:
- heading "易安装" [level=1] [ref=f1e27]
- paragraph [ref=f1e30]:
- text: 直接
- link "运行适合您平台的二进制文件" [ref=f1e31] [cursor=pointer]:
- /url: https://docs.gitea.com/installation/install-from-binary
- text: ,通过
- link "Docker" [ref=f1e32] [cursor=pointer]:
- /url: https://github.com/go-gitea/gitea/tree/master/docker
- text: 部署,或使用
- link "软件包" [ref=f1e33] [cursor=pointer]:
- /url: https://docs.gitea.com/installation/install-from-package
- text: 安装。
- generic [ref=f1e34]:
- heading "跨平台" [level=1] [ref=f1e35]
- paragraph [ref=f1e38]:
- text: 任何
- link "Go 语言" [ref=f1e39] [cursor=pointer]:
- /url: https://go.dev/
- text: 支持的平台都可以运行 Gitea,包括 Windows、Mac、Linux 以及 ARM。挑一个您喜欢的就行!
- generic [ref=f1e40]:
- generic [ref=f1e41]:
- heading "轻量级" [level=1] [ref=f1e42]
- paragraph [ref=f1e45]: 一个廉价的树莓派就足以满足 Gitea 的最低系统硬件要求。最大程度节省您的服务器资源!
- generic [ref=f1e46]:
- heading "开源化" [level=1] [ref=f1e47]
- paragraph [ref=f1e50]:
- text: 所有代码都开源在
- link "gitea.dev" [ref=f1e51] [cursor=pointer]:
- /url: https://gitea.dev
- text: ,还等什么?赶快加入我们来
- link "共同发展" [ref=f1e52] [cursor=pointer]:
- /url: https://github.com/go-gitea/gitea
- text: 这个伟大的项目,成为贡献者吧!
- group "页脚" [ref=f1e53]:
- contentinfo "关于软件" [ref=f1e54]:
- link "由 Gitea 强力驱动" [ref=f1e55] [cursor=pointer]:
- /url: https://about.gitea.com
- generic [ref=f1e56]: "版本: 1.27.1"
- generic [ref=f1e57]:
- text: "页面:"
- strong [ref=f1e58]: 3ms
- text: "模板:"
- strong [ref=f1e59]: 3ms
- group "链接" [ref=f1e60]:
- menu [ref=f1e61] [cursor=pointer]:
- generic [ref=f1e62]: Auto
- menu [ref=f1e66] [cursor=pointer]:
- generic [ref=f1e67]: 简体中文
- link "许可证" [ref=f1e70] [cursor=pointer]:
- /url: /assets/licenses.txt
- link "API" [ref=f1e71] [cursor=pointer]:
- /url: /api/swagger
+31 -5
View File
@@ -35,7 +35,7 @@
3. 相同或相似的功能必须封装为可复用的方法,避免重复代码。
4. 查询字段、路由、菜单、配置前,优先以当前项目代码和 `sbnews.sql` / `docs/sql/` 中结构为准;如需线上核对,再按用户要求使用可用 MCP 或指定连接方式。
5. 不通过 WSL sshpass/scp 上传代码到服务器;项目代码更新由用户自行通过 SFTP 等方式处理。
6. 项目级文档(如协作规范、业务进度、设计稿映射、产品方案、实现流程、爬虫说明等)统一存放在项目根目录;仅子项目专属文档放在对应子目录。
6. 新增项目级文档(如协作规范、业务进度、设计稿映射、产品方案、实现流程、爬虫说明等)统一存放在 `docs/`;根目录仅保留 `README.md``AGENTS.md``文档索引.md` 等入口文件。仅子项目专属的 README / AGENTS 等说明可放在对应子目录。
---
@@ -48,6 +48,13 @@
| uniapp | `npm run dev:h5` | `npm run build:h5` |
| pc | `npm run dev` | `npm run generate` / `npm run build` |
## 自动化执行边界
- 除非用户在当前任务中明确要求,否则不要自动执行构建、测试、提交代码或部署。
- 完成代码修改后,可以说明建议执行的验证命令或部署命令,但不得自行运行。
- 用户明确要求“构建”“测试”“提交”“部署”“上线”“发布”等操作时,才执行对应命令。
- 对已经由用户给出的命令,可按用户原命令执行;不要额外扩展到未要求的构建、测试、提交或部署步骤。
## 数据库结构基准
- 数据库表前缀:`la_`
@@ -77,7 +84,7 @@
## 代码修改后验证与评审
除非用户特别说明不需要验证,代码修改完成后按改动类型选择验证方式:
仅在用户明确要求验证、测试或构建时,代码修改完成后按改动类型选择验证方式;未明确要求时只列出建议命令,不自动执行
### 后端接口修改
@@ -104,13 +111,24 @@
| 5 | 命名一致性 | 字段、方法、路由命名与现有风格一致 |
| 6 | 安全性 | SQL 拼接、用户输入、权限校验是否安全 |
| 7 | 幂等性 | SQL 初始化必须可重复执行 |
| 8 | 文档同步 | 功能完成后更新 `业务进度管理.md` |
| 8 | 文档同步 | 功能完成后更新 `docs/业务进度管理.md` |
## 代码变更后部署
- 仅在用户明确要求部署、上线、发布或同步到测试服时,执行以下命令,将最新代码更新到测试服运行环境:
```powershell
cd D:\www\gs-sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net
```
- 不要把部署视为代码变更后的默认必做步骤;未明确要求部署时,只提示可执行的部署命令。
## 设计稿映射
设计稿与页面/组件映射统一维护在:
- [设计稿映射.md](./设计稿映射.md)
- [设计稿映射.md](./docs/设计稿映射.md)
开发或还原视觉前,先确认设计稿名称、页面路径、实现状态与备注。
@@ -118,10 +136,18 @@
当前业务进度、待办事项、避坑记录统一维护在:
- [业务进度管理.md](./业务进度管理.md)
- [业务进度管理.md](./docs/业务进度管理.md)
开发或接手任务前,先查看该文档确认已完成事项、进行中事项、待办事项与避坑记录。
## 近期交接总结
- 当前真实开发目录以 `D:\www\gs-sport-era` 为准;Codex worktree(如 `C:\Users\Administrator\.codex\worktrees\...\gs-sport-era`)中的改动不会自动进入真实仓库、`git log` 或服务器运行目录。涉及可部署代码时,先确认改动已经同步到真实目录并形成清晰提交。
- 正确的服务器运行目录是 `/www/wwwroot/test-server.sbnews.net``/www/wwwroot/api.sbnews.net` 不是当前项目的有效同步目录,不要再部署到该路径。排查“代码已更新但服务器没生效”时,本地真实仓库看 `git log`,服务器侧看文件内容、修改时间、容器内 `/app` 文件与定时任务日志。
- Docker crawler 任务真实执行入口是 `sport-era-crawler` 容器。更新 `docker/crawler` 源码或 `config/crawler_tasks.yaml` 后,必须在服务器 `docker` 目录执行 `sudo docker compose -f docker-compose.crawler.yml build crawler``sudo docker compose -f docker-compose.crawler.yml up -d --no-deps crawler`;仅重启旧容器不能保证镜像内代码更新,必要时使用 `--no-cache` 重建。
- 重启 crawler 后必须核验容器内代码和 cron,而不是只看宿主机源码:例如检查 `/app/match_live_stream.py``/app/config/crawler_tasks.yaml`,以及 `cat /etc/cron.d/* | grep match_live_stream`
- 雨燕直播间任务 `match_live_stream` 当前应直接同步 `all_live_rooms.json`,写入主播 `anchor_id``anchor_name``anchor_avatar``anchor_level` 以及直播间观看/关注/公告/简介字段;对应 SQL 为 `docs/sql/alter_match_live_anchor_fields.sql``docs/sql/insert_crontab_match_live_stream.sql`。上线后用 `la_match_live` 主播字段统计和 `la_crawler_task_log` 最近任务状态共同确认是否生效。
## 近期踩坑
- `Validate::only()` 只限制校验字段范围,不会自动过滤请求参数;新增字段落库前,要同时检查 validate、logic、model、保存白名单和数据库列是否同步。
+54 -21
View File
@@ -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 定时任务列表
@@ -62,7 +66,7 @@ Docker 只负责运行 Python 爬虫和容器内 cron。MySQL、Redis、Nginx
```bash
ssh sbnews
cd /www/wwwroot/test-server.sbnews.net/docker
cd /www/wwwroot/api.sbnews.net/docker
```
查看容器状态:
@@ -123,7 +127,7 @@ sudo docker exec sport-era-crawler python -c "import scrapling, browserforge; pr
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/api.sbnews.net
```
如果 `sbnews` 配置在 `C:\Users\Administrator\.ssh\config` 中,默认会使用 Windows 的 `ssh.exe` 读取该配置。
@@ -134,20 +138,29 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -Remo
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/test-server.sbnews.net
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/api.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
@@ -164,14 +177,14 @@ server/route
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From <old_commit> -To <new_commit> -RemoteDir /www/wwwroot/test-server.sbnews.net
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From <old_commit> -To <new_commit> -RemoteDir /www/wwwroot/api.sbnews.net
```
示例:
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From abc1234 -To def5678 -RemoteDir /www/wwwroot/test-server.sbnews.net
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From abc1234 -To def5678 -RemoteDir /www/wwwroot/api.sbnews.net
```
### 自动增量发布
@@ -180,28 +193,28 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -From
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -RemoteDir /www/wwwroot/api.sbnews.net
```
如果需要一次发布最近 N 个提交的后端改动,可以追加 `-AutoIncrementalCommits <n>`,例如发布最近 3 个提交会对比 `HEAD~3``HEAD`
```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
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/api.sbnews.net
```
先演练不上传:
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncremental -RemoteDir /www/wwwroot/api.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 的自动提交,并把该目录从当前工作区按源码目录补进增量包,保证可以继续发布。
### 只演练不上传
@@ -209,7 +222,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -AutoIncrem
```powershell
cd D:\www\sport-era
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir /www/wwwroot/test-server.sbnews.net -DryRun
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir /www/wwwroot/api.sbnews.net -DryRun
```
脚本会在打包后打印 `update log`:增量模式包含 commit 范围、commit 摘要、变更文件和删除文件;全量模式包含当前最新 commit 和默认打包目录。
@@ -222,17 +235,37 @@ powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -RemoteDir
```powershell
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"
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -RemoteDir /www/wwwroot/api.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/api.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">![likeshop全开源商城](https://server.likeshop.cn/uploads/gitad/fastbuildAI.png)</a><br>
<a href="https://www.likeshop.cn">![likeshop全开源商城](https://server.likeshop.cn/uploads/gitad/likeshop.png)</a><br>
-43
View File
@@ -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` = '世界杯';
-29
View File
@@ -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;
+19
View File
@@ -21,6 +21,25 @@ 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
# Titan007 public cup odds. Multiple cup URLs can be comma-separated.
TITAN_ODDS_ENABLED=1
TITAN_ODDS_URLS=https://zq.titan007.com/cn/CupMatch/75.html
TITAN_ODDS_COMPANY_ID=3
TITAN_ODDS_EURO_COMPANY_ID=545
TITAN_ODDS_COMPANY_NAME=Crown
TITAN_ODDS_SHOW_TYPE=early
TITAN_ODDS_VERIFY_TLS=1
TITAN_ODDS_TIMEOUT=20
TITAN_ODDS_REQUEST_DELAY_SECONDS=0.2
# 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
+1
View File
@@ -0,0 +1 @@
CRAWLER_DIR=./crawler
+4
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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_", "TITAN_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,
+487
View File
@@ -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()
+725
View File
@@ -0,0 +1,725 @@
#!/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
from titan_odds import (
DEFAULT_TITAN_COMPANY_ID,
DEFAULT_TITAN_COMPANY_NAME,
DEFAULT_TITAN_CUP_URLS,
DEFAULT_TITAN_EURO_COMPANY_ID,
TitanOddsClient,
)
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 _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(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
items: List[Dict[str, Any]] = []
fetch_errors: List[str] = []
source_urls: List[str] = []
successful_sources = 0
skipped_count = 0
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 username and 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 = _safe_float(
env.get("ODDS_REQUEST_DELAY_SECONDS"),
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"hg {base_url}: {exc}")
if client is None:
fetch_errors.extend(login_errors)
else:
successful_sources += 1
source_urls.append(client.base_url)
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"hg {sport_type}/{show_type}: {exc}")
if request_delay > 0:
time.sleep(request_delay)
titan_enabled = _bool_env(env.get("TITAN_ODDS_ENABLED"), default=True)
if titan_enabled:
titan_urls = [
str(url).strip()
for url in _split_env_list(
env.get("TITAN_ODDS_URLS", ""),
DEFAULT_TITAN_CUP_URLS,
)
]
titan_client = TitanOddsClient(
timeout=_safe_int(env.get("TITAN_ODDS_TIMEOUT"), 20),
verify_tls=_bool_env(env.get("TITAN_ODDS_VERIFY_TLS"), default=True),
company_id=_safe_int(
env.get("TITAN_ODDS_COMPANY_ID"),
DEFAULT_TITAN_COMPANY_ID,
),
euro_company_id=_safe_int(
env.get("TITAN_ODDS_EURO_COMPANY_ID"),
DEFAULT_TITAN_EURO_COMPANY_ID,
),
company_name=(
env.get("TITAN_ODDS_COMPANY_NAME")
or DEFAULT_TITAN_COMPANY_NAME
).strip(),
show_type=(env.get("TITAN_ODDS_SHOW_TYPE") or "early").strip().lower(),
request_delay=_safe_float(
env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"),
0.2,
),
)
for titan_url in titan_urls:
try:
items.extend(titan_client.fetch_cup(titan_url))
successful_sources += 1
source_urls.append(titan_url)
except Exception as exc: # noqa: BLE001
fetch_errors.append(f"titan007 {titan_url}: {exc}")
titan_request_delay = _safe_float(
env.get("TITAN_ODDS_REQUEST_DELAY_SECONDS"),
0.2,
)
if titan_request_delay > 0:
time.sleep(titan_request_delay)
if successful_sources == 0:
if not username or not password:
fetch_errors.append("ODDS_USERNAME/ODDS_PASSWORD 未配置")
return _build_result(
success=False,
candidate_count=len(items),
saved_count=0,
source_url=",".join(dict.fromkeys(source_urls)),
skipped_count=skipped_count,
errors=fetch_errors or ["没有可用的赔率来源"],
)
repo = None
source_url = ",".join(dict.fromkeys(source_urls))
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=source_url,
skipped_count=skipped_count,
errors=fetch_errors + [f"database: {exc}"],
)
finally:
if repo is not None:
repo.close()
return _build_result(
success=True,
candidate_count=len(items),
saved_count=saved_count,
source_url=source_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());

Some files were not shown because too many files have changed in this diff Show More