Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282029706b | ||
|
|
820c3fb1f1 | ||
|
|
cafd0651b1 | ||
|
|
41d272ba29 | ||
|
|
f5b7011c45 | ||
|
|
4a61aeeb26 | ||
|
|
d6f7c3f0f5 | ||
|
|
1e44c0eb9b | ||
|
|
961574037e | ||
|
|
3ee4ba7e84 | ||
|
|
30218790ce | ||
|
|
a257f96d94 | ||
|
|
ee180f3519 | ||
|
|
c5a342cb5d | ||
|
|
76fa474b7d | ||
|
|
f035e2c233 | ||
|
|
0f7e7b68dd | ||
|
|
3b7d00b32b | ||
|
|
633a6f16b9 | ||
|
|
2c4e2c2653 | ||
|
|
e0a8228707 | ||
|
|
11dbb73b5c | ||
|
|
43e8409c0e | ||
|
|
22a9cf256d | ||
|
|
fefd8c7823 | ||
|
|
79ce5cfd2e | ||
|
|
aac4593cc5 | ||
|
|
1aa6710e39 | ||
|
|
7c9e54e5eb | ||
|
|
544ea42d40 | ||
|
|
38f46ad2ea | ||
|
|
fdd926e9bb | ||
|
|
28858c0703 | ||
|
|
fe1b13b7c1 |
@@ -38,7 +38,16 @@ jobs:
|
|||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
"$PYTHON" -m pip install -q -r web/requirements.txt
|
# Prefer job-local venv (PEP 668). Some runners lack ensurepip/python3-venv.
|
||||||
|
rm -rf .venv
|
||||||
|
if "$PYTHON" -m venv .venv; then
|
||||||
|
.venv/bin/pip install -q -r web/requirements.txt
|
||||||
|
echo "CLIMPEROR_PY=${PWD}/.venv/bin/python" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "venv unavailable; pip --break-system-packages"
|
||||||
|
"$PYTHON" -m pip install -q --break-system-packages -r web/requirements.txt
|
||||||
|
echo "CLIMPEROR_PY=${PYTHON}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Refresh daily tier
|
- name: Refresh daily tier
|
||||||
env:
|
env:
|
||||||
@@ -51,13 +60,13 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
"${CLIMPEROR_PY}" web/refresh_web.py --tier daily
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
|
||||||
"$PYTHON" web/refresh_web.py --tier daily
|
|
||||||
|
|
||||||
- name: Save refresh summary
|
- name: Save refresh summary
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
continue-on-error: true
|
||||||
|
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: web-daily-refresh-${{ github.run_id }}
|
name: web-daily-refresh-${{ github.run_id }}
|
||||||
path: web/.refresh/summary.json
|
path: web/.refresh/summary.json
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ jobs:
|
|||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
"$PYTHON" -m pip install -q -r web/requirements.txt
|
# Prefer job-local venv (PEP 668). Some runners lack ensurepip/python3-venv.
|
||||||
|
rm -rf .venv
|
||||||
|
if "$PYTHON" -m venv .venv; then
|
||||||
|
.venv/bin/pip install -q -r web/requirements.txt
|
||||||
|
echo "CLIMPEROR_PY=${PWD}/.venv/bin/python" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "venv unavailable; pip --break-system-packages"
|
||||||
|
"$PYTHON" -m pip install -q --break-system-packages -r web/requirements.txt
|
||||||
|
echo "CLIMPEROR_PY=${PYTHON}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Refresh patch tier
|
- name: Refresh patch tier
|
||||||
env:
|
env:
|
||||||
@@ -49,13 +58,13 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
"${CLIMPEROR_PY}" web/refresh_web.py --tier patch
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
|
||||||
"$PYTHON" web/refresh_web.py --tier patch
|
|
||||||
|
|
||||||
- name: Save refresh summary
|
- name: Save refresh summary
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
continue-on-error: true
|
||||||
|
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: web-patch-refresh-${{ github.run_id }}
|
name: web-patch-refresh-${{ github.run_id }}
|
||||||
path: web/.refresh/summary.json
|
path: web/.refresh/summary.json
|
||||||
|
|||||||
@@ -36,7 +36,16 @@ jobs:
|
|||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
"$PYTHON" -m pip install -q -r web/requirements.txt
|
# Prefer job-local venv (PEP 668). Some runners lack ensurepip/python3-venv.
|
||||||
|
rm -rf .venv
|
||||||
|
if "$PYTHON" -m venv .venv; then
|
||||||
|
.venv/bin/pip install -q -r web/requirements.txt
|
||||||
|
echo "CLIMPEROR_PY=${PWD}/.venv/bin/python" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "venv unavailable; pip --break-system-packages"
|
||||||
|
"$PYTHON" -m pip install -q --break-system-packages -r web/requirements.txt
|
||||||
|
echo "CLIMPEROR_PY=${PYTHON}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Refresh weekly tier
|
- name: Refresh weekly tier
|
||||||
env:
|
env:
|
||||||
@@ -49,13 +58,13 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
"${CLIMPEROR_PY}" web/refresh_web.py --tier weekly
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
|
||||||
"$PYTHON" web/refresh_web.py --tier weekly
|
|
||||||
|
|
||||||
- name: Save refresh summary
|
- name: Save refresh summary
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
continue-on-error: true
|
||||||
|
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: web-weekly-refresh-${{ github.run_id }}
|
name: web-weekly-refresh-${{ github.run_id }}
|
||||||
path: web/.refresh/summary.json
|
path: web/.refresh/summary.json
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pc/preview/
|
|||||||
pc/results/
|
pc/results/
|
||||||
pc/failures/
|
pc/failures/
|
||||||
pc/samples/raw/
|
pc/samples/raw/
|
||||||
|
pc/player_pages/
|
||||||
_tools/
|
_tools/
|
||||||
_tmp_*
|
_tmp_*
|
||||||
_cmp_*
|
_cmp_*
|
||||||
@@ -42,7 +43,10 @@ web/.refresh-cache/
|
|||||||
|
|
||||||
# Large regenerable CDN media (fetch scripts)
|
# Large regenerable CDN media (fetch scripts)
|
||||||
web/assets/ability_videos/
|
web/assets/ability_videos/
|
||||||
web/assets/ability_icons/
|
# Regenerable Steam CDN ability icons; keep bundled shared badges in git.
|
||||||
|
web/assets/ability_icons/*
|
||||||
|
!web/assets/ability_icons/innate.png
|
||||||
|
!web/assets/ability_icons/talent_tree.png
|
||||||
web/assets/hero_portraits/
|
web/assets/hero_portraits/
|
||||||
web/assets/item_icons/
|
web/assets/item_icons/
|
||||||
# Curated streamer highlight clips (large; sync via OSS, not git)
|
# Curated streamer highlight clips (large; sync via OSS, not git)
|
||||||
|
|||||||
@@ -33,13 +33,16 @@ climperor/
|
|||||||
| `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载;re-export `shared.paths` 常量 |
|
| `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载;re-export `shared.paths` 常量 |
|
||||||
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
||||||
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
||||||
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装 |
|
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装;`POST_GAME` 触发 `player_pages` 赛后建页 |
|
||||||
|
| `pc/player_pages.py` | 赛后轮询 OpenDota → `pc/player_pages/{account_id}/`(profile + match JSON);`enrich_profile_recent` 写近 20 场 + `career`/`recent_20`/`top_heroes`/`activity_180`/`peers`/`availability`;`get_profile_for_web` 缓存优先(`enrich_ttl_seconds` 默认 600,与 Pages `isStale` 对齐:未过期直出、过期后台刷新、冷启动同步一次);详情 `ensure_match_detail` 懒加载;`public_share` 时 POST `/api/players/publish` 入队;**不**进 recommend |
|
||||||
|
| `pc/player_stats.py` | 玩家聚合公式(KDA/胜率/生涯/近 N 场/180 天热力图);空 OpenDota 不覆盖旧 career |
|
||||||
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
||||||
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
||||||
|
| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
|
||||||
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
||||||
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
|
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
|
||||||
| `pc/capture.py` | 屏幕捕获 |
|
| `pc/capture.py` | 屏幕捕获 |
|
||||||
| `pc/overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) |
|
| `pc/overlay.py` | 网格「克/搭/补」方标 + 阵容分析横条 + 锁定后装备图标条(点击穿透;不再在顶栏头像下画定位) |
|
||||||
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
|
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
|
||||||
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) |
|
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) |
|
||||||
| `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json`) |
|
| `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json`) |
|
||||||
@@ -52,7 +55,7 @@ climperor/
|
|||||||
|
|
||||||
| 路径 | 职责 |
|
| 路径 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
|
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`PC_PLAYER_PAGES`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
|
||||||
| `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
|
| `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
|
||||||
| `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
|
| `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
|
||||||
| `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
|
| `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
|
||||||
@@ -70,10 +73,13 @@ climperor/
|
|||||||
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
||||||
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
|
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
|
||||||
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`)+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
|
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`)+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
|
||||||
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range) |
|
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;History 深度路径 SPA fallback 回 `index.html`;`/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`GET /api/players/me` 登录本人 enrich;`GET /api/players/{account_id}[/{match_id}]` 读 `pc/player_pages/`;`POST /api/players/enrich` / `ensure-match` 本机补拉近期与详情;`POST /api/players/publish` 本地 no-op;Steam OpenID:`/api/auth/steam` / `callback` / `me` / `logout`(需 `STEAM_API_KEY` + `SESSION_SECRET`,镜像 `web/steam_auth.py`);`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range) |
|
||||||
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` 写 `config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist;`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
|
| `web/steam_auth.py` | Steam OpenID + 签名 Cookie 会话(本地 serve 用;与 `web/frontend/functions/api/auth/*` 对齐) |
|
||||||
|
| `web/frontend/functions/api/auth/` | Pages Functions:Steam 登录 / 回调 / me / logout;Cookie `climperor_steam`;Env:`STEAM_API_KEY`、`SESSION_SECRET` |
|
||||||
|
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` / `SITE_ORIGIN` 与 `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` / `--site-origin` 写 `config.js`;设 `--static-asset-base` 时不拷贝图标进 dist;调用 `seo_prerender.py` 写英雄/机制预渲染 HTML + `sitemap.xml` / `llms.txt`;拷贝 `_redirects` / `robots.txt`;`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
|
||||||
|
| `web/seo_prerender.py` | 导出期 SEO/GEO:注入 title/description/canonical/OG/JSON-LD 与 `#seo-prerender` 正文;生成全英雄 `/heroes/{key}`、机制 `/mechanics/{effect}`、顶层页、`sitemap.xml`、`llms.txt` |
|
||||||
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
||||||
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳的业务摘要有变更才 OSS upload + `deploy_relations.py`;写 `web/.refresh/summary.json`(步骤/耗时/stale/部署);同 runner 文件锁串行发布,patch 遇全量刷新则延后;`--dry-run` / `--skip-deploy` / `--skip-oss` / `--force-deploy`。`daily`:stats/排行/比赛/pro/主播/主播开播探测 + patch check;`weekly`:STRATZ meta + 物品;`patch`:仅 check,`has_new` 时详情 + abilities/商店/meta/fears |
|
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳与 `is_live` 的业务摘要有变更才 OSS upload + `deploy_relations.py`(亦监视 frontend / `relations.json` / `heroes.json` / 网格顺序);写 `web/.refresh/summary.json`(步骤/耗时/stale/部署;`--dry-run` 只打印不写盘、不 restore/save 缓存);同 runner 文件锁串行发布,patch 遇全量刷新则延后并标 `skipped`;`--skip-deploy` / `--skip-oss` / `--force-deploy`。`daily`:stats/排行/比赛/pro/主播/主播开播探测 + patch check;`weekly`:STRATZ meta + 物品 + counter;`patch`:仅 check,`has_new` 时详情 + abilities/商店/meta/fears |
|
||||||
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
|
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
|
||||||
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
|
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
|
||||||
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`;Secrets:`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
|
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`;Secrets:`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
|
||||||
@@ -86,15 +92,19 @@ climperor/
|
|||||||
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
|
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
|
||||||
| `web/fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `web/data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend) |
|
| `web/fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `web/data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend) |
|
||||||
| `web/fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `web/data/hero_matches.json`(`--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby;`--public-region china` 优先国服;`--workers` 详情并发 + 凑满即停;可选 `OPENDOTA_API_KEY`;增量补缺失/不足 N 场;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend) |
|
| `web/fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `web/data/hero_matches.json`(`--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby;`--public-region china` 优先国服;`--workers` 详情并发 + 凑满即停;可选 `OPENDOTA_API_KEY`;增量补缺失/不足 N 场;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend) |
|
||||||
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json`;`--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--players` 覆盖整文件;`--all-pros` 从 `/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs`;**仅上分帝 Web「比赛」**;勿进 recommend) |
|
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json`;`--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--refresh-limit N` 按 `fetched_at` 只刷新最陈旧 N 人并保留其余;连续 429 熔断后仍写盘;可选 `OPENDOTA_API_KEY`;`--players` 覆盖整文件且不做轮换;`--all-pros` 从 `/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs --refresh-limit 15`;**仅上分帝 Web「比赛」**;勿进 recommend) |
|
||||||
| `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend) |
|
| `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend) |
|
||||||
| `web/fetch_streamers.py` | 从抖音主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞/头像(手工名单 + 直播间/主页 URL;支持 `v.douyin.com` 短链;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily) |
|
| `web/fetch_streamers.py` | 从抖音 / 斗鱼主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞(播放)/头像(手工名单 + 直播间/主页 URL;抖音支持 `v.douyin.com` 短链;斗鱼优先 `v.douyu.com/author/<hash>`(兼容 `author-video`)的 `$DATA`,仅房间号时从直播间 HTML 解析 `up_id` 再拉作者页,失败才回退 `betard`;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily) |
|
||||||
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json` 的 `is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info`(`live_status==1` 在播,轮播算下播);软失败保留旧 `is_live`、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily |
|
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json` 的 `is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info`(`live_status==1` 在播,轮播算下播),斗鱼走 `betard/{room_id}`(`show_status==1` 在播,`videoLoop==1` 轮播算下播);探测失败清为 `is_live:false` 并去掉 `live_probed_at`(与 `/api/live-status` 一致,不沿用旧直播中)、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily(角标以访问触发的 live API 为准,daily 仅作 data.json 兜底) |
|
||||||
| `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`),读 `data.json` 的 `streamers.streamers`;Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 单主播沿用 edge 缓存/daily `is_live` 标 `stale`,全失败回陈旧快照(`stale-override`)或空表(`error`),永不 500;导出时拷贝 `functions/`;**部署须 `cwd=dist` 跑 wrangler**(Functions 相对 cwd 解析) |
|
| `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`,含抖音 / B 站 / 斗鱼),读 `data.json` 的 `streamers.streamers`;Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 失败主播一律 `is_live:false` + `stale:true`(**不**沿用旧的直播中);全失败回 `stale-override` 空角标表或 `error`,永不 500;导出时拷贝 `functions/`;**部署须 `cwd=dist` 跑 wrangler**(Functions 相对 cwd 解析) |
|
||||||
| `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py` 写 `web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 |
|
| `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py` 写 `web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 |
|
||||||
|
| `web/frontend/functions/api/players/publish.js` | Pages Function `POST /api/players/publish`:校验后投递 Queue(D1/R2 由 Worker 写入);可选 `PLAYER_PAGES_PUBLISH_SECRET`;**不**进主 `data.json` / recommend |
|
||||||
|
| `web/frontend/functions/api/players/me.js` | `GET /api/players/me`:登录本人 D1 档案;过期则 Queue 刷新并标 `stale` |
|
||||||
|
| `web/frontend/functions/api/players/ensure-match.js` | `POST /api/players/ensure-match`:本人或 `public_share` 懒加载比赛详情(OpenDota → 规范化 → R2);静态路由须优先于 `[account_id]`,否则 POST 会 405 |
|
||||||
|
| `web/cloudflare/` | 多用户数据层:D1 migrations、`player-sync` Worker、`provision.py` / `deploy_worker.py`;资源名见 `web/cloudflare/README.md` |
|
||||||
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__` 供 `app.js` 跳过桌面 boot |
|
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__` 供 `app.js` 跳过桌面 boot |
|
||||||
| `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `web/data/item_shop.json` + 图标 |
|
| `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `web/data/item_shop.json` + 图标 |
|
||||||
| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json`(`%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`) |
|
| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json`(`%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`;合并 `item_alias_overrides.json` → `aliases`) |
|
||||||
| `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
| `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
||||||
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%` 走 `values_shard`/`values_scepter`) |
|
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%` 走 `values_shard`/`values_scepter`) |
|
||||||
| `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
| `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
||||||
@@ -102,33 +112,35 @@ climperor/
|
|||||||
| `web/fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `web/data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) |
|
| `web/fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `web/data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) |
|
||||||
| `web/item_fears.py` | 规则推导「英雄怕的装备」→ `web/data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides |
|
| `web/item_fears.py` | 规则推导「英雄怕的装备」→ `web/data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides |
|
||||||
| `web/data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) |
|
| `web/data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) |
|
||||||
|
| `web/data/patch_summaries.json` | 手写「AI 解读」(仅保留最新版条目;Cursor 审阅后写入;进 `INPUT_WATCH`;**不**进定时生成 / recommend) |
|
||||||
| `web/data/hero_grid_order.json` | Web 站点四列网格顺序 |
|
| `web/data/hero_grid_order.json` | Web 站点四列网格顺序 |
|
||||||
| `web/data/hero_items.json` | 核心成品装备缓存(Web 站点只读;由 `fetch_hero_items.py` 生成) |
|
| `web/data/hero_items.json` | 核心成品装备缓存(Web + PC 锁定后核心装推荐只读;由 `fetch_hero_items.py` 生成) |
|
||||||
| `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`(OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
|
| `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`(OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
|
||||||
| `web/data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`;Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
|
| `web/data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`;Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
|
||||||
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
|
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
|
||||||
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) |
|
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) |
|
||||||
| `web/data/leaderboards.json` | Valve Immortal 四区 Top100(`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
|
| `web/data/leaderboards.json` | Valve Immortal 四区 Top100(`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
|
||||||
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音/B 站 profile 补全;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`)+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上 `/api/live-status` 边缘缓存 5 分钟;`data.json` daily 兜底;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
|
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音 profile 补全;平台含抖音 / B 站 / 斗鱼;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);列表排序:先 `is_live` 再粉丝数降序;视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`)+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s;`stale`/失败不显示角标;`data.json` daily 仅作首屏兜底直至接口返回;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
|
||||||
| `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win(`weeks`/`latest`)+ **同段位最近 1 周分路**(`winWeek`+`positionIds`)+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`#/trends` 近 N 周榜;**不**参与局内推荐) |
|
| `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win(`weeks`/`latest`)+ **同段位最近 1 周分路**(`winWeek`+`positionIds`)+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`/trends` 近 N 周榜;**不**参与局内推荐) |
|
||||||
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Top(counters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
|
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Top(counters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
|
||||||
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
||||||
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
||||||
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
||||||
|
| `web/data/item_alias_overrides.json` | 装备中文简称/俗称(合并进 `items_meta.aliases`;物品页搜索与悬停;勿与 `name_loc` 重复) |
|
||||||
| `web/data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
| `web/data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
||||||
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
||||||
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) |
|
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) |
|
||||||
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
||||||
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `functions/`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}`;移动端由 `mobile-gate.js` 拦截 |
|
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `_redirects` / `robots.txt` / `functions/`);`config.js` 含 `SITE_VERSION`、`SITE_ORIGIN`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`、`PLAYERS_ASSET_BASE`;英雄页底部(无详情时)显示 `v{SITE_VERSION}` 与数据更新时间;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截(搜索/AI 爬虫 UA 跳过) |
|
||||||
| `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
|
| `web/frontend/router.js` | History 路径路由:`parseHash` / `serializeHash`(操作 pathname+search)/ `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层 `/home\|heroes\|rankings\|streamers\|matches\|players\|trends\|mechanics\|items\|patches` / 登录后「我」`/home[/{match_id}]` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal `/rankings[/region]` / 明星比赛 `/matches[/account_id][?origin=pro\|china][&page=N]` / PC 赛后玩家页 `/players/{account_id}[/{match_id}]`(本机 API 或 OSS `players/`;默认私有) / 主播 `/streamers` / 走势 `/trends[/bracket][?sort=pr]` / 机制 `/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `/rankings/meta` 与 hash `#/...` 兼容) |
|
||||||
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
|
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
|
||||||
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
|
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
|
||||||
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/`) |
|
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/`) |
|
||||||
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) |
|
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝 + `rank_star_1..5` 星级叠层;玩家页具体段位;走势段位选择器只用勋章) |
|
||||||
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
||||||
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
||||||
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) |
|
||||||
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等;`icon_damage.png` 等战斗属性图标;平台 logo) |
|
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等;`icon_damage.png` 等战斗属性图标;平台 logo) |
|
||||||
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
|
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
|
||||||
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
||||||
@@ -137,6 +149,7 @@ climperor/
|
|||||||
运行时产物(**勿提交**,见 `.gitignore`):
|
运行时产物(**勿提交**,见 `.gitignore`):
|
||||||
|
|
||||||
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
|
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
|
||||||
|
- `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后入队 Cloudflare D1/R2,不进主 data.json)
|
||||||
- `pc/preview/` — 标定 / sheet 预览
|
- `pc/preview/` — 标定 / sheet 预览
|
||||||
- `web/dist/` — 静态站点导出(`export_relations_site.py`)
|
- `web/dist/` — 静态站点导出(`export_relations_site.py`)
|
||||||
- `pc/results/` — 每局 JSON
|
- `pc/results/` — 每局 JSON
|
||||||
@@ -155,23 +168,27 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
|
|||||||
→ recognize_image (CDN 模板 + 可选天梯遮罩)
|
→ recognize_image (CDN 模板 + 可选天梯遮罩)
|
||||||
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
|
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
|
||||||
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
|
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
|
||||||
→ overlay 角色标签 + 网格克/搭/补 + 阵容分析条(可选)
|
→ overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
|
||||||
→ pc/results/draft_*.json + 终端时间线
|
→ pc/results/draft_*.json + 终端时间线
|
||||||
|
→ POST_GAME:player_pages 轮询 OpenDota → pc/player_pages/(可选 publish → Queue → D1/R2)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术约束(修改前必读)
|
## 技术约束(修改前必读)
|
||||||
|
|
||||||
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
|
||||||
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
|
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
|
||||||
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `#/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;首版仅抖音),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
|
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;平台含抖音 / B 站 / 斗鱼),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
|
||||||
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
|
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。Web「怕」行只读 `hero_item_fears.json`;**禁止**把 fears / counter_stats 统计胜率写入 `pc/recommend.py` 或 `pc/item_suggest.py` 评分。PC 局内出装仅允许:`pc/item_suggest.py` 在本人锁定后只读 `hero_items.json`(核心相对热度)+ 定性敌方 tags/画像规则表 + `items_meta` 名称 + 本地 `item_icons`;仍禁止 STRATZ / hero_stats / matches。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;机制 tags **禁止**写入 relations。
|
||||||
- **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
- **机制查询页**:顶层 `/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
|
||||||
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
|
||||||
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
||||||
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
|
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
|
||||||
- **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。
|
- **宁可不认,不可乱认**:`min_score` + `min_margin` 双门控;不确定就 `null`。
|
||||||
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
|
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
|
||||||
- **上分帝 Web 定时刷新**:Gitea Actions(self-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
|
- **上分帝 Web 定时刷新**:Gitea Actions(self-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
|
||||||
|
- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` 时 POST 入队由 Worker 写 D1/R2(PC **不**内置云密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 D1(+R2 详情);登录「我」`/home` 走 `/api/players/me`(生产 D1 缓存 + Queue 刷新;本机 `profile.json` TTL 缓存,语义对齐)。首次同步可慢,TTL 内后续直出缓存;R2 仅完整比赛详情,不承担主页加速。有公开 `account_id` 的昵称可点进主页并补拉近 20 场,详情懒加载;隐私局可借本机 GSI `samples/raw` 发现;**禁止**塞进主 `data.json`,**禁止**进 recommend。需玩家开启「公开比赛数据」才能稳定拿到他人 ID。
|
||||||
|
- **Steam 登录**:OpenID 确认本人 `account_id`;顶栏登录后出现「我」`/home`(复用玩家页)。本机 `serve_relations` 与 CF Pages 均需 `STEAM_API_KEY` + `SESSION_SECRET`;登录只解锁本人身份/历史,不能绕过他人隐私。勿把会话密钥写进 git / `data.json`。
|
||||||
|
- **多用户玩家数据**:生产用 Cloudflare **D1**(档案/统计索引)+ **Queue/Worker**(OpenDota 同步)+ 可选 **R2**(完整比赛 JSON);本机仍写 `pc/player_pages/`。他人主页须 `public_share`;勿把玩家战绩打进主 `data.json` / recommend。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
@@ -185,7 +202,7 @@ python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝
|
|||||||
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
|
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
|
||||||
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token)
|
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token)
|
||||||
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage;--workers 6;--enrich-item-times)
|
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage;--workers 6;--enrich-item-times)
|
||||||
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist;--include-pubs 含天梯;可 --players Ame,898754153)
|
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist;--include-pubs 含天梯;--refresh-limit 15 轮换;可 --players Ame,898754153)
|
||||||
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
|
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
|
||||||
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
|
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
|
||||||
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily;--dry-run 只打印)
|
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily;--dry-run 只打印)
|
||||||
@@ -221,9 +238,9 @@ python pc/evaluate.py
|
|||||||
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`。
|
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`。
|
||||||
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
|
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
|
||||||
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
||||||
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`web/serve_relations.py` 无需改;`web/export_relations_site.py` 的导出文件列表须含 `router.js`。
|
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`(Cloudflare)与 `web/serve_relations.py` 的 SPA fallback(含 `players`);预渲染路径变更时同步 `web/seo_prerender.py` 与导出文件列表(含 `router.js` / `_redirects` / `robots.txt`)。
|
||||||
- 改 `shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles` 供 `pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
|
- 改 `shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` / `item_suggest` 缺口与应对装共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
|
||||||
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py` 的 `SITE_VERSION`、`web/frontend/config.js` 的同名变量,以及 `index.html` 里 `style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js` 的 `?v=` 缓存戳;并写 `CHANGELOG.md`。
|
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py` 的 `SITE_VERSION`、`web/frontend/config.js` 的同名变量,以及 `index.html` 里 `style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js` 的 `?v=` 缓存戳;并写 `CHANGELOG.md`。`SITE_VERSION` 语义:末位 = 增量 UI/修复;中段 = 壳层 / 路由 / 可索引或其它阶段性能力成型(如 `0.5.x` → `0.6.0`);数据-only 刷新不 bump。
|
||||||
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
||||||
|
|
||||||
## 文档分工
|
## 文档分工
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove
|
|||||||
climperor/
|
climperor/
|
||||||
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV)
|
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV)
|
||||||
│ ├── common.py / recognize.py / draft_session.py / gsi_watch.py
|
│ ├── common.py / recognize.py / draft_session.py / gsi_watch.py
|
||||||
│ ├── recommend.py / draft_archetypes.py / roles.py / modes.py / overlay.py
|
│ ├── recommend.py / item_suggest.py / draft_archetypes.py / roles.py / modes.py / overlay.py
|
||||||
│ ├── config.json / templates/ / assets/role_icons/ / samples/
|
│ ├── config.json / templates/ / assets/role_icons/ / samples/
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── web/ # 上分帝 Web(前端、数据流水线、部署)
|
├── web/ # 上分帝 Web(前端、数据流水线、部署)
|
||||||
@@ -200,11 +200,12 @@ climperor/
|
|||||||
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
|
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
|
||||||
| `recommend.relations_path` | 定性关系文件 | `shared/data/relations.json` |
|
| `recommend.relations_path` | 定性关系文件 | `shared/data/relations.json` |
|
||||||
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json |
|
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json |
|
||||||
| `overlay.enabled` | 选将顶栏角色标签悬浮层 | true |
|
| `recommend.items.*` | 锁定后核心装+应对装(`hero_items` + 定性规则) | 见 config.json |
|
||||||
| `overlay.y_gap_rel` / `icon_h_rel` / `icon_gap_rel` | 标签相对屏幕高的间距与尺寸 | 0.008 / 0.016 / 0.002 |
|
| `overlay.enabled` | 选将网格「克/搭/补」+ 分析条 + 装备图标条 | true |
|
||||||
| `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
|
| `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
|
||||||
| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
|
| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
|
||||||
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
|
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
|
||||||
|
| `overlay.items_*` / `item_*` | 锁定后装备图标条位置/尺寸/底色 | 见 config.json |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,13 +241,14 @@ Dota 2 (-gamestateintegration)
|
|||||||
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
|
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
|
||||||
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
|
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
|
||||||
- **上分帝 Web**:`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。
|
- **上分帝 Web**:`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。
|
||||||
另有顶级 **排行**、**走势**(`#/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json`。
|
另有顶级 **排行**、**走势**(`/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json`。
|
||||||
英雄详情子标签含 **走势**(见上;`#/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`#/heroes/<key>/matchups`)、**近期比赛**(`#/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
|
英雄详情子标签含 **走势**(见上;`/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`/heroes/<key>/matchups`)、**近期比赛**(`/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
|
||||||
- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
- **Web History 路由 + SEO 预渲染**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
||||||
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe`、`#/heroes/axe/trends` 等)。选 Hash
|
标签筛选 / 搜索)双向同步进路径 URL(`/heroes/axe/core?tags=核心&q=axe`、`/heroes/axe/trends` 等)。
|
||||||
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`web/serve_relations.py` 零改动、
|
旧 `#/...` 书签在装载时 `replaceState` 迁到路径。写 URL 用 `history.pushState`/`replaceState`
|
||||||
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
|
(静默,不触发 `popstate`);前进/后退靠 `popstate`。Cloudflare `_redirects` 与
|
||||||
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
|
`serve_relations.py` 对未知深度路径回退 `index.html`。导出(`seo_prerender.py`)为首页、
|
||||||
|
顶层页、全英雄与机制效果写可抓取 HTML + `sitemap.xml` / `llms.txt`。
|
||||||
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
|
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
|
||||||
`web/frontend/router.js`,`app.js` 只在 `main()` 装 `installRouter` + 各 state 变更点
|
`web/frontend/router.js`,`app.js` 只在 `main()` 装 `installRouter` + 各 state 变更点
|
||||||
调 `syncStateToUrl`。
|
调 `syncStateToUrl`。
|
||||||
|
|||||||
@@ -4,6 +4,435 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
||||||
|
- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。
|
||||||
|
|
||||||
|
## [0.6.57] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 物品中文简称:`item_alias_overrides.json`(斯嘉蒂之眼→冰眼,怪蛇之息→蛇矛);物品页可搜简称,悬停/详情显示。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 小小「怕的装备」:手工改为含怪蛇之息(`hydras_breath`),去掉清莲宝珠。
|
||||||
|
|
||||||
|
## [0.6.56] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 生产「比赛详情」:补齐 `POST /api/players/ensure-match`(此前落入动态路由仅支持 GET → HTTP 405);详情规范化写入 R2,与本机 `player_pages` 同形。
|
||||||
|
|
||||||
|
## [0.6.55] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家同步:OpenDota 空/残缺响应不再覆盖已有近 20 场、常用英雄、队友与段位。
|
||||||
|
- 样式:`style.css` 缓存改为 60s,并 bump 版本戳,避免同 `?v=` 内容变更仍命中旧 CSS 导致「我」页双栏错乱。
|
||||||
|
- 补齐 OSS `ui-icon`(含 wordmark),避免顶栏 Logo 404。
|
||||||
|
|
||||||
|
## [0.6.54] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」/玩家页:有战绩时不再因 TTL `stale` 轮询十余秒或闪「加载中…」;仅空档案才同步等待。
|
||||||
|
|
||||||
|
## [0.6.53] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 近期比赛:去掉行中弹性空隙;时长/相对时间与胜负/#ID 收成右侧紧簇,避免宽屏悬空。
|
||||||
|
|
||||||
|
## [0.6.52] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 「我」/玩家页:缓存优先——首次可慢,约 10 分钟 TTL 内后续直出(生产 D1 + Queue;本机 `profile.json` + 后台 enrich);前端有新鲜数据时不再强制全量重拉。
|
||||||
|
|
||||||
|
## [0.6.51] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 近期比赛:弹性空白改到时长与相对时间之间,避免两者挤在一起、右侧大片空。
|
||||||
|
|
||||||
|
## [0.6.50] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 十人详情:返回按钮移到比赛摘要卡上方,改为 36px 描边控件 + SVG 左箭头 +「返回玩家主页」。
|
||||||
|
|
||||||
|
## [0.6.49] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 十人详情:昵称列封顶约 16rem,中间剩余宽度四等分给参战/伤害/经济/KDA,避免昵称挤占指标空间。
|
||||||
|
|
||||||
|
## [0.6.48] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 十人详情:去掉独立「返回主页」描边按钮,改为摘要头右侧文字链「← 玩家主页」。
|
||||||
|
|
||||||
|
## [0.6.47] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 十人详情:参战/伤害/经济/KDA 作为居中指标组,不再贴靠右侧装备列。
|
||||||
|
|
||||||
|
## [0.6.46] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家页分析区标题「常用队友」改为「队友」。
|
||||||
|
|
||||||
|
## [0.6.45] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 近期比赛:时长与相对时间改为水平并排(如 `41:54 · 3小时前`),并拉开与英雄列、胜负列的间距。
|
||||||
|
|
||||||
|
## [0.6.44] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家身份条段位勋章放大到 64px(对齐 56px 头像的光学尺寸;OpenDota 图标含画布留白)。
|
||||||
|
|
||||||
|
## [0.6.43] - 2026-07-31
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 「我」/玩家页摘要区去掉近 20 场英雄肖像条(胜负已由下方近期比赛列表表达)。
|
||||||
|
|
||||||
|
## [0.6.42] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家仪表盘恢复真实 1200px 数据主列;900–1200px 保持五列摘要和十人详情横向比较,近 20 场英雄条改为无滚动条双排网格,180 天活动改为按周排列并补强度图例。
|
||||||
|
- 十人详情增加共享列头,胜负统一使用绿/红语义色;组队/MVP 徽章移至昵称次行,避免挤压长昵称。
|
||||||
|
|
||||||
|
## [0.6.41] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 「我」/玩家页改为紧凑数据仪表盘:身份条 + 生涯/近 N 场并排摘要 + 分析双栏(常用英雄 / 180 天与队友)+ 固定列近期比赛;十人详情摘要头与昵称/指标/装备列对齐统一。
|
||||||
|
|
||||||
|
## [0.6.40] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长/时间与英雄/KDA 收成左侧紧凑双列表格(上:英雄+时长,下:KDA+相对时间),胜负靠右,消除中间悬空。
|
||||||
|
|
||||||
|
## [0.6.39] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长/时间与英雄名/KDA、胜负/#ID 改为统一双行网格,同行同字号对齐。
|
||||||
|
|
||||||
|
## [0.6.38] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 比赛十人详情:加宽昵称列,空间足够时尽量完整显示玩家名(组队/MVP 徽章不抢缩名称)。
|
||||||
|
|
||||||
|
## [0.6.37] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家段位改为勋章图标 + 星级叠层(如传奇四),不再并排显示「传奇4」文字;冠绝榜位仍显示 `#N`。
|
||||||
|
|
||||||
|
## [0.6.36] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长与相对时间改为双行列(上时长、下时间),与英雄/胜负对齐,紧跟英雄信息。
|
||||||
|
|
||||||
|
## [0.6.35] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长与相对时间贴近英雄信息,不再挤在右侧。
|
||||||
|
|
||||||
|
## [0.6.34] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长与相对时间同列垂直居中对齐;「胜利」/`失败` 用绿/红字;身份条去掉「本机/未公开」。
|
||||||
|
|
||||||
|
## [0.6.33] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 「我」主页丰富:生涯场次/胜率/KDA、近 20 场摘要与英雄条、常用英雄、180 天活动热力图与样本最高、公开常用队友;空数据不显示伪 0%。
|
||||||
|
- 生产多用户数据层:Cloudflare D1 `climperor-users` + Queue `climperor-player-sync` + Worker 异步同步;Pages 已绑定 `DB` / `SYNC_QUEUE`;`GET /api/players/me` 读库并排队刷新。R2 比赛详情桶待 Dashboard 启用后接入。
|
||||||
|
- 本机 enrich 同步写入 `career` / `recent_20` / `top_heroes` / `activity_180` / `peers` / `availability`。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `/home` 点击近期比赛可进入详情(此前被 `page !== players` 早退)。
|
||||||
|
|
||||||
|
## [0.6.32] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 顶栏登录后 Tab「我的主页」改为「我」。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家主页补拉 OpenDota 段位(`rank_tier`)与昵称;「我」页展示勋章。近期比赛仍依赖「公开比赛数据」或本机 GSI。
|
||||||
|
|
||||||
|
## [0.6.31] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Steam 登录:顶栏「Steam 登录」;登录后显示头像/昵称与「退出」,并在「英雄」前出现「我」(`/home`,复用玩家战绩页看本人近 2 天比赛)。本机与 Pages 需配置 `STEAM_API_KEY` + `SESSION_SECRET`。
|
||||||
|
|
||||||
|
## [0.6.30] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家主页:补拉 API 不可用时不再闪「补拉跳过」文案;已有列表则静默失败。
|
||||||
|
|
||||||
|
## [0.6.29] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情主行显示 Steam 昵称(有公开 ID 时补拉 OpenDota;本人可从 GSI 取名);无昵称显示「匿名」/「玩家 {id}」,不再用英雄名冒充玩家名。
|
||||||
|
|
||||||
|
## [0.6.28] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家页:本场有公开 `account_id` 的昵称可点进 `/players/{id}`;进入主页补拉近 2 天比赛列表(OpenDota + 本机 GSI 目录);点进某场再懒加载十人详情。
|
||||||
|
|
||||||
|
## [0.6.27] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情参战/伤害/经济/KDA 强制同一行显示。
|
||||||
|
|
||||||
|
## [0.6.26] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情个人经济改为完整数值(如 `25,903`),不再用「万」缩写。
|
||||||
|
|
||||||
|
## [0.6.25] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家比赛详情:每人显示个人经济(净身价);OpenDota 有 `party_id` 且 ≥2 人同组时标注「组A/B…」。
|
||||||
|
|
||||||
|
## [0.6.24] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情:OpenDota 隐去 `account_id` 时仍按场内槽位标注 MVP(此前会整场不显示)。
|
||||||
|
|
||||||
|
## [0.6.23] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情:参战/伤害/KDA 居中于英雄与装备之间。
|
||||||
|
|
||||||
|
## [0.6.22] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情:去掉易混淆的「比」列,KDA 改为 `5/14/15(1.4)`;指标紧贴英雄,空白留给装备前。
|
||||||
|
|
||||||
|
## [0.6.21] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家比赛详情与近期列表显示开赛时间(相对时间,悬停看完整时间)。
|
||||||
|
|
||||||
|
## [0.6.20] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情:收紧「参战/伤害/KDA/比」列宽并加大与装备区间距,避免「比」与物品图标重叠。
|
||||||
|
|
||||||
|
## [0.6.19] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情:装备位固定补齐 6 格并锁定列宽,避免物品数量不同把参战/伤害/KDA 整列挤歪。
|
||||||
|
|
||||||
|
## [0.6.18] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情十人战绩:参战 / 伤害 / KDA / 比改为固定列宽网格对齐,避免数字长短导致错位。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web 每日刷新明星比赛改为按 `fetched_at` 轮换(默认每次 15 人),遇 OpenDota 连续 429 熔断并保留旧缓存;`hero_stats` / `hero_matches` 限流时 soft-fail 保留缓存,避免整档 daily 被拖死。
|
||||||
|
- 上分帝 Web daily:OpenDota 先单次探测,429/失败则跳过 stats/matches/pro;`hero_stats` 遇 429 不再长退避;部署前拒绝空壳 `hero_stats` / STRATZ 覆盖生产。`fetch_patches` 图标下载 import 修复,可正常入库新版本(含 7.41e)。
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。
|
||||||
|
|
||||||
|
## [0.6.17] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 版本页宽屏「AI 解读」侧栏随可用右侧空隙加宽(约 280–420px),窄于 1440px 仍上下堆叠。
|
||||||
|
|
||||||
|
## [0.6.16] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PC 赛后玩家页:进入 `POST_GAME` 后异步拉取 OpenDota,本机生成 `/players/{account_id}` 主页与 `/players/{account_id}/{match_id}` 十人战绩详情(参战/伤害/KDA/装备/MVP);默认私有(`pc/player_pages/`),`player_pages.public_share=true` 时经 `/api/players/publish` 同步到 OSS 公开。
|
||||||
|
|
||||||
|
## [0.6.15] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 物品详情下方展示近一年该物品的版本改动时间线(与英雄「改动」同源 `patches` 数据;无改动则不显示)。
|
||||||
|
|
||||||
|
## [0.6.14] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 版本页侧栏标题改为「AI 解读」,去掉「非官方 · 社区摘要」副标。
|
||||||
|
|
||||||
|
## [0.6.13] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页「AI 解读」:`max-height` 按顶栏留白(`100dvh - 9rem`),sticky 面板不再伸出视口,主栏未滚到底也能独立滚完解读全文。
|
||||||
|
|
||||||
|
## [0.6.12] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页技能图标:补拉补丁引用的 CDN 图标;先天无 CDN 图时用 `innate.png`;加载失败不再留空框(回退先天徽章或移除 img)。
|
||||||
|
|
||||||
|
## [0.6.11] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页:正文单独居中,解读贴在主列右侧;解读侧栏视口内可滚动看完全文。
|
||||||
|
|
||||||
|
## [0.6.10] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 上分帝 Web 版本页:最新版右侧「版本解读」侧栏(手写静态摘要 `patch_summaries.json`,非官方);入库 **7.41e** 补丁详情与解读。
|
||||||
|
|
||||||
|
## [0.6.9] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 恢复 OSS 英雄横版头像(曾被 96×96 匹配模板误覆盖);静态导出/上传不再回退到 `pc/templates/cdn`,缺图或非宽图会直接失败;头像 URL 缓存戳改为 `v=wide2`。
|
||||||
|
|
||||||
|
## [0.6.8] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 主播:Emo(火女教学)、奶绿(琼英碧灵 / dota2不落)。
|
||||||
|
- 琼英碧灵别名「奶绿」。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 物品页商店网格在视口内居中,详情栏落在右侧空隙,不再把整块商店挤偏。
|
||||||
|
|
||||||
|
## [0.6.7] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 比赛页主列由 1680px 收至 1200px,并按居中布局预留右侧筛选栏宽度,避免选手列表被裁切。
|
||||||
|
|
||||||
|
## [0.6.6] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情抽屉改为按 `#columns` 实测高度预留完整网格(含最后一排),剩余给抽屉;技能 Tab 不硬性规定高度,内容随抽屉 flex/滚动。
|
||||||
|
- 技能演示视频在抽屉变矮时宽度随高度收缩,保持 16:9,避免左右黑边。
|
||||||
|
|
||||||
|
## [0.6.5] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情抽屉按视口高度计算:预留顶栏、约 24% 英雄网格与定位标签,上限 640px(原 `88dvh`/720 几乎遮满网格)。
|
||||||
|
|
||||||
|
## [0.6.4] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 比赛卡片头部显示比赛 ID(顶层「比赛」页与英雄详情「近期比赛」Tab 共用)。
|
||||||
|
|
||||||
|
## [0.6.3] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情改为自底部上滑抽屉:高度展开顶起定位标签(标签不挂在面板上);点击空白或下滑可关闭;去掉标签行全宽底与打开时的网格遮罩条。
|
||||||
|
- 比赛页去掉筛选侧栏重复的「共 X 场 · 第 X/X 页」(仅保留列表底部分页区统计)。
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 英雄页底部居中显示站点版本号与数据更新时间(取各数据集 `fetched_at` 最新值;打开详情时隐藏)。
|
||||||
|
- 点击定位标签上方空白(英雄网格空隙或工具栏空白)可关闭详情面板。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 版本页底部不再显示站点版本号(改由英雄页底部展示)。
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 英雄详情标题下显示英文名与缩写(如 `Anti-Mage · AM`;无缩写时仅英文名)。
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 站点版本升至 **0.6.0**:汇总 `0.5.x` 已成型能力——History 路径路由与 SEO/GEO 预渲染、设计令牌与顶栏壳、英雄页定位/详情布局、主播多平台与直播探测纠偏、monorepo 与刷新部署可靠性。此后增量 UI/文案仍抬末位;壳层、路由或可索引能力等阶段性成型再抬中段。
|
||||||
|
- 顶栏品牌「上分帝」改用思源宋体(Noto Serif SC)子集,气质更贴「帝」;正文仍为系统无衬线。
|
||||||
|
|
||||||
|
## [0.5.115] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 上分帝 Web SEO / GEO:History 路径路由(兼容旧 `#/...` 书签)、首页 meta/OG/JSON-LD、路由态动态 `title`/`description`、爬虫跳过移动端门禁。
|
||||||
|
- 导出预渲染:各英雄 `/heroes/{key}`、机制 `/mechanics/{effect}` 与顶层页可抓取 HTML;生成 `sitemap.xml` / `robots.txt` / `llms.txt`;Cloudflare `_redirects` SPA fallback;本地 `serve_relations` 同步支持深度链接刷新。
|
||||||
|
|
||||||
|
## [0.5.114] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 主播资料补全支持斗鱼:`v.douyu.com/author/<hash>`(兼容 `author-video`)拉粉丝/关注/播放/头像;仅有房间号时从直播间 HTML 解析 `up_id` 再拉作者页(失败才回退 `betard`)。斗鱼卡片展示「关注 / 播放 / 粉丝」。
|
||||||
|
- 主播页收录斗鱼「踏上征途167(狗皇)」「天残少年K9」「叶子长青K」、抖音「浙大小鱼王—dota2」与 B 站「Dota2绝中绝」;开播探测支持斗鱼(`betard`)。
|
||||||
|
|
||||||
|
## [0.5.112] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 技能详情演示视频:有横向空间时加宽(上限约 720px),按官网 16:9 原比例显示;去掉左右 `cover` 裁切与过窄的 480px 上限。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Web 刷新可靠性:前端 / `relations.json` / 英雄表 / 网格顺序变更也会触发部署;主播 `is_live` 翻转不再单独触发整站部署。
|
||||||
|
- 主播直播探测失败统一清为未开播(不再沿用旧的“直播中”兜底);`--dry-run` 不再 restore/save 缓存或写 summary;patch 因全量刷新锁冲突跳过时飞书摘要显示「已跳过」。
|
||||||
|
|
||||||
|
## [0.5.111] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 主播页排序:先按是否在播(在播靠前),再按粉丝数降序;直播状态刷新后同步重排。
|
||||||
|
|
||||||
|
## [0.5.110] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 主播直播状态:线上与本地预览均执行平台探测;探测失败或过期状态不再沿用旧的“直播中”结果,只有本轮确认开播才显示直播角标。
|
||||||
|
|
||||||
## [0.5.109] - 2026-07-30
|
## [0.5.109] - 2026-07-30
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ colors:
|
|||||||
panel-soft: "rgba(8, 12, 20, 0.55)"
|
panel-soft: "rgba(8, 12, 20, 0.55)"
|
||||||
on-accent: "#0B0D10"
|
on-accent: "#0B0D10"
|
||||||
typography:
|
typography:
|
||||||
|
brand:
|
||||||
|
fontFamily: "Noto Serif SC, Songti SC, STSong, SimSun, serif"
|
||||||
|
fontSize: 18px
|
||||||
|
fontWeight: 700
|
||||||
|
lineHeight: 1.3
|
||||||
|
letterSpacing: 0.16em
|
||||||
display:
|
display:
|
||||||
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
|
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
|
||||||
fontSize: 32px
|
fontSize: 32px
|
||||||
@@ -104,7 +110,7 @@ spacing:
|
|||||||
content-read: 820px
|
content-read: 820px
|
||||||
content-standard: 880px
|
content-standard: 880px
|
||||||
content-wide: 1080px
|
content-wide: 1080px
|
||||||
content-data: 1680px
|
content-data: 1200px
|
||||||
components:
|
components:
|
||||||
button-primary:
|
button-primary:
|
||||||
backgroundColor: "{colors.primary}"
|
backgroundColor: "{colors.primary}"
|
||||||
@@ -208,10 +214,13 @@ components:
|
|||||||
|
|
||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
系统栈:`Segoe UI` + `Microsoft YaHei UI` + `PingFang SC`。中文优先清晰,不引入展示性装饰字体。
|
系统栈:`Segoe UI` + `Microsoft YaHei UI` + `PingFang SC`。正文与控件优先清晰,不引入全站装饰字体。
|
||||||
|
|
||||||
|
品牌例外:顶栏 / 移动端门禁的「上分帝」使用 `brand`(自托管 Noto Serif SC 子集,仅 U+4E0A / U+5206 / U+5E1D),宋体气质贴合「帝」字;正文仍走系统无衬线栈。禁止把衬线栈扩到导航、页标题或表格。
|
||||||
|
|
||||||
| Token | 用途 |
|
| Token | 用途 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
|
| `brand` | 仅品牌字标「上分帝」(顶栏 + mobile-gate) |
|
||||||
| `display` | 版本号等少数展示数字(约 32px,勿再放大到 40px) |
|
| `display` | 版本号等少数展示数字(约 32px,勿再放大到 40px) |
|
||||||
| `page-title` | 各顶层页标题(排行 / 走势 / 机制 / 主播 / 物品等) |
|
| `page-title` | 各顶层页标题(排行 / 走势 / 机制 / 主播 / 物品等) |
|
||||||
| `title-lg` / `title-md` | 区段标题、英雄名、物品名 |
|
| `title-lg` / `title-md` | 区段标题、英雄名、物品名 |
|
||||||
@@ -224,8 +233,12 @@ components:
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
|
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
|
||||||
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`),
|
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度:按视口预留顶栏 + **完整** `#columns`
|
||||||
Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。
|
英雄网格实测高度 + 定位标签后取余,再 `clamp` 到约 `280–640px`
|
||||||
|
(CSS 回退 `min(640px, calc(100dvh - 26rem))`)。技能 Tab 不驱动抽屉高度,
|
||||||
|
内容在抽屉内 flex/滚动;Tab 切换不跳动;长 Tab(走势/对位等)同理在详情区内滚动。
|
||||||
|
技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%),
|
||||||
|
`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。
|
||||||
|
|
||||||
### 顶部壳(紧凑单层)
|
### 顶部壳(紧凑单层)
|
||||||
|
|
||||||
@@ -254,10 +267,10 @@ Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对
|
|||||||
| 档位 | 宽度 | 页面 |
|
| 档位 | 宽度 | 页面 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| full | 100% | 英雄网格 |
|
| full | 100% | 英雄网格 |
|
||||||
| read | `min(820px, 92%)` | 版本 |
|
| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),宽度随可用侧栏约 280–420px,sticky + 视口内自滚动 | 版本 |
|
||||||
| standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
|
| standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
|
||||||
| wide | `min(1080px, …)` | 走势 |
|
| wide | `min(1080px, …)` | 走势 |
|
||||||
| data | `min(1680px, …)` | 比赛 |
|
| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛、玩家「我」主页 |
|
||||||
|
|
||||||
间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
|
间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
|
||||||
|
|
||||||
@@ -293,9 +306,22 @@ Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对
|
|||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
|
### 玩家主页(Identity / Snapshot / Dashboard)
|
||||||
|
|
||||||
|
登录后「我」与 `/players/{id}` 共用**紧凑数据仪表盘**语言,不做小黑盒式社交 feed:
|
||||||
|
|
||||||
|
- **Identity strip**:整行;`panel-soft` + 细边框 + `radius-lg`;头像 `full` 圆形 56px;昵称 + 段位勋章同行。勋章容器约 64px(OpenDota 图标含画布留白,光学尺寸对齐头像),具体星级用 `rank_icon_*` + `rank_star_*` 叠层表达(如传奇四),不并排写「传奇4」文字;冠绝榜位可保留 `#N`。
|
||||||
|
- **Snapshot 摘要区**:生涯与近 N 场并排(窄屏堆叠);指标卡 `radius-md`、数字 `tabular-nums`、紧凑五列网格;空数据整组隐藏,禁止显示伪 0% 胜率。
|
||||||
|
- **Analysis 次级区**:宽屏双栏——左常用英雄列表,右 180 天活动 + 队友;`panel-soft` 分块,避免每块独占整页高度。
|
||||||
|
- **近期比赛列表**:左英雄/KDA(弹性占满),右「时长/相对时间」与「胜负/#ID」两列双行紧簇右对齐(上:时长·胜负,下:相对时间·#ID);禁止再插 `1fr` 空隙把时长悬在行中;胜负语义色 + 左侧 3px 细边;整行可点。
|
||||||
|
- **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。
|
||||||
|
- **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。
|
||||||
|
- 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。
|
||||||
|
- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。Cloudflare Worker 在 OpenDota 空/残缺时不得覆盖已有近场/英雄/队友/段位。
|
||||||
|
|
||||||
### 导航
|
### 导航
|
||||||
|
|
||||||
- **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。
|
- **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。登录后「我」出现在「英雄」前。
|
||||||
- **Detail tabs**:下划线式,字号 `body-md`。
|
- **Detail tabs**:下划线式,字号 `body-md`。
|
||||||
- **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。
|
- **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。
|
||||||
- **顶栏邮件**:40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。
|
- **顶栏邮件**:40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。
|
||||||
@@ -338,4 +364,4 @@ Valve 图标。支持多选筛选(`active`);选中英雄时,其定位以
|
|||||||
- Don't 把直播粉、胜负红、金价黄当作通用 CTA。
|
- Don't 把直播粉、胜负红、金价黄当作通用 CTA。
|
||||||
- Don't 为真移动端在现有碎片 `@media` 上硬撑;门禁解除前保持桌面壳。
|
- Don't 为真移动端在现有碎片 `@media` 上硬撑;门禁解除前保持桌面壳。
|
||||||
- Do 保持英雄底栏固定高与列表页整板滚动两种壳模型,勿发明第三种。
|
- Do 保持英雄底栏固定高与列表页整板滚动两种壳模型,勿发明第三种。
|
||||||
- Don't 在 data-only 刷新里 bump `SITE_VERSION`;视觉发版才同步 config / export / `?v=` 缓存戳。
|
- Don't 在 data-only 刷新里 bump `SITE_VERSION`;视觉发版才同步 config / export / `?v=` 缓存戳。末位用于增量改动;中段留给壳层 / 路由 / 可索引等阶段性成型(见 `AGENTS.md`)。
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ python web/fetch_streamer_live.py # 探测真实在播状态 → is_live/live_p
|
|||||||
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
|
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
|
||||||
```
|
```
|
||||||
|
|
||||||
Web 站点带 **Hash 路由**:`#/heroes/axe/core`、`#/heroes/axe/trends`、`#/heroes/axe/matchups`、`#/heroes/axe/matches`、`#/trends/legend`、`#/rankings`、`#/matches`(明星比赛,可 `#/matches/898754153`、`#/matches?origin=china`、`#/matches?page=2`)、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。
|
Web 站点带 **History 路径路由**:`/heroes/axe/core`、`/heroes/axe/trends`、`/heroes/axe/matchups`、`/heroes/axe/matches`、`/trends/legend`、`/rankings`、`/matches`(明星比赛,可 `/matches/898754153`、`/matches?origin=china`、`/matches?page=2`)、`/items/black_king_bar`、`/patches/7.41`(`/patches` = 最新,`/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。旧 `#/...` 书签会自动迁移到路径。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。导出时预渲染英雄/机制等页并生成 `sitemap.xml`(SEO);Cloudflare Pages 用 `_redirects` 做 SPA fallback。
|
||||||
|
|
||||||
### 部署到 Cloudflare Pages
|
### 部署到 Cloudflare Pages
|
||||||
|
|
||||||
@@ -117,9 +117,9 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
|
|||||||
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
||||||
|
|
||||||
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json`(`python web/fetch_leaderboards.py`)。
|
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json`(`python web/fetch_leaderboards.py`)。
|
||||||
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新,缓存 5 分钟合并请求;`data.json` 内 `fetch_streamer_live.py` daily 探测值兜底);数据来自 `web/data/streamers.json`(`python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`(gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`。
|
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);列表先按是否在播、再按粉丝数降序;抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json`(`python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`(gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`。
|
||||||
顶部另有 **比赛** 页(`#/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。
|
顶部另有 **比赛** 页(`/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。
|
||||||
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`;STRATZ 周胜率数据)。
|
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`/trends[/bracket]`;STRATZ 周胜率数据)。
|
||||||
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json`(`python web/fetch_item_shop.py`)。
|
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json`(`python web/fetch_item_shop.py`)。
|
||||||
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json`(`python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
|
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json`(`python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
|
||||||
数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ(`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 仅用 `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py` 与 `web/item_fears.py`。**尚无对局内出装推荐**。
|
数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ(`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 仅用 `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py` 与 `web/item_fears.py`。**尚无对局内出装推荐**。
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
"min_iou": 0.55
|
"min_iou": 0.55
|
||||||
},
|
},
|
||||||
"recommend": {
|
"recommend": {
|
||||||
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). top_n<=0 = no cap. No AI.",
|
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). After self-lock: core+answer items. top_n<=0 = no cap. No AI.",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"top_n": 0,
|
"top_n": 0,
|
||||||
"min_enemies": 1,
|
"min_enemies": 1,
|
||||||
@@ -115,6 +115,15 @@
|
|||||||
"3": ["Initiator", "Durable", "Carry"],
|
"3": ["Initiator", "Durable", "Carry"],
|
||||||
"4": ["Support"],
|
"4": ["Support"],
|
||||||
"5": ["Support"]
|
"5": ["Support"]
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"comment": "After self hero locked: core from hero_items + qualitative answers vs enemies. No fears/STRATZ stats.",
|
||||||
|
"enabled": true,
|
||||||
|
"hero_items_path": "web/data/hero_items.json",
|
||||||
|
"items_meta_path": "web/data/items_meta.json",
|
||||||
|
"core_n": 3,
|
||||||
|
"answer_n": 3,
|
||||||
|
"max_total": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gsi": {
|
"gsi": {
|
||||||
@@ -137,12 +146,22 @@
|
|||||||
"target_slots": 10,
|
"target_slots": 10,
|
||||||
"dump_payloads": true
|
"dump_payloads": true
|
||||||
},
|
},
|
||||||
"overlay": {
|
"player_pages": {
|
||||||
"comment": "Role tags under top-bar + 克/搭/补 marks + lineup analysis banner.",
|
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true POSTs /api/players/publish (queue→D1/R2). recent_limit=recent N matches; recent_days=GSI discovery window; enrich_ttl_seconds=web cache TTL (default 600, same as Pages). Default private.",
|
||||||
|
"enabled": true,
|
||||||
|
"public_share": false,
|
||||||
|
"recent_limit": 20,
|
||||||
|
"recent_days": 14,
|
||||||
|
"enrich_ttl_seconds": 600,
|
||||||
|
"poll_attempts": 12,
|
||||||
|
"poll_base_seconds": 30,
|
||||||
|
"publish_url": "https://dota2.refining.dev/api/players/publish",
|
||||||
|
"publish_secret": "",
|
||||||
|
"local_web_origin": "http://127.0.0.1:8765"
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"comment": "克/搭/补 marks + lineup analysis banner + post-lock item icon bar (no role tags under top-bar).",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"y_gap_rel": 0.008,
|
|
||||||
"icon_h_rel": 0.016,
|
|
||||||
"icon_gap_rel": 0.002,
|
|
||||||
"mark_size_rel": 0.018,
|
"mark_size_rel": 0.018,
|
||||||
"mark_pad_rel": 0.004,
|
"mark_pad_rel": 0.004,
|
||||||
"mark_gap_rel": 0.002,
|
"mark_gap_rel": 0.002,
|
||||||
@@ -154,7 +173,13 @@
|
|||||||
"analysis_h_rel": 0.028,
|
"analysis_h_rel": 0.028,
|
||||||
"analysis_font_rel": 0.014,
|
"analysis_font_rel": 0.014,
|
||||||
"analysis_bg": "#1a2332",
|
"analysis_bg": "#1a2332",
|
||||||
"analysis_fg": "#e8eef7"
|
"analysis_fg": "#e8eef7",
|
||||||
|
"items_y_rel": 0.16,
|
||||||
|
"item_icon_h_rel": 0.036,
|
||||||
|
"item_gap_rel": 0.01,
|
||||||
|
"item_core_bg": "#1a2332",
|
||||||
|
"item_answer_bg": "#2a1f14",
|
||||||
|
"item_reason_fg": "#e8eef7"
|
||||||
},
|
},
|
||||||
"calibrated_from": "draft_141704.png"
|
"calibrated_from": "draft_141704.png"
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,7 @@ from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_fram
|
|||||||
from shared.grid import bans, hero_table, read_grid
|
from shared.grid import bans, hero_table, read_grid
|
||||||
from modes import detect_mode, load_mode_templates
|
from modes import detect_mode, load_mode_templates
|
||||||
from recognize import recognize_image
|
from recognize import recognize_image
|
||||||
|
from item_suggest import suggest_items
|
||||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
||||||
from roles import ROLES, detect_roles, load_role_templates
|
from roles import ROLES, detect_roles, load_role_templates
|
||||||
|
|
||||||
@@ -95,10 +96,18 @@ class DraftSession:
|
|||||||
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
||||||
self.recommend_role_tags = rec.get("role_tags")
|
self.recommend_role_tags = rec.get("role_tags")
|
||||||
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
|
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
|
||||||
|
items_cfg = rec.get("items") or {}
|
||||||
|
self.recommend_items_enabled = bool(items_cfg.get("enabled", True))
|
||||||
|
self.recommend_items_core_n = int(items_cfg.get("core_n", 3))
|
||||||
|
self.recommend_items_answer_n = int(items_cfg.get("answer_n", 3))
|
||||||
|
self.recommend_items_max_total = int(items_cfg.get("max_total", 6))
|
||||||
|
self.recommend_hero_items_path = items_cfg.get("hero_items_path")
|
||||||
|
self.recommend_items_meta_path = items_cfg.get("items_meta_path")
|
||||||
self._rec_warned = False
|
self._rec_warned = False
|
||||||
self._last_rec_sig: tuple | None = None
|
self._last_rec_sig: tuple | None = None
|
||||||
self._last_enemy_profile: dict = {}
|
self._last_enemy_profile: dict = {}
|
||||||
self._last_rec_meta: dict = {}
|
self._last_rec_meta: dict = {}
|
||||||
|
self._last_item_sig: tuple | None = None
|
||||||
|
|
||||||
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
def _push_overlay(self, confirmed: dict[int, str]) -> None:
|
||||||
if self.overlay is None:
|
if self.overlay is None:
|
||||||
@@ -113,6 +122,7 @@ class DraftSession:
|
|||||||
cells: dict | None,
|
cells: dict | None,
|
||||||
picks: list[dict],
|
picks: list[dict],
|
||||||
analysis: str = "",
|
analysis: str = "",
|
||||||
|
items: list[dict] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.overlay is None:
|
if self.overlay is None:
|
||||||
return
|
return
|
||||||
@@ -121,6 +131,8 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks(cells or {}, marks)
|
self.overlay.set_grid_marks(cells or {}, marks)
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis(analysis or "")
|
self.overlay.set_analysis(analysis or "")
|
||||||
|
if hasattr(self.overlay, "set_items"):
|
||||||
|
self.overlay.set_items(items if items is not None else [])
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
self.log(f"[draft] rec overlay update failed: {e}")
|
self.log(f"[draft] rec overlay update failed: {e}")
|
||||||
|
|
||||||
@@ -134,9 +146,12 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks({}, {})
|
self.overlay.set_grid_marks({}, {})
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis("")
|
self.overlay.set_analysis("")
|
||||||
|
if hasattr(self.overlay, "set_items"):
|
||||||
|
self.overlay.set_items([])
|
||||||
self.overlay.show()
|
self.overlay.show()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
self.log(f"[draft] overlay show failed: {e}")
|
self.log(f"[draft] overlay show failed: {e}")
|
||||||
|
self._last_item_sig = None
|
||||||
if self.recommend_enabled and not self._rec_warned:
|
if self.recommend_enabled and not self._rec_warned:
|
||||||
rel = self.relations or {}
|
rel = self.relations or {}
|
||||||
if not rel.get("counters") and not rel.get("synergies"):
|
if not rel.get("counters") and not rel.get("synergies"):
|
||||||
@@ -157,6 +172,7 @@ class DraftSession:
|
|||||||
"cells": None,
|
"cells": None,
|
||||||
"mode": None,
|
"mode": None,
|
||||||
"recommendations": [],
|
"recommendations": [],
|
||||||
|
"item_recommendations": [],
|
||||||
}
|
}
|
||||||
polls = 0
|
polls = 0
|
||||||
last_frame = None
|
last_frame = None
|
||||||
@@ -248,7 +264,7 @@ class DraftSession:
|
|||||||
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
||||||
timeline.append(event)
|
timeline.append(event)
|
||||||
self._log_event(event, info)
|
self._log_event(event, info)
|
||||||
if state == HERO_SELECTION:
|
# Hero marks in selection; item bar after self-lock (incl. strategy tail).
|
||||||
self._refresh_recommendations(confirmed, info, gsi_fn)
|
self._refresh_recommendations(confirmed, info, gsi_fn)
|
||||||
|
|
||||||
n = len(confirmed)
|
n = len(confirmed)
|
||||||
@@ -276,6 +292,8 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks({}, {})
|
self.overlay.set_grid_marks({}, {})
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis("")
|
self.overlay.set_analysis("")
|
||||||
|
if hasattr(self.overlay, "set_items"):
|
||||||
|
self.overlay.set_items([])
|
||||||
self.overlay.hide()
|
self.overlay.hide()
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
self.log(f"[draft] overlay hide failed: {e}")
|
self.log(f"[draft] overlay hide failed: {e}")
|
||||||
@@ -383,26 +401,70 @@ class DraftSession:
|
|||||||
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
|
||||||
f"(contrast margin {res['margin']}) - {names}")
|
f"(contrast margin {res['margin']}) - {names}")
|
||||||
|
|
||||||
|
def _refresh_item_recommendations(
|
||||||
|
self,
|
||||||
|
confirmed: dict,
|
||||||
|
info: dict,
|
||||||
|
*,
|
||||||
|
self_hero: str,
|
||||||
|
enemies: list[str],
|
||||||
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""After self-lock: clear hero marks and show core + answer items."""
|
||||||
|
analysis = (self._last_rec_meta or {}).get("analysis") or ""
|
||||||
|
if not self.recommend_items_enabled:
|
||||||
|
if info.get("recommendations") or info.get("item_recommendations") or self._last_rec_sig != ("locked",):
|
||||||
|
info["recommendations"] = []
|
||||||
|
info["item_recommendations"] = []
|
||||||
|
self._last_rec_sig = ("locked",)
|
||||||
|
self._last_item_sig = ("locked-off",)
|
||||||
|
self._push_rec_overlay(info.get("cells"), [], analysis, [])
|
||||||
|
return
|
||||||
|
sig = (self_hero, tuple(enemies))
|
||||||
|
if not force and sig == self._last_item_sig:
|
||||||
|
return
|
||||||
|
self._last_item_sig = sig
|
||||||
|
self._last_rec_sig = ("locked",)
|
||||||
|
info["recommendations"] = []
|
||||||
|
items = suggest_items(
|
||||||
|
self_hero,
|
||||||
|
enemies,
|
||||||
|
core_n=self.recommend_items_core_n,
|
||||||
|
answer_n=self.recommend_items_answer_n,
|
||||||
|
max_total=self.recommend_items_max_total,
|
||||||
|
hero_items_path=self.recommend_hero_items_path,
|
||||||
|
items_meta_path=self.recommend_items_meta_path,
|
||||||
|
)
|
||||||
|
info["item_recommendations"] = items
|
||||||
|
self._push_rec_overlay(info.get("cells"), [], analysis, items)
|
||||||
|
if items:
|
||||||
|
bits = [
|
||||||
|
f"{it.get('name_loc') or it['key']}({it.get('reason') or it.get('kind')})"
|
||||||
|
for it in items
|
||||||
|
]
|
||||||
|
self.log(
|
||||||
|
f"[rec:items] {self.hero_names.get(self_hero, self_hero)} vs "
|
||||||
|
f"{[self.hero_names.get(e, e) for e in enemies]}: {', '.join(bits)}"
|
||||||
|
)
|
||||||
|
|
||||||
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
|
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
|
||||||
if not self.recommend_enabled:
|
if not self.recommend_enabled:
|
||||||
return
|
return
|
||||||
if not self.relations and not self.recommend_archetypes:
|
|
||||||
return
|
|
||||||
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
gsi = (gsi_fn() or {}) if gsi_fn else {}
|
||||||
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
self_slot = gsi_slot(gsi) or info.get("self_slot")
|
||||||
self_team = gsi.get("team") or info.get("self_team")
|
self_team = gsi.get("team") or info.get("self_team")
|
||||||
role = info["roles"].get(self_slot) if self_slot else None
|
role = info["roles"].get(self_slot) if self_slot else None
|
||||||
position = role["position"] if role else None
|
position = role["position"] if role else None
|
||||||
# Stop suggesting once you have locked a hero.
|
self_hero = (confirmed.get(self_slot) if self_slot else None) or gsi.get("hero")
|
||||||
if self_slot and confirmed.get(self_slot):
|
|
||||||
if info.get("recommendations") or self._last_rec_sig is not None:
|
|
||||||
info["recommendations"] = []
|
|
||||||
self._last_enemy_profile = {}
|
|
||||||
self._last_rec_meta = {}
|
|
||||||
self._last_rec_sig = ("locked",)
|
|
||||||
self._push_rec_overlay(info.get("cells"), [], "")
|
|
||||||
return
|
|
||||||
enemies = enemy_keys(confirmed, self_team)
|
enemies = enemy_keys(confirmed, self_team)
|
||||||
|
# Once locked, switch from hero marks to item icon bar.
|
||||||
|
if self_slot and self_hero:
|
||||||
|
self._refresh_item_recommendations(
|
||||||
|
confirmed, info, self_hero=self_hero, enemies=enemies, force=force,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not self.relations and not self.recommend_archetypes:
|
||||||
|
return
|
||||||
allies = ally_keys(confirmed, self_team, self_slot)
|
allies = ally_keys(confirmed, self_team, self_slot)
|
||||||
exclude = set(confirmed.values())
|
exclude = set(confirmed.values())
|
||||||
if info.get("unavailable"):
|
if info.get("unavailable"):
|
||||||
@@ -411,11 +473,13 @@ class DraftSession:
|
|||||||
if not force and sig == self._last_rec_sig:
|
if not force and sig == self._last_rec_sig:
|
||||||
return
|
return
|
||||||
self._last_rec_sig = sig
|
self._last_rec_sig = sig
|
||||||
|
self._last_item_sig = None
|
||||||
if len(enemies) < self.recommend_min_enemies:
|
if len(enemies) < self.recommend_min_enemies:
|
||||||
info["recommendations"] = []
|
info["recommendations"] = []
|
||||||
|
info["item_recommendations"] = []
|
||||||
self._last_enemy_profile = {}
|
self._last_enemy_profile = {}
|
||||||
self._last_rec_meta = {}
|
self._last_rec_meta = {}
|
||||||
self._push_rec_overlay(info.get("cells"), [], "")
|
self._push_rec_overlay(info.get("cells"), [], "", [])
|
||||||
return
|
return
|
||||||
result = suggest_marks(
|
result = suggest_marks(
|
||||||
position=position,
|
position=position,
|
||||||
@@ -433,6 +497,7 @@ class DraftSession:
|
|||||||
profile = result.get("enemy_profile") or {}
|
profile = result.get("enemy_profile") or {}
|
||||||
analysis = result.get("analysis") or ""
|
analysis = result.get("analysis") or ""
|
||||||
info["recommendations"] = picks
|
info["recommendations"] = picks
|
||||||
|
info["item_recommendations"] = []
|
||||||
self._last_enemy_profile = profile
|
self._last_enemy_profile = profile
|
||||||
self._last_rec_meta = {
|
self._last_rec_meta = {
|
||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
@@ -441,7 +506,7 @@ class DraftSession:
|
|||||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||||
"ally_profile": dict(result.get("ally_profile") or {}),
|
"ally_profile": dict(result.get("ally_profile") or {}),
|
||||||
}
|
}
|
||||||
self._push_rec_overlay(info.get("cells"), picks, analysis)
|
self._push_rec_overlay(info.get("cells"), picks, analysis, [])
|
||||||
if picks or analysis:
|
if picks or analysis:
|
||||||
bits = []
|
bits = []
|
||||||
for p in picks[:12]:
|
for p in picks[:12]:
|
||||||
@@ -597,6 +662,7 @@ class DraftSession:
|
|||||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||||
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
|
||||||
"picks": info.get("recommendations") or [],
|
"picks": info.get("recommendations") or [],
|
||||||
|
"items": info.get("item_recommendations") or [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if info["unavailable"] is not None:
|
if info["unavailable"] is not None:
|
||||||
@@ -680,6 +746,13 @@ def describe(summary: dict) -> list[str]:
|
|||||||
name = p.get("name_loc") or loc(p["key"], names)
|
name = p.get("name_loc") or loc(p["key"], names)
|
||||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
||||||
|
items = rec.get("items") or []
|
||||||
|
if items:
|
||||||
|
bits = [
|
||||||
|
f"{it.get('name_loc') or it.get('key')}({it.get('reason') or it.get('kind')})"
|
||||||
|
for it in items
|
||||||
|
]
|
||||||
|
lines.append(f"items : {', '.join(bits)}")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_
|
|||||||
from common import ROOT, load_config, load_template_library
|
from common import ROOT, load_config, load_template_library
|
||||||
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
|
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
|
||||||
from overlay import DraftOverlay
|
from overlay import DraftOverlay
|
||||||
|
from player_pages import POST_GAME, schedule_post_game
|
||||||
from recognize import recognize_image
|
from recognize import recognize_image
|
||||||
from roles import detect_roles
|
from roles import detect_roles
|
||||||
|
|
||||||
@@ -77,10 +78,12 @@ class Watcher:
|
|||||||
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
|
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
|
||||||
try:
|
try:
|
||||||
self.overlay = DraftOverlay(cfg)
|
self.overlay = DraftOverlay(cfg)
|
||||||
print("[draft] role-tag overlay ready", flush=True)
|
print("[draft] overlay ready", flush=True)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
print(f"[draft] overlay disabled: {e}", flush=True)
|
print(f"[draft] overlay disabled: {e}", flush=True)
|
||||||
self.overlay = None
|
self.overlay = None
|
||||||
|
# Dedup post-game player-page jobs per match (in addition to module lock).
|
||||||
|
self._player_page_matches: set[str] = set()
|
||||||
|
|
||||||
def on_payload(self, payload: dict) -> None:
|
def on_payload(self, payload: dict) -> None:
|
||||||
if not self.connected:
|
if not self.connected:
|
||||||
@@ -123,7 +126,11 @@ class Watcher:
|
|||||||
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
|
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
|
||||||
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
|
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
|
||||||
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
|
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
|
||||||
|
prev = self.last_state
|
||||||
self.last_state = state
|
self.last_state = state
|
||||||
|
# First transition into POST_GAME → build local /players pages.
|
||||||
|
if state == POST_GAME and prev != POST_GAME:
|
||||||
|
self._maybe_schedule_player_page(match_id)
|
||||||
|
|
||||||
if state not in self.trigger_states:
|
if state not in self.trigger_states:
|
||||||
return
|
return
|
||||||
@@ -144,6 +151,18 @@ class Watcher:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"[gsi] dump failed: {e}", flush=True)
|
print(f"[gsi] dump failed: {e}", flush=True)
|
||||||
|
|
||||||
|
def _maybe_schedule_player_page(self, match_id: str) -> None:
|
||||||
|
if not match_id or match_id == "no-match":
|
||||||
|
return
|
||||||
|
if match_id in self._player_page_matches:
|
||||||
|
return
|
||||||
|
self._player_page_matches.add(match_id)
|
||||||
|
schedule_post_game(
|
||||||
|
self.cfg,
|
||||||
|
match_id=match_id,
|
||||||
|
account_id=self.self_info.get("accountid"),
|
||||||
|
)
|
||||||
|
|
||||||
def track(self, match_id: str, state: str, key: str) -> dict | None:
|
def track(self, match_id: str, state: str, key: str) -> dict | None:
|
||||||
"""Follow the draft from here to the end, recording every reveal."""
|
"""Follow the draft from here to the end, recording every reveal."""
|
||||||
if not self.busy.acquire(blocking=False):
|
if not self.busy.acquire(blocking=False):
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""Draft item suggestions: hero core builds + qualitative answers vs enemies.
|
||||||
|
|
||||||
|
Core items come from web/data/hero_items.json (relative popularity).
|
||||||
|
Answer items are rule-mapped from enemy tags / push-global archetypes.
|
||||||
|
Does not read hero_item_fears stats or STRATZ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from draft_archetypes import detect_archetypes, tag_profile
|
||||||
|
from shared.grid import hero_table
|
||||||
|
from shared.hero_tags import tags_for_hero
|
||||||
|
from shared.paths import DATA, ROOT
|
||||||
|
|
||||||
|
DEFAULT_HERO_ITEMS = DATA / "hero_items.json"
|
||||||
|
DEFAULT_ITEMS_META = DATA / "items_meta.json"
|
||||||
|
|
||||||
|
# Heroes that commonly pick from fog / invis — qualitative only.
|
||||||
|
INVIS_HEROES = frozenset({
|
||||||
|
"riki",
|
||||||
|
"bounty_hunter",
|
||||||
|
"clinkz",
|
||||||
|
"weaver",
|
||||||
|
"nyx_assassin",
|
||||||
|
"templar_assassin",
|
||||||
|
"mirana",
|
||||||
|
"treant",
|
||||||
|
"windrunner",
|
||||||
|
"slark",
|
||||||
|
"invoker",
|
||||||
|
"sand_king",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Soft heal / sustain cores — vessel answers.
|
||||||
|
HEAL_HEROES = frozenset({
|
||||||
|
"omniknight",
|
||||||
|
"winter_wyvern",
|
||||||
|
"bane",
|
||||||
|
"undying",
|
||||||
|
"abaddon",
|
||||||
|
"oracle",
|
||||||
|
"chen",
|
||||||
|
"io",
|
||||||
|
"wisp",
|
||||||
|
"dazzle",
|
||||||
|
"warlock",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fallback Chinese names when catalogs miss an entry.
|
||||||
|
_FALLBACK_NAMES = {
|
||||||
|
"black_king_bar": "黑皇杖",
|
||||||
|
"bfury": "狂战斧",
|
||||||
|
"disperser": "散魂剑",
|
||||||
|
"gem": "真视宝石",
|
||||||
|
"dust": "显影之尘",
|
||||||
|
"pipe": "洞察烟斗",
|
||||||
|
"eternal_shroud": "永世护盾",
|
||||||
|
"travel_boots": "远行鞋",
|
||||||
|
"crimson_guard": "赤红甲",
|
||||||
|
"spirit_vessel": "魂之灵瓮",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(path: str | Path | None, default: Path) -> Path:
|
||||||
|
if not path:
|
||||||
|
return default
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_absolute():
|
||||||
|
p = ROOT / p
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load_hero_items(path_str: str) -> dict:
|
||||||
|
path = Path(path_str)
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load_items_meta(path_str: str) -> dict[str, str]:
|
||||||
|
path = Path(path_str)
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
items = data.get("items") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(items, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for row in items.values():
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
key = row.get("key")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
name = row.get("name_loc") or row.get("dname") or key
|
||||||
|
out[str(key)] = str(name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _tags_by_key() -> dict[str, list[str]]:
|
||||||
|
return {
|
||||||
|
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
|
||||||
|
for h in hero_table()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _name_for(
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
catalog: dict[str, dict],
|
||||||
|
meta_names: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
row = catalog.get(key) or {}
|
||||||
|
return (
|
||||||
|
str(row.get("name_loc") or "")
|
||||||
|
or meta_names.get(key)
|
||||||
|
or _FALLBACK_NAMES.get(key)
|
||||||
|
or key
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_from_hero_items(data: dict) -> dict[str, dict]:
|
||||||
|
"""id-str -> row and also key -> row for lookups."""
|
||||||
|
items = data.get("items") or {}
|
||||||
|
by_key: dict[str, dict] = {}
|
||||||
|
for row in items.values():
|
||||||
|
if isinstance(row, dict) and row.get("key"):
|
||||||
|
by_key[str(row["key"])] = row
|
||||||
|
return by_key
|
||||||
|
|
||||||
|
|
||||||
|
def core_items_for_hero(
|
||||||
|
self_hero: str,
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
core_n: int = 3,
|
||||||
|
catalog: dict[str, dict] | None = None,
|
||||||
|
meta_names: dict[str, str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Top relative-popularity finished items for one hero."""
|
||||||
|
if not self_hero or core_n <= 0:
|
||||||
|
return []
|
||||||
|
by_hero = data.get("by_hero") or {}
|
||||||
|
rows = list(by_hero.get(self_hero) or [])
|
||||||
|
items_by_id = data.get("items") or {}
|
||||||
|
catalog = catalog if catalog is not None else _catalog_from_hero_items(data)
|
||||||
|
meta_names = meta_names or {}
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
if len(out) >= core_n:
|
||||||
|
break
|
||||||
|
iid = str(row.get("id") if isinstance(row, dict) else row)
|
||||||
|
meta = items_by_id.get(iid) or items_by_id.get(int(iid) if iid.isdigit() else iid) or {}
|
||||||
|
key = str(meta.get("key") or "")
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({
|
||||||
|
"key": key,
|
||||||
|
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
|
||||||
|
"kind": "core",
|
||||||
|
"reason": "常用",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def answer_items_for_enemies(
|
||||||
|
enemies: list[str],
|
||||||
|
*,
|
||||||
|
answer_n: int = 3,
|
||||||
|
catalog: dict[str, dict] | None = None,
|
||||||
|
meta_names: dict[str, str] | None = None,
|
||||||
|
tags_by_key: dict[str, list[str]] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Qualitative counter items from enemy tags / archetypes."""
|
||||||
|
if answer_n <= 0 or not enemies:
|
||||||
|
return []
|
||||||
|
tags_by_key = tags_by_key or _tags_by_key()
|
||||||
|
catalog = catalog or {}
|
||||||
|
meta_names = meta_names or {}
|
||||||
|
profile = tag_profile(enemies, tags_by_key)
|
||||||
|
arches = detect_archetypes(enemies, tags_by_key)
|
||||||
|
|
||||||
|
candidates: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
control_n = int(profile.get("控制") or 0) + int(profile.get("先手") or 0)
|
||||||
|
if control_n >= 2:
|
||||||
|
candidates.append(("black_king_bar", "克控制"))
|
||||||
|
|
||||||
|
if int(profile.get("幻象") or 0) >= 1 or any(
|
||||||
|
"幻象" in (tags_by_key.get(e) or []) for e in enemies
|
||||||
|
):
|
||||||
|
candidates.append(("bfury", "清幻象"))
|
||||||
|
candidates.append(("disperser", "打幻象"))
|
||||||
|
|
||||||
|
if any(e in INVIS_HEROES for e in enemies):
|
||||||
|
candidates.append(("gem", "克隐身"))
|
||||||
|
candidates.append(("dust", "显影"))
|
||||||
|
|
||||||
|
if int(profile.get("爆发") or 0) >= 2:
|
||||||
|
candidates.append(("pipe", "克魔法"))
|
||||||
|
candidates.append(("eternal_shroud", "魔抗"))
|
||||||
|
|
||||||
|
if any(e in HEAL_HEROES for e in enemies):
|
||||||
|
candidates.append(("spirit_vessel", "克回复"))
|
||||||
|
|
||||||
|
if "push" in arches:
|
||||||
|
candidates.append(("crimson_guard", "抗推进"))
|
||||||
|
candidates.append(("travel_boots", "对推进"))
|
||||||
|
if "global" in arches:
|
||||||
|
candidates.append(("travel_boots", "对全球流"))
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for key, reason in candidates:
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({
|
||||||
|
"key": key,
|
||||||
|
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
|
||||||
|
"kind": "answer",
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
if len(out) >= answer_n:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_items(
|
||||||
|
self_hero: str | None,
|
||||||
|
enemies: list[str] | None,
|
||||||
|
*,
|
||||||
|
core_n: int = 3,
|
||||||
|
answer_n: int = 3,
|
||||||
|
max_total: int = 6,
|
||||||
|
hero_items_path: str | Path | None = None,
|
||||||
|
items_meta_path: str | Path | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Merge answer items then core items; dedupe by key; soft-fail to []."""
|
||||||
|
if not self_hero or max_total <= 0:
|
||||||
|
return []
|
||||||
|
hi_path = _resolve_path(hero_items_path, DEFAULT_HERO_ITEMS)
|
||||||
|
meta_path = _resolve_path(items_meta_path, DEFAULT_ITEMS_META)
|
||||||
|
data = _load_hero_items(str(hi_path))
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
catalog = _catalog_from_hero_items(data)
|
||||||
|
meta_names = _load_items_meta(str(meta_path))
|
||||||
|
answers = answer_items_for_enemies(
|
||||||
|
list(enemies or []),
|
||||||
|
answer_n=answer_n,
|
||||||
|
catalog=catalog,
|
||||||
|
meta_names=meta_names,
|
||||||
|
)
|
||||||
|
cores = core_items_for_hero(
|
||||||
|
self_hero,
|
||||||
|
data,
|
||||||
|
core_n=core_n,
|
||||||
|
catalog=catalog,
|
||||||
|
meta_names=meta_names,
|
||||||
|
)
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in answers + cores:
|
||||||
|
key = row.get("key")
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(row)
|
||||||
|
if len(out) >= max_total:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_HERO_ITEMS",
|
||||||
|
"answer_items_for_enemies",
|
||||||
|
"core_items_for_hero",
|
||||||
|
"suggest_items",
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar.
|
"""Transparent click-through overlay: 克/搭/补 marks + analysis bar + item icons.
|
||||||
|
|
||||||
Runs a Tk root on a background thread. DraftSession calls set_roster(),
|
Runs a Tk root on a background thread. DraftSession calls set_grid_marks(),
|
||||||
set_grid_marks(), and set_analysis(); geometry uses relative coords.
|
set_analysis(), and set_items(); geometry uses relative coords.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,26 +11,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
import mss
|
import mss
|
||||||
|
|
||||||
from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px
|
from shared.paths import ITEM_ICONS
|
||||||
|
|
||||||
ROLE_ICONS_DIR = ROOT / "assets" / "role_icons"
|
|
||||||
|
|
||||||
ROLE_ORDER = [
|
|
||||||
"Carry",
|
|
||||||
"Support",
|
|
||||||
"Nuker",
|
|
||||||
"Disabler",
|
|
||||||
"Durable",
|
|
||||||
"Escape",
|
|
||||||
"Initiator",
|
|
||||||
"Pusher",
|
|
||||||
]
|
|
||||||
|
|
||||||
CHROMA = "#ff00ff"
|
CHROMA = "#ff00ff"
|
||||||
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
||||||
@@ -39,6 +25,9 @@ DEFAULT_FILL_COLOR = "#9b7ebd"
|
|||||||
DEFAULT_MARK_TEXT = "#0b1220"
|
DEFAULT_MARK_TEXT = "#0b1220"
|
||||||
DEFAULT_ANALYSIS_BG = "#1a2332"
|
DEFAULT_ANALYSIS_BG = "#1a2332"
|
||||||
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
||||||
|
DEFAULT_ITEM_CORE_BG = "#1a2332"
|
||||||
|
DEFAULT_ITEM_ANSWER_BG = "#2a1f14"
|
||||||
|
DEFAULT_ITEM_REASON_FG = "#e8eef7"
|
||||||
LABEL_ORDER = ("克", "搭", "补")
|
LABEL_ORDER = ("克", "搭", "补")
|
||||||
LABEL_COLORS = {
|
LABEL_COLORS = {
|
||||||
"克": "counter",
|
"克": "counter",
|
||||||
@@ -47,13 +36,6 @@ LABEL_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_roles_by_key() -> dict[str, list[str]]:
|
|
||||||
if not HEROES_PATH.exists():
|
|
||||||
return {}
|
|
||||||
table = json.loads(HEROES_PATH.read_text(encoding="utf-8"))
|
|
||||||
return {h["key"]: list(h.get("roles") or []) for h in table}
|
|
||||||
|
|
||||||
|
|
||||||
def _primary_monitor_size() -> tuple[int, int]:
|
def _primary_monitor_size() -> tuple[int, int]:
|
||||||
with mss.MSS() as sct:
|
with mss.MSS() as sct:
|
||||||
mon = sct.monitors[1]
|
mon = sct.monitors[1]
|
||||||
@@ -83,9 +65,6 @@ class DraftOverlay:
|
|||||||
def __init__(self, cfg: dict):
|
def __init__(self, cfg: dict):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
o = cfg.get("overlay") or {}
|
o = cfg.get("overlay") or {}
|
||||||
self.y_gap_rel = float(o.get("y_gap_rel", 0.008))
|
|
||||||
self.icon_h_rel = float(o.get("icon_h_rel", 0.016))
|
|
||||||
self.icon_gap_rel = float(o.get("icon_gap_rel", 0.002))
|
|
||||||
self.mark_size_rel = float(o.get("mark_size_rel", 0.018))
|
self.mark_size_rel = float(o.get("mark_size_rel", 0.018))
|
||||||
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
||||||
self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
|
self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
|
||||||
@@ -98,11 +77,17 @@ class DraftOverlay:
|
|||||||
self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014))
|
self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014))
|
||||||
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
||||||
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
||||||
self.roles_by_key = _load_roles_by_key()
|
self.items_y_rel = float(o.get("items_y_rel", 0.16))
|
||||||
self._roster: dict[int, str] = {}
|
self.item_icon_h_rel = float(o.get("item_icon_h_rel", 0.036))
|
||||||
|
self.item_gap_rel = float(o.get("item_gap_rel", 0.01))
|
||||||
|
self.item_core_bg = str(o.get("item_core_bg", DEFAULT_ITEM_CORE_BG))
|
||||||
|
self.item_answer_bg = str(o.get("item_answer_bg", DEFAULT_ITEM_ANSWER_BG))
|
||||||
|
self.item_reason_fg = str(o.get("item_reason_fg", DEFAULT_ITEM_REASON_FG))
|
||||||
|
self.item_icons_dir = Path(o.get("item_icons_dir") or ITEM_ICONS)
|
||||||
self._cells: dict[str, dict] = {}
|
self._cells: dict[str, dict] = {}
|
||||||
self._marks: dict[str, list[str]] = {}
|
self._marks: dict[str, list[str]] = {}
|
||||||
self._analysis = ""
|
self._analysis = ""
|
||||||
|
self._items: list[dict] = []
|
||||||
self._ready = threading.Event()
|
self._ready = threading.Event()
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._root: tk.Tk | None = None
|
self._root: tk.Tk | None = None
|
||||||
@@ -137,7 +122,6 @@ class DraftOverlay:
|
|||||||
canvas.pack(fill="both", expand=True)
|
canvas.pack(fill="both", expand=True)
|
||||||
self._canvas = canvas
|
self._canvas = canvas
|
||||||
self._screen = (sw, sh)
|
self._screen = (sw, sh)
|
||||||
self._load_icon_sources()
|
|
||||||
root.update_idletasks()
|
root.update_idletasks()
|
||||||
try:
|
try:
|
||||||
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
||||||
@@ -157,38 +141,9 @@ class DraftOverlay:
|
|||||||
pass
|
pass
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
def _load_icon_sources(self) -> None:
|
|
||||||
assert self._root is not None
|
|
||||||
for name in ROLE_ORDER:
|
|
||||||
path = ROLE_ICONS_DIR / f"{name}.png"
|
|
||||||
if not path.exists():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
self._icon_src[name] = tk.PhotoImage(master=self._root, file=str(path))
|
|
||||||
except tk.TclError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
def _scaled_icon(self, name: str, target_h: int) -> tk.PhotoImage | None:
|
|
||||||
src = self._icon_src.get(name)
|
|
||||||
if src is None or target_h <= 0:
|
|
||||||
return None
|
|
||||||
h = max(src.height(), 1)
|
|
||||||
if target_h >= h:
|
|
||||||
factor = max(1, round(target_h / h))
|
|
||||||
img = src.zoom(factor, factor)
|
|
||||||
else:
|
|
||||||
factor = max(1, round(h / target_h))
|
|
||||||
img = src.subsample(factor, factor)
|
|
||||||
self._photos.append(img)
|
|
||||||
return img
|
|
||||||
|
|
||||||
def set_roster(self, confirmed: dict[int, str]) -> None:
|
def set_roster(self, confirmed: dict[int, str]) -> None:
|
||||||
"""Update tags for confirmed slots (hero_key by slot index)."""
|
"""No-op kept for DraftSession compatibility (role tags under avatars removed)."""
|
||||||
roster = {int(k): v for k, v in confirmed.items() if v}
|
|
||||||
if roster == self._roster:
|
|
||||||
return
|
return
|
||||||
self._roster = dict(roster)
|
|
||||||
self._schedule_redraw()
|
|
||||||
|
|
||||||
def set_analysis(self, text: str | None) -> None:
|
def set_analysis(self, text: str | None) -> None:
|
||||||
"""Short lineup analysis banner (empty clears)."""
|
"""Short lineup analysis banner (empty clears)."""
|
||||||
@@ -198,6 +153,24 @@ class DraftOverlay:
|
|||||||
self._analysis = value
|
self._analysis = value
|
||||||
self._schedule_redraw()
|
self._schedule_redraw()
|
||||||
|
|
||||||
|
def set_items(self, items: list[dict] | None) -> None:
|
||||||
|
"""Item icon bar after self-lock: [{key, name_loc, kind, reason}, ...]."""
|
||||||
|
parsed: list[dict] = []
|
||||||
|
for row in items or []:
|
||||||
|
key = row.get("key")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
parsed.append({
|
||||||
|
"key": str(key),
|
||||||
|
"name_loc": str(row.get("name_loc") or key),
|
||||||
|
"kind": str(row.get("kind") or "core"),
|
||||||
|
"reason": str(row.get("reason") or ""),
|
||||||
|
})
|
||||||
|
if parsed == self._items:
|
||||||
|
return
|
||||||
|
self._items = parsed
|
||||||
|
self._schedule_redraw()
|
||||||
|
|
||||||
def set_grid_marks(
|
def set_grid_marks(
|
||||||
self,
|
self,
|
||||||
cells: dict[str, dict] | None,
|
cells: dict[str, dict] | None,
|
||||||
@@ -265,6 +238,7 @@ class DraftOverlay:
|
|||||||
self._canvas.delete("all")
|
self._canvas.delete("all")
|
||||||
self._photos.clear()
|
self._photos.clear()
|
||||||
self._analysis = ""
|
self._analysis = ""
|
||||||
|
self._items = []
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -296,6 +270,85 @@ class DraftOverlay:
|
|||||||
self._thread.join(timeout=2.0)
|
self._thread.join(timeout=2.0)
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
|
def _load_item_src(self, key: str) -> tk.PhotoImage | None:
|
||||||
|
assert self._root is not None
|
||||||
|
if key in self._icon_src:
|
||||||
|
return self._icon_src[key]
|
||||||
|
path = self.item_icons_dir / f"{key}.png"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
img = tk.PhotoImage(master=self._root, file=str(path))
|
||||||
|
except tk.TclError:
|
||||||
|
return None
|
||||||
|
self._icon_src[key] = img
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _scaled_item_icon(self, key: str, target_h: int) -> tk.PhotoImage | None:
|
||||||
|
src = self._load_item_src(key)
|
||||||
|
if src is None or target_h <= 0:
|
||||||
|
return None
|
||||||
|
h = max(src.height(), 1)
|
||||||
|
if target_h >= h:
|
||||||
|
factor = max(1, round(target_h / h))
|
||||||
|
img = src.zoom(factor, factor)
|
||||||
|
else:
|
||||||
|
factor = max(1, round(h / target_h))
|
||||||
|
img = src.subsample(factor, factor)
|
||||||
|
self._photos.append(img)
|
||||||
|
return img
|
||||||
|
|
||||||
|
def _draw_items(self, canvas: tk.Canvas, sw: int, sh: int) -> None:
|
||||||
|
if not self._items:
|
||||||
|
return
|
||||||
|
icon_h = max(20, int(round(self.item_icon_h_rel * sh)))
|
||||||
|
gap = max(4, int(round(self.item_gap_rel * sw)))
|
||||||
|
reason_font = max(9, int(round(0.011 * sh)))
|
||||||
|
pad = max(4, int(round(0.003 * sh)))
|
||||||
|
top = int(round(self.items_y_rel * sh))
|
||||||
|
|
||||||
|
cells: list[tuple[dict, tk.PhotoImage | None, int, int]] = []
|
||||||
|
for row in self._items:
|
||||||
|
img = self._scaled_item_icon(row["key"], icon_h)
|
||||||
|
iw = img.width() if img is not None else icon_h
|
||||||
|
ih = img.height() if img is not None else icon_h
|
||||||
|
cells.append((row, img, iw, ih))
|
||||||
|
|
||||||
|
cell_w = max((iw for _, _, iw, _ in cells), default=icon_h) + 2 * pad
|
||||||
|
# Extra width for short Chinese reason under icon.
|
||||||
|
cell_w = max(cell_w, reason_font * 4 + 2 * pad)
|
||||||
|
cell_h = max((ih for _, _, _, ih in cells), default=icon_h) + reason_font + 3 * pad
|
||||||
|
total_w = len(cells) * cell_w + gap * max(0, len(cells) - 1)
|
||||||
|
left = max(0, (sw - total_w) // 2)
|
||||||
|
|
||||||
|
for i, (row, img, iw, ih) in enumerate(cells):
|
||||||
|
x0 = left + i * (cell_w + gap)
|
||||||
|
y0 = top
|
||||||
|
x1 = x0 + cell_w
|
||||||
|
y1 = y0 + cell_h
|
||||||
|
bg = self.item_answer_bg if row.get("kind") == "answer" else self.item_core_bg
|
||||||
|
canvas.create_rectangle(x0, y0, x1, y1, fill=bg, outline=bg)
|
||||||
|
cx = (x0 + x1) / 2
|
||||||
|
if img is not None:
|
||||||
|
canvas.create_image(cx, y0 + pad + ih / 2, image=img, anchor="center")
|
||||||
|
else:
|
||||||
|
name = row.get("name_loc") or row["key"]
|
||||||
|
canvas.create_text(
|
||||||
|
cx,
|
||||||
|
y0 + pad + icon_h / 2,
|
||||||
|
text=name[:4],
|
||||||
|
fill=self.item_reason_fg,
|
||||||
|
font=("Microsoft YaHei UI", max(8, reason_font - 1), "bold"),
|
||||||
|
)
|
||||||
|
reason = (row.get("reason") or "").strip() or ("应对" if row.get("kind") == "answer" else "常用")
|
||||||
|
canvas.create_text(
|
||||||
|
cx,
|
||||||
|
y1 - pad - reason_font / 2,
|
||||||
|
text=reason[:6],
|
||||||
|
fill=self.item_reason_fg,
|
||||||
|
font=("Microsoft YaHei UI", reason_font),
|
||||||
|
)
|
||||||
|
|
||||||
def _redraw(self) -> None:
|
def _redraw(self) -> None:
|
||||||
canvas = self._canvas
|
canvas = self._canvas
|
||||||
if canvas is None:
|
if canvas is None:
|
||||||
@@ -303,9 +356,6 @@ class DraftOverlay:
|
|||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
self._photos.clear()
|
self._photos.clear()
|
||||||
sw, sh = self._screen
|
sw, sh = self._screen
|
||||||
icon_h = max(8, int(round(self.icon_h_rel * sh)))
|
|
||||||
gap = max(0, int(round(self.icon_gap_rel * sh)))
|
|
||||||
y_gap = int(round(self.y_gap_rel * sh))
|
|
||||||
|
|
||||||
if self._analysis:
|
if self._analysis:
|
||||||
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
||||||
@@ -327,26 +377,7 @@ class DraftOverlay:
|
|||||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for slot in self.cfg.get("slots") or []:
|
self._draw_items(canvas, sw, sh)
|
||||||
idx = int(slot["index"])
|
|
||||||
hero = self._roster.get(idx)
|
|
||||||
if not hero:
|
|
||||||
continue
|
|
||||||
roles = [r for r in ROLE_ORDER if r in set(self.roles_by_key.get(hero, []))]
|
|
||||||
if not roles:
|
|
||||||
continue
|
|
||||||
x, y, w, h = slot_rect_px(slot, self.cfg, sw, sh)
|
|
||||||
icons = [img for r in roles if (img := self._scaled_icon(r, icon_h)) is not None]
|
|
||||||
if not icons:
|
|
||||||
continue
|
|
||||||
total_w = sum(img.width() for img in icons) + gap * (len(icons) - 1)
|
|
||||||
cx = x + w / 2
|
|
||||||
left = int(round(cx - total_w / 2))
|
|
||||||
top = y + h + y_gap
|
|
||||||
cursor = left
|
|
||||||
for img in icons:
|
|
||||||
canvas.create_image(cursor, top, image=img, anchor="nw")
|
|
||||||
cursor += img.width() + gap
|
|
||||||
|
|
||||||
mark = max(12, int(round(self.mark_size_rel * sh)))
|
mark = max(12, int(round(self.mark_size_rel * sh)))
|
||||||
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""Player homepage aggregates (career / recent20 / heroes / activity / peers).
|
||||||
|
|
||||||
|
Pure helpers used by pc/player_pages.py. Not used by recommend / item_suggest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _int(v: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _float(v: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def kda(kills: int, deaths: int, assists: int) -> float:
|
||||||
|
return round((kills + assists) / max(deaths, 1), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def winrate(wins: int, losses: int) -> float | None:
|
||||||
|
total = wins + losses
|
||||||
|
if total <= 0:
|
||||||
|
return None
|
||||||
|
return round(wins / total * 1000) / 10
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_from_rows(rows: list[dict], *, limit: int | None = None) -> dict:
|
||||||
|
"""Aggregate match summary rows into a stats snapshot."""
|
||||||
|
sample = list(rows)
|
||||||
|
if limit is not None:
|
||||||
|
sample = sample[: max(0, limit)]
|
||||||
|
wins = 0
|
||||||
|
losses = 0
|
||||||
|
kills = deaths = assists = 0
|
||||||
|
gpm_sum = xpm_sum = dmg_sum = 0
|
||||||
|
gpm_n = xpm_n = dmg_n = 0
|
||||||
|
heroes: list[dict] = []
|
||||||
|
for r in sample:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
if r.get("won"):
|
||||||
|
wins += 1
|
||||||
|
else:
|
||||||
|
losses += 1
|
||||||
|
k = _int(r.get("kills"))
|
||||||
|
d = _int(r.get("deaths"))
|
||||||
|
a = _int(r.get("assists"))
|
||||||
|
kills += k
|
||||||
|
deaths += d
|
||||||
|
assists += a
|
||||||
|
if r.get("gpm") is not None:
|
||||||
|
gpm_sum += _int(r.get("gpm"))
|
||||||
|
gpm_n += 1
|
||||||
|
if r.get("xpm") is not None:
|
||||||
|
xpm_sum += _int(r.get("xpm"))
|
||||||
|
xpm_n += 1
|
||||||
|
if r.get("hero_damage") is not None:
|
||||||
|
dmg_sum += _int(r.get("hero_damage"))
|
||||||
|
dmg_n += 1
|
||||||
|
heroes.append(
|
||||||
|
{
|
||||||
|
"match_id": _int(r.get("match_id")),
|
||||||
|
"hero_id": r.get("hero_id"),
|
||||||
|
"hero_key": r.get("hero_key"),
|
||||||
|
"hero_name_loc": r.get("hero_name_loc"),
|
||||||
|
"won": bool(r.get("won")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
n = wins + losses
|
||||||
|
out = {
|
||||||
|
"sample": n,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"winrate": winrate(wins, losses),
|
||||||
|
"kills": kills,
|
||||||
|
"deaths": deaths,
|
||||||
|
"assists": assists,
|
||||||
|
"kda": kda(kills, deaths, assists) if n else None,
|
||||||
|
"avg_kills": round(kills / n, 1) if n else None,
|
||||||
|
"avg_deaths": round(deaths / n, 1) if n else None,
|
||||||
|
"avg_assists": round(assists / n, 1) if n else None,
|
||||||
|
"avg_gpm": round(gpm_sum / gpm_n) if gpm_n else None,
|
||||||
|
"avg_xpm": round(xpm_sum / xpm_n) if xpm_n else None,
|
||||||
|
"avg_hero_damage": round(dmg_sum / dmg_n) if dmg_n else None,
|
||||||
|
"heroes": heroes,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def career_from_opendota(wl: dict | None, totals: list | None) -> dict | None:
|
||||||
|
"""Build career block from OpenDota /wl + /totals. None when empty/private."""
|
||||||
|
wl = wl if isinstance(wl, dict) else {}
|
||||||
|
wins = _int(wl.get("win"))
|
||||||
|
losses = _int(wl.get("lose"))
|
||||||
|
if wins <= 0 and losses <= 0:
|
||||||
|
return None
|
||||||
|
by_field: dict[str, dict] = {}
|
||||||
|
if isinstance(totals, list):
|
||||||
|
for row in totals:
|
||||||
|
if isinstance(row, dict) and row.get("field"):
|
||||||
|
by_field[str(row["field"])] = row
|
||||||
|
def sum_of(field: str) -> int:
|
||||||
|
return _int((by_field.get(field) or {}).get("sum"))
|
||||||
|
|
||||||
|
def n_of(field: str) -> int:
|
||||||
|
return _int((by_field.get(field) or {}).get("n"))
|
||||||
|
|
||||||
|
n = wins + losses
|
||||||
|
kills = sum_of("kills")
|
||||||
|
deaths = sum_of("deaths")
|
||||||
|
assists = sum_of("assists")
|
||||||
|
gpm_n = n_of("gold_per_min")
|
||||||
|
xpm_n = n_of("xp_per_min")
|
||||||
|
dmg_n = n_of("hero_damage")
|
||||||
|
return {
|
||||||
|
"games": n,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"winrate": winrate(wins, losses),
|
||||||
|
"kills": kills,
|
||||||
|
"deaths": deaths,
|
||||||
|
"assists": assists,
|
||||||
|
"kda": kda(kills, deaths, assists) if n else None,
|
||||||
|
"avg_kills": round(kills / n, 1) if n else None,
|
||||||
|
"avg_deaths": round(deaths / n, 1) if n else None,
|
||||||
|
"avg_assists": round(assists / n, 1) if n else None,
|
||||||
|
"avg_gpm": round(sum_of("gold_per_min") / gpm_n) if gpm_n else None,
|
||||||
|
"avg_xpm": round(sum_of("xp_per_min") / xpm_n) if xpm_n else None,
|
||||||
|
"avg_hero_damage": round(sum_of("hero_damage") / dmg_n) if dmg_n else None,
|
||||||
|
"source": "opendota",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def top_heroes_from_opendota(
|
||||||
|
rows: list | None,
|
||||||
|
*,
|
||||||
|
hero_lookup: dict[int, dict],
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
scored: list[tuple[int, dict]] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
games = _int(row.get("games"))
|
||||||
|
if games <= 0:
|
||||||
|
continue
|
||||||
|
hid = _int(row.get("hero_id"))
|
||||||
|
hero = hero_lookup.get(hid) or {}
|
||||||
|
wins = _int(row.get("win"))
|
||||||
|
last = row.get("last_played")
|
||||||
|
try:
|
||||||
|
last_i = int(last) if last is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
last_i = None
|
||||||
|
scored.append(
|
||||||
|
(
|
||||||
|
games,
|
||||||
|
{
|
||||||
|
"hero_id": hid or None,
|
||||||
|
"hero_key": hero.get("key"),
|
||||||
|
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
|
||||||
|
"games": games,
|
||||||
|
"wins": wins,
|
||||||
|
"winrate": winrate(wins, max(0, games - wins)),
|
||||||
|
"last_played": last_i,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scored.sort(key=lambda t: (-t[0], -(_int(t[1].get("last_played")))))
|
||||||
|
return [item for _, item in scored[: max(1, limit)]]
|
||||||
|
|
||||||
|
|
||||||
|
def peers_from_opendota(rows: list | None, *, limit: int = 8) -> list[dict]:
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
return []
|
||||||
|
out: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
aid = _int(row.get("account_id"))
|
||||||
|
games = _int(row.get("games"))
|
||||||
|
if aid <= 0 or games <= 0:
|
||||||
|
continue
|
||||||
|
wins = _int(row.get("win"))
|
||||||
|
name = row.get("personaname")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
name = f"玩家 {aid}"
|
||||||
|
avatar = row.get("avatarfull") or row.get("avatar")
|
||||||
|
if not isinstance(avatar, str):
|
||||||
|
avatar = None
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"account_id": aid,
|
||||||
|
"personaname": name.strip(),
|
||||||
|
"avatar": avatar,
|
||||||
|
"games": games,
|
||||||
|
"wins": wins,
|
||||||
|
"winrate": winrate(wins, max(0, games - wins)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def activity_from_matches(rows: list | None, *, days: int = 180) -> dict | None:
|
||||||
|
"""Build 180-day activity heatmap + sample highs from OpenDota /matches."""
|
||||||
|
if not isinstance(rows, list) or not rows:
|
||||||
|
return None
|
||||||
|
by_day: dict[str, dict] = {}
|
||||||
|
max_kills = max_assists = max_gpm = None
|
||||||
|
wins = losses = 0
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
st = row.get("start_time")
|
||||||
|
try:
|
||||||
|
st_i = int(st) if st is not None else 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
st_i = 0
|
||||||
|
if st_i <= 0:
|
||||||
|
continue
|
||||||
|
# UTC day key YYYY-MM-DD
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
day = datetime.fromtimestamp(st_i, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
cell = by_day.setdefault(day, {"games": 0, "wins": 0})
|
||||||
|
cell["games"] += 1
|
||||||
|
player_slot = _int(row.get("player_slot"))
|
||||||
|
radiant_win = bool(row.get("radiant_win"))
|
||||||
|
won = radiant_win if player_slot < 128 else not radiant_win
|
||||||
|
if won:
|
||||||
|
cell["wins"] += 1
|
||||||
|
wins += 1
|
||||||
|
else:
|
||||||
|
losses += 1
|
||||||
|
kills = _int(row.get("kills"))
|
||||||
|
assists = _int(row.get("assists"))
|
||||||
|
gpm = _int(row.get("gold_per_min"))
|
||||||
|
hero_id = _int(row.get("hero_id")) or None
|
||||||
|
if max_kills is None or kills > max_kills["value"]:
|
||||||
|
max_kills = {"value": kills, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
|
||||||
|
if max_assists is None or assists > max_assists["value"]:
|
||||||
|
max_assists = {
|
||||||
|
"value": assists,
|
||||||
|
"hero_id": hero_id,
|
||||||
|
"match_id": _int(row.get("match_id")),
|
||||||
|
}
|
||||||
|
if gpm > 0 and (max_gpm is None or gpm > max_gpm["value"]):
|
||||||
|
max_gpm = {"value": gpm, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
|
||||||
|
days_list = [
|
||||||
|
{"date": d, "games": v["games"], "wins": v["wins"]}
|
||||||
|
for d, v in sorted(by_day.items())
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"sample": wins + losses,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"winrate": winrate(wins, losses),
|
||||||
|
"heatmap": days_list,
|
||||||
|
"highs": {
|
||||||
|
"kills": max_kills,
|
||||||
|
"assists": max_assists,
|
||||||
|
"gpm": max_gpm,
|
||||||
|
},
|
||||||
|
"label": f"最近 {days} 天样本",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_availability(
|
||||||
|
*,
|
||||||
|
opendota_recent_n: int,
|
||||||
|
career: dict | None,
|
||||||
|
steam_history_status: int | None,
|
||||||
|
fetched_at: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Describe whether match history is public / syncing / private."""
|
||||||
|
od_public = opendota_recent_n > 0 or bool(career and career.get("games"))
|
||||||
|
steam_allowed = steam_history_status in (1,) # 1 = success
|
||||||
|
steam_denied = steam_history_status == 15
|
||||||
|
if od_public:
|
||||||
|
status = "public"
|
||||||
|
complete = True
|
||||||
|
note = None
|
||||||
|
elif steam_allowed and not od_public:
|
||||||
|
status = "syncing"
|
||||||
|
complete = False
|
||||||
|
note = "Steam 已公开,OpenDota 同步中"
|
||||||
|
elif steam_denied:
|
||||||
|
status = "private"
|
||||||
|
complete = False
|
||||||
|
note = "未公开比赛数据"
|
||||||
|
else:
|
||||||
|
status = "unknown"
|
||||||
|
complete = False
|
||||||
|
note = "暂无公开战绩"
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"complete": complete,
|
||||||
|
"opendota_public": od_public,
|
||||||
|
"steam_history_status": steam_history_status,
|
||||||
|
"source": "opendota+steam",
|
||||||
|
"fetched_at": fetched_at,
|
||||||
|
"note": note,
|
||||||
|
"stale": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def should_keep_old_career(old: dict | None, new: dict | None) -> dict | None:
|
||||||
|
"""HTTP 200 empty must not wipe a previously populated career."""
|
||||||
|
if new:
|
||||||
|
return new
|
||||||
|
if old and isinstance(old, dict) and _int(old.get("games")) > 0:
|
||||||
|
return old
|
||||||
|
return new
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""TTL helpers for player homepage cache-first serving."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PC = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PC))
|
||||||
|
|
||||||
|
from player_pages import ( # noqa: E402
|
||||||
|
profile_fetched_at,
|
||||||
|
profile_has_payload,
|
||||||
|
profile_is_stale,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_ago(seconds: int) -> str:
|
||||||
|
t = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
||||||
|
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerPagesCacheTests(unittest.TestCase):
|
||||||
|
def test_has_payload_career(self):
|
||||||
|
self.assertTrue(profile_has_payload({"career": {"games": 10}}))
|
||||||
|
self.assertFalse(profile_has_payload({"career": {"games": 0}}))
|
||||||
|
|
||||||
|
def test_has_payload_recent(self):
|
||||||
|
self.assertTrue(profile_has_payload({"recent": [{"match_id": 1}]}))
|
||||||
|
self.assertFalse(profile_has_payload({"recent": []}))
|
||||||
|
|
||||||
|
def test_fetched_at_prefers_availability(self):
|
||||||
|
p = {
|
||||||
|
"availability": {"fetched_at": "2026-01-01T00:00:00Z"},
|
||||||
|
"enriched_at": "2026-01-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
self.assertEqual(profile_fetched_at(p), "2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
def test_stale_ttl(self):
|
||||||
|
fresh = {"enriched_at": _iso_ago(60)}
|
||||||
|
old = {"enriched_at": _iso_ago(1200)}
|
||||||
|
self.assertFalse(profile_is_stale(fresh, 600))
|
||||||
|
self.assertTrue(profile_is_stale(old, 600))
|
||||||
|
self.assertTrue(profile_is_stale({}, 600))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Unit tests for player homepage aggregates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PC = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PC))
|
||||||
|
|
||||||
|
from player_stats import ( # noqa: E402
|
||||||
|
aggregate_from_rows,
|
||||||
|
career_from_opendota,
|
||||||
|
kda,
|
||||||
|
merge_availability,
|
||||||
|
should_keep_old_career,
|
||||||
|
top_heroes_from_opendota,
|
||||||
|
winrate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStatsTests(unittest.TestCase):
|
||||||
|
def test_kda_zero_deaths(self):
|
||||||
|
self.assertEqual(kda(5, 0, 5), 10.0)
|
||||||
|
|
||||||
|
def test_winrate_empty(self):
|
||||||
|
self.assertIsNone(winrate(0, 0))
|
||||||
|
self.assertEqual(winrate(1, 1), 50.0)
|
||||||
|
|
||||||
|
def test_aggregate_recent(self):
|
||||||
|
rows = [
|
||||||
|
{"won": True, "kills": 10, "deaths": 2, "assists": 8, "gpm": 500, "hero_id": 1},
|
||||||
|
{"won": False, "kills": 0, "deaths": 10, "assists": 2, "gpm": 300, "hero_id": 2},
|
||||||
|
]
|
||||||
|
out = aggregate_from_rows(rows, limit=20)
|
||||||
|
self.assertEqual(out["sample"], 2)
|
||||||
|
self.assertEqual(out["wins"], 1)
|
||||||
|
self.assertEqual(out["winrate"], 50.0)
|
||||||
|
self.assertEqual(out["avg_gpm"], 400)
|
||||||
|
|
||||||
|
def test_career_empty_private(self):
|
||||||
|
self.assertIsNone(career_from_opendota({"win": 0, "lose": 0}, []))
|
||||||
|
|
||||||
|
def test_career_populated(self):
|
||||||
|
career = career_from_opendota(
|
||||||
|
{"win": 10, "lose": 10},
|
||||||
|
[
|
||||||
|
{"field": "kills", "n": 20, "sum": 100},
|
||||||
|
{"field": "deaths", "n": 20, "sum": 50},
|
||||||
|
{"field": "assists", "n": 20, "sum": 150},
|
||||||
|
{"field": "gold_per_min", "n": 20, "sum": 8000},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(career["games"], 20)
|
||||||
|
self.assertEqual(career["kda"], 5.0)
|
||||||
|
self.assertEqual(career["avg_gpm"], 400)
|
||||||
|
|
||||||
|
def test_keep_old_career_on_empty(self):
|
||||||
|
old = {"games": 100, "wins": 50, "losses": 50}
|
||||||
|
self.assertEqual(should_keep_old_career(old, None), old)
|
||||||
|
new = {"games": 101, "wins": 51, "losses": 50}
|
||||||
|
self.assertEqual(should_keep_old_career(old, new), new)
|
||||||
|
|
||||||
|
def test_top_heroes(self):
|
||||||
|
heroes = top_heroes_from_opendota(
|
||||||
|
[
|
||||||
|
{"hero_id": 1, "games": 5, "win": 3, "last_played": 100},
|
||||||
|
{"hero_id": 2, "games": 10, "win": 4, "last_played": 90},
|
||||||
|
],
|
||||||
|
hero_lookup={1: {"key": "antimage", "name_loc": "敌法"}, 2: {"key": "axe", "name_loc": "斧王"}},
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(heroes), 1)
|
||||||
|
self.assertEqual(heroes[0]["hero_key"], "axe")
|
||||||
|
|
||||||
|
def test_availability_syncing(self):
|
||||||
|
avail = merge_availability(
|
||||||
|
opendota_recent_n=0,
|
||||||
|
career=None,
|
||||||
|
steam_history_status=1,
|
||||||
|
fetched_at="t",
|
||||||
|
)
|
||||||
|
self.assertEqual(avail["status"], "syncing")
|
||||||
|
private = merge_availability(
|
||||||
|
opendota_recent_n=0,
|
||||||
|
career=None,
|
||||||
|
steam_history_status=15,
|
||||||
|
fetched_at="t",
|
||||||
|
)
|
||||||
|
self.assertEqual(private["status"], "private")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5582,7 +5582,8 @@
|
|||||||
],
|
],
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"穷逼",
|
"穷逼",
|
||||||
"琼碧"
|
"琼碧",
|
||||||
|
"奶绿"
|
||||||
],
|
],
|
||||||
"abbr": [],
|
"abbr": [],
|
||||||
"base_str": 19,
|
"base_str": 19,
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
"b": "spirit_breaker",
|
"b": "spirit_breaker",
|
||||||
"reason": ""
|
"reason": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"a": "ancient_apparition",
|
||||||
|
"b": "necrolyte",
|
||||||
|
"reason": "冰晶爆轰禁疗,克死亡脉冲续航"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"a": "antimage",
|
"a": "antimage",
|
||||||
"b": "lina",
|
"b": "lina",
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ def block_of_column(cols: list[tuple[int, int]]) -> list[int]:
|
|||||||
Blocks are separated by a visibly wider gutter than the gap between two
|
Blocks are separated by a visibly wider gutter than the gap between two
|
||||||
cards in the same block.
|
cards in the same block.
|
||||||
"""
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
||||||
if not gaps:
|
if not gaps:
|
||||||
return [0] * len(cols)
|
return [0] * len(cols)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ RELATIONS_JSON = SHARED_DATA / "relations.json"
|
|||||||
PC_DIR = ROOT / "pc"
|
PC_DIR = ROOT / "pc"
|
||||||
PC_CONFIG = PC_DIR / "config.json"
|
PC_CONFIG = PC_DIR / "config.json"
|
||||||
TEMPLATES_CDN = PC_DIR / "templates" / "cdn"
|
TEMPLATES_CDN = PC_DIR / "templates" / "cdn"
|
||||||
|
# Post-match player pages (profile + match JSON; gitignored; local/OSS).
|
||||||
|
PC_PLAYER_PAGES = PC_DIR / "player_pages"
|
||||||
|
|
||||||
# Web subproject locations. DATA keeps its historical name: every web-side
|
# Web subproject locations. DATA keeps its historical name: every web-side
|
||||||
# JSON cache lives here (hero_stats, patches, streamers, ...).
|
# JSON cache lives here (hero_stats, patches, streamers, ...).
|
||||||
@@ -36,7 +38,7 @@ HERO_PORTRAITS = WEB_ASSETS / "hero_portraits"
|
|||||||
ATTR_ICONS = WEB_ASSETS / "attr_icons"
|
ATTR_ICONS = WEB_ASSETS / "attr_icons"
|
||||||
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
|
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
|
||||||
ROLE_ICONS = WEB_ASSETS / "role_icons"
|
ROLE_ICONS = WEB_ASSETS / "role_icons"
|
||||||
# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only.
|
# Item icons from Steam CDN (dota_react/items/{key}.png); Web + PC draft overlay.
|
||||||
ITEM_ICONS = WEB_ASSETS / "item_icons"
|
ITEM_ICONS = WEB_ASSETS / "item_icons"
|
||||||
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
||||||
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
|
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""One-off: enrich a player profile (no secrets printed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "pc"))
|
||||||
|
|
||||||
|
from player_pages import enrich_profile_recent # noqa: E402
|
||||||
|
|
||||||
|
AID = int(sys.argv[1]) if len(sys.argv) > 1 else 143712136
|
||||||
|
cfg = json.loads((ROOT / "pc" / "config.json").read_text(encoding="utf-8"))
|
||||||
|
p = enrich_profile_recent(cfg, AID, include_gsi=True)
|
||||||
|
print(
|
||||||
|
"name",
|
||||||
|
p.get("personaname"),
|
||||||
|
"rank",
|
||||||
|
p.get("rank_tier"),
|
||||||
|
"lb",
|
||||||
|
p.get("leaderboard_rank"),
|
||||||
|
"recent",
|
||||||
|
len(p.get("recent") or []),
|
||||||
|
)
|
||||||
@@ -84,6 +84,51 @@ def _build_staging() -> tuple[Path, dict[str, int]]:
|
|||||||
return staging, counts
|
return staging, counts
|
||||||
|
|
||||||
|
|
||||||
|
def _png_size(path: Path) -> tuple[int, int] | None:
|
||||||
|
"""Return (width, height) for a PNG, or None if unreadable."""
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n":
|
||||||
|
return None
|
||||||
|
# IHDR: length(4) + type(4) + width(4) + height(4)
|
||||||
|
if raw[12:16] != b"IHDR":
|
||||||
|
return None
|
||||||
|
w = int.from_bytes(raw[16:20], "big")
|
||||||
|
h = int.from_bytes(raw[20:24], "big")
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_wide_portraits(staging: Path) -> None:
|
||||||
|
"""Refuse top-bar match crops (square ~96×96) before any OSS put.
|
||||||
|
|
||||||
|
Official cards are usually 256×144; Steam occasionally ships half-res
|
||||||
|
128×72 landscape (still fine for the grid). Match templates are square.
|
||||||
|
"""
|
||||||
|
portrait_dir = staging / "portrait"
|
||||||
|
if not portrait_dir.is_dir():
|
||||||
|
raise SystemExit("staging missing portrait/ — run fetch_hero_portraits.py")
|
||||||
|
bad: list[str] = []
|
||||||
|
for path in sorted(portrait_dir.glob("*.png")):
|
||||||
|
size = _png_size(path)
|
||||||
|
if size is None:
|
||||||
|
bad.append(f"{path.name}: unreadable")
|
||||||
|
continue
|
||||||
|
w, h = size
|
||||||
|
# Landscape Heroes cards: aspect ≈ 16:9. Match CDN faces are square.
|
||||||
|
if h <= 0 or w / h < 1.4:
|
||||||
|
bad.append(f"{path.name}: {w}x{h}")
|
||||||
|
if bad:
|
||||||
|
sample = "; ".join(bad[:6])
|
||||||
|
more = f" (+{len(bad) - 6} more)" if len(bad) > 6 else ""
|
||||||
|
raise SystemExit(
|
||||||
|
f"refusing to upload {len(bad)} non-wide portrait(s) "
|
||||||
|
f"(need landscape aspect ≥1.4, not match-template squares): "
|
||||||
|
f"{sample}{more}. Run: python web/fetch_hero_portraits.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _iter_files(root: Path) -> list[Path]:
|
def _iter_files(root: Path) -> list[Path]:
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
for sub in ASSET_DIRS:
|
for sub in ASSET_DIRS:
|
||||||
@@ -97,6 +142,7 @@ def _iter_files(root: Path) -> list[Path]:
|
|||||||
def upload(bucket_name: str, *, force: bool = False) -> None:
|
def upload(bucket_name: str, *, force: bool = False) -> None:
|
||||||
staging, counts = _build_staging()
|
staging, counts = _build_staging()
|
||||||
try:
|
try:
|
||||||
|
_assert_wide_portraits(staging)
|
||||||
files = _iter_files(staging)
|
files = _iter_files(staging)
|
||||||
total_bytes = sum(f.stat().st_size for f in files)
|
total_bytes = sum(f.stat().st_size for f in files)
|
||||||
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
|
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Upload web/assets/ui_icons/* to OSS ui-icon/ (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import oss2
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "web" / "assets" / "ui_icons"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_ID"
|
||||||
|
)
|
||||||
|
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_SECRET"
|
||||||
|
)
|
||||||
|
if not ak or not sk:
|
||||||
|
print("missing OSS credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
bucket = oss2.Bucket(oss2.Auth(ak, sk), "https://oss-cn-shanghai.aliyuncs.com", "climperor")
|
||||||
|
n = 0
|
||||||
|
for f in sorted(SRC.iterdir()):
|
||||||
|
if not f.is_file() or f.name.startswith("."):
|
||||||
|
continue
|
||||||
|
key = f"ui-icon/{f.name}"
|
||||||
|
ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
||||||
|
bucket.put_object_from_file(
|
||||||
|
key,
|
||||||
|
str(f),
|
||||||
|
headers={"Content-Type": ctype, "Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
print(f"uploaded {n} ui-icon files", flush=True)
|
||||||
|
for name in ("dota2_logo_wordmark.png", "dota2_logo.png"):
|
||||||
|
print(f" {name} exists={bucket.object_exists('ui-icon/' + name)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Probe OpenDota + Steam for a player (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
AID = 143712136
|
||||||
|
SID = AID + 76561197960265728
|
||||||
|
UA = "climperor-probe"
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str) -> tuple[int, object]:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode(errors="replace")[:200]
|
||||||
|
return e.code, {"error": body}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return 0, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
key = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
od_key = (os.environ.get("OPENDOTA_API_KEY") or "").strip()
|
||||||
|
print(f"steam_key={bool(key)} opendota_key={bool(od_key)}")
|
||||||
|
|
||||||
|
od = f"https://api.opendota.com/api/players/{AID}"
|
||||||
|
if od_key:
|
||||||
|
od += f"?api_key={urllib.parse.quote(od_key)}"
|
||||||
|
code, data = _get(od)
|
||||||
|
if isinstance(data, dict) and "error" not in data:
|
||||||
|
profile = data.get("profile") if isinstance(data.get("profile"), dict) else {}
|
||||||
|
print(
|
||||||
|
"opendota player",
|
||||||
|
code,
|
||||||
|
"name=",
|
||||||
|
profile.get("personaname") or data.get("personaname"),
|
||||||
|
"rank_tier=",
|
||||||
|
data.get("rank_tier"),
|
||||||
|
"lb=",
|
||||||
|
data.get("leaderboard_rank"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("opendota player", code, data)
|
||||||
|
|
||||||
|
od_r = f"https://api.opendota.com/api/players/{AID}/recentMatches"
|
||||||
|
if od_key:
|
||||||
|
od_r += f"?api_key={urllib.parse.quote(od_key)}"
|
||||||
|
code, data = _get(od_r)
|
||||||
|
if isinstance(data, list):
|
||||||
|
print("opendota recent", code, "n=", len(data))
|
||||||
|
if data:
|
||||||
|
print(" first", data[0].get("match_id"), data[0].get("start_time"))
|
||||||
|
else:
|
||||||
|
print("opendota recent", code, data)
|
||||||
|
|
||||||
|
if key:
|
||||||
|
q = urllib.parse.urlencode({"key": key, "steamids": str(SID)})
|
||||||
|
code, data = _get(
|
||||||
|
f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?{q}"
|
||||||
|
)
|
||||||
|
players = (((data or {}).get("response") or {}).get("players")) or []
|
||||||
|
if players:
|
||||||
|
p = players[0]
|
||||||
|
print(
|
||||||
|
"steam summary",
|
||||||
|
code,
|
||||||
|
"name=",
|
||||||
|
p.get("personaname"),
|
||||||
|
"loc=",
|
||||||
|
p.get("loccountrycode"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("steam summary", code, data)
|
||||||
|
|
||||||
|
q = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"account_id": AID,
|
||||||
|
"matches_requested": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
code, data = _get(
|
||||||
|
"https://api.steampowered.com/IDOTA2Match_570/GetMatchHistory/v1/?" + q
|
||||||
|
)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
result = (data.get("result") or {})
|
||||||
|
print(
|
||||||
|
"steam match_history",
|
||||||
|
code,
|
||||||
|
"status=",
|
||||||
|
result.get("status"),
|
||||||
|
"statusDetail=",
|
||||||
|
result.get("statusDetail"),
|
||||||
|
"num=",
|
||||||
|
result.get("num_results"),
|
||||||
|
"total=",
|
||||||
|
result.get("total_results"),
|
||||||
|
)
|
||||||
|
matches = result.get("matches") or []
|
||||||
|
if matches:
|
||||||
|
m0 = matches[0]
|
||||||
|
print(" first match_id", m0.get("match_id"), "start", m0.get("start_time"))
|
||||||
|
else:
|
||||||
|
print("steam match_history", code, data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Launch serve_relations with STEAM_API_KEY / SESSION_SECRET from keyzoo env.
|
||||||
|
|
||||||
|
Maps KEYZOO_ASSET_SECRET_WEB_API_KEY / KEYZOO_ASSET_SECRET_SESSION_SECRET
|
||||||
|
when the plain names are unset. Does not print secret values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _map_env() -> None:
|
||||||
|
if not (os.environ.get("STEAM_API_KEY") or "").strip():
|
||||||
|
alt = (os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
|
||||||
|
if alt:
|
||||||
|
os.environ["STEAM_API_KEY"] = alt
|
||||||
|
if not (os.environ.get("SESSION_SECRET") or "").strip():
|
||||||
|
alt = (os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
|
||||||
|
if alt:
|
||||||
|
os.environ["SESSION_SECRET"] = alt
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_map_env()
|
||||||
|
steam_ok = bool((os.environ.get("STEAM_API_KEY") or "").strip())
|
||||||
|
sess_ok = bool((os.environ.get("SESSION_SECRET") or "").strip())
|
||||||
|
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
|
||||||
|
if not steam_ok or not sess_ok:
|
||||||
|
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
cmd = [sys.executable, "-u", str(ROOT / "web" / "serve_relations.py"), *sys.argv[1:]]
|
||||||
|
return subprocess.call(cmd, cwd=str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Background-start serve_relations with keyzoo-mapped Steam auth env."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PORT = "8765"
|
||||||
|
|
||||||
|
|
||||||
|
def _map_env() -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if not (env.get("STEAM_API_KEY") or "").strip():
|
||||||
|
alt = (env.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
|
||||||
|
if alt:
|
||||||
|
env["STEAM_API_KEY"] = alt
|
||||||
|
if not (env.get("SESSION_SECRET") or "").strip():
|
||||||
|
alt = (env.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
|
||||||
|
if alt:
|
||||||
|
env["SESSION_SECRET"] = alt
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
env = _map_env()
|
||||||
|
steam_ok = bool((env.get("STEAM_API_KEY") or "").strip())
|
||||||
|
sess_ok = bool((env.get("SESSION_SECRET") or "").strip())
|
||||||
|
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
|
||||||
|
if not steam_ok or not sess_ok:
|
||||||
|
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Free the port if an old serve is still listening.
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
(
|
||||||
|
f"$c=Get-NetTCPConnection -LocalPort {PORT} -ErrorAction SilentlyContinue;"
|
||||||
|
"if($c){$c.OwningProcess|Sort-Object -Unique|ForEach-Object{"
|
||||||
|
"Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue}}"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log_path = ROOT / "web" / ".refresh" / "serve_steam.log"
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-u",
|
||||||
|
str(ROOT / "web" / "serve_relations.py"),
|
||||||
|
"--port",
|
||||||
|
PORT,
|
||||||
|
"--no-browser",
|
||||||
|
]
|
||||||
|
flags = 0
|
||||||
|
if sys.platform == "win32":
|
||||||
|
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||||
|
with log_path.open("ab") as log:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
stdout=log,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
creationflags=flags,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
print(f"started pid={proc.pid} port={PORT}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Smoke-check STEAM_API_KEY / KEYZOO_ASSET_SECRET_WEB_API_KEY (no secret echo)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
k = (os.environ.get("STEAM_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
|
||||||
|
if not k:
|
||||||
|
raise SystemExit("missing key")
|
||||||
|
url = (
|
||||||
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
|
||||||
|
f"?key={k}&steamids=76561198000000000"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(url, timeout=20) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
print("ok" if isinstance(data, dict) and "response" in data else "bad")
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"account_id": "510534f7f6284344aadaf2f5a0794d48",
|
||||||
|
"d1": {
|
||||||
|
"name": "climperor-users",
|
||||||
|
"id": "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
},
|
||||||
|
"r2": {
|
||||||
|
"name": "climperor-player-data",
|
||||||
|
"ready": true
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "climperor-player-sync",
|
||||||
|
"id": "371f11c7b4114f9b99ab10062a38ecd7"
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"name": "climperor-player-sync-dlq",
|
||||||
|
"id": "f7d1d45494f744e59effafb75ec62249"
|
||||||
|
},
|
||||||
|
"worker": "climperor-player-sync",
|
||||||
|
"pages_project": "climperor-relations"
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Climperor Cloudflare player data layer
|
||||||
|
|
||||||
|
Production multi-user storage for Steam-login player pages:
|
||||||
|
|
||||||
|
| Resource | Name | Role |
|
||||||
|
|----------|------|------|
|
||||||
|
| D1 | `climperor-users` | users, stats, heroes, match index, sync jobs |
|
||||||
|
| R2 | `climperor-player-data` | private full match JSON (`matches/{id}.json`) |
|
||||||
|
| Queue | `climperor-player-sync` | async OpenDota/Steam refresh |
|
||||||
|
| DLQ | `climperor-player-sync-dlq` | failed sync messages |
|
||||||
|
| Worker | `climperor-player-sync` | queue consumer |
|
||||||
|
|
||||||
|
## Provision
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# via keyzoo refining/cloudflare asset
|
||||||
|
python web/cloudflare/provision.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then deploy the worker:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd web/cloudflare/player-sync
|
||||||
|
npx wrangler@3 deploy
|
||||||
|
npx wrangler@3 secret put STEAM_API_KEY
|
||||||
|
# optional:
|
||||||
|
npx wrangler@3 secret put OPENDOTA_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages project `climperor-relations` needs bindings `DB`, `MATCHES`, `SYNC_QUEUE`
|
||||||
|
(see `web/frontend/wrangler.toml`). `provision.py` binds production **and** preview
|
||||||
|
with matching `fail_open`. Auth secrets on Pages (prod+preview):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python web/cloudflare/_stage_steam_auth.py # via keyzoo refining/steam
|
||||||
|
python web/cloudflare/put_pages_secrets.py # via keyzoo refining/cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional `PLAYER_PAGES_PUBLISH_SECRET`. Worker also needs `STEAM_API_KEY`
|
||||||
|
(`python web/cloudflare/put_worker_secrets.py` after `_stage_steam_key.py`).
|
||||||
|
|
||||||
|
**R2**: if create returns “enable R2”, open
|
||||||
|
https://dash.cloudflare.com/?to=/:account/r2 once, then re-run `provision.py`
|
||||||
|
and uncomment the `[[r2_buckets]]` block in `player-sync/wrangler.toml`, redeploy Worker.
|
||||||
|
Until then, match list/stats still work via D1; full match JSON download is deferred.
|
||||||
|
|
||||||
|
Ids are recorded in `.resources.json` (no secrets).
|
||||||
|
|
||||||
|
## Local
|
||||||
|
|
||||||
|
`python web/serve_relations.py` continues to use `pc/player_pages/` JSON + enrich
|
||||||
|
(`GET /api/players/me` runs `enrich_profile_recent`).
|
||||||
|
Production `/home` uses `GET /api/players/me` (D1 + queue).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest pc.tests.test_player_stats -v
|
||||||
|
node web/cloudflare/player-sync/test_stats.mjs
|
||||||
|
```
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Backfill D1 from local pc/player_pages profile (+ live OpenDota if needed).
|
||||||
|
|
||||||
|
Used when Worker edge cannot reach OpenDota (429). No secret echo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||||
|
PROFILE = ROOT / "pc" / "player_pages" / str(AID) / "profile.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def d1_batch(statements: list[dict]) -> None:
|
||||||
|
email, key = _creds()
|
||||||
|
for st in statements:
|
||||||
|
payload = {"sql": st["sql"]}
|
||||||
|
if "params" in st:
|
||||||
|
payload["params"] = st["params"]
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
|
||||||
|
data=data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
out = json.loads(r.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise SystemExit(f"D1 {e.code}: {raw[:500]}\nSQL: {st['sql'][:200]}") from e
|
||||||
|
if not out.get("success"):
|
||||||
|
raise SystemExit(json.dumps(out, ensure_ascii=False)[:800])
|
||||||
|
|
||||||
|
|
||||||
|
def esc(v) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "NULL"
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "1" if v else "0"
|
||||||
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||||
|
if isinstance(v, float) and (v != v): # NaN
|
||||||
|
return "NULL"
|
||||||
|
return str(v)
|
||||||
|
s = str(v).replace("'", "''")
|
||||||
|
return f"'{s}'"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not PROFILE.is_file():
|
||||||
|
raise SystemExit(f"missing {PROFILE}")
|
||||||
|
p = json.loads(PROFILE.read_text(encoding="utf-8"))
|
||||||
|
now = utc_now()
|
||||||
|
steamid = str(AID + 76561197960265728)
|
||||||
|
personaname = p.get("personaname") or "refining"
|
||||||
|
avatar = p.get("avatar")
|
||||||
|
public_share = 1 if p.get("public_share") else 0
|
||||||
|
avail = p.get("availability") or {}
|
||||||
|
career = p.get("career") or {}
|
||||||
|
recent20 = p.get("recent_20") or {}
|
||||||
|
activity = p.get("activity_180") or {}
|
||||||
|
top_heroes = p.get("top_heroes") or []
|
||||||
|
peers = p.get("peers") or []
|
||||||
|
recent = (p.get("recent") or [])[:20]
|
||||||
|
|
||||||
|
stmts: list[dict] = []
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at) "
|
||||||
|
f"VALUES ({AID}, {esc(steamid)}, {esc(personaname)}, {esc(avatar)}, {public_share}, {esc(now)}, {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id) DO UPDATE SET "
|
||||||
|
f"personaname=excluded.personaname, avatar=COALESCE(excluded.avatar, users.avatar), "
|
||||||
|
f"last_login_at=excluded.last_login_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_profiles ("
|
||||||
|
f"account_id, rank_tier, leaderboard_rank, availability_status, availability_note, "
|
||||||
|
f"availability_complete, source, fetched_at, enriched_at, updated_at) VALUES ("
|
||||||
|
f"{AID}, {esc(p.get('rank_tier'))}, {esc(p.get('leaderboard_rank'))}, "
|
||||||
|
f"{esc(avail.get('status') or 'public')}, {esc(avail.get('note'))}, "
|
||||||
|
f"{1 if avail.get('complete', True) else 0}, "
|
||||||
|
f"{esc(avail.get('source') or 'opendota+steam')}, "
|
||||||
|
f"{esc(avail.get('fetched_at') or now)}, {esc(p.get('enriched_at') or now)}, {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id) DO UPDATE SET "
|
||||||
|
f"rank_tier=excluded.rank_tier, leaderboard_rank=excluded.leaderboard_rank, "
|
||||||
|
f"availability_status=excluded.availability_status, availability_note=excluded.availability_note, "
|
||||||
|
f"availability_complete=excluded.availability_complete, source=excluded.source, "
|
||||||
|
f"fetched_at=excluded.fetched_at, enriched_at=excluded.enriched_at, updated_at=excluded.updated_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def stats_sql(scope: str, stats: dict) -> dict:
|
||||||
|
payload = json.dumps(stats, ensure_ascii=False).replace("'", "''")
|
||||||
|
sample = stats.get("sample") if stats.get("sample") is not None else stats.get("games") or 0
|
||||||
|
return {
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_stats ("
|
||||||
|
f"account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda, "
|
||||||
|
f"avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at) "
|
||||||
|
f"VALUES ({AID}, {esc(scope)}, {int(sample)}, {int(stats.get('wins') or 0)}, "
|
||||||
|
f"{int(stats.get('losses') or 0)}, {esc(stats.get('winrate'))}, "
|
||||||
|
f"{esc(stats.get('kills'))}, {esc(stats.get('deaths'))}, {esc(stats.get('assists'))}, "
|
||||||
|
f"{esc(stats.get('kda'))}, {esc(stats.get('avg_kills'))}, {esc(stats.get('avg_deaths'))}, "
|
||||||
|
f"{esc(stats.get('avg_assists'))}, {esc(stats.get('avg_gpm'))}, {esc(stats.get('avg_xpm'))}, "
|
||||||
|
f"{esc(stats.get('avg_hero_damage'))}, '{payload}', {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id, scope) DO UPDATE SET "
|
||||||
|
f"sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate, "
|
||||||
|
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
|
||||||
|
f"avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists, "
|
||||||
|
f"avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage, "
|
||||||
|
f"payload_json=excluded.payload_json, updated_at=excluded.updated_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if career:
|
||||||
|
stmts.append(stats_sql("career", career))
|
||||||
|
if recent20:
|
||||||
|
stmts.append(stats_sql("recent20", recent20))
|
||||||
|
if activity:
|
||||||
|
stmts.append(stats_sql("recent180", activity))
|
||||||
|
|
||||||
|
stmts.append({"sql": f"DELETE FROM player_heroes WHERE account_id={AID}"})
|
||||||
|
for h in top_heroes[:8]:
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_heroes ("
|
||||||
|
f"account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at) "
|
||||||
|
f"VALUES ({AID}, {int(h.get('hero_id') or 0)}, {esc(h.get('hero_key'))}, "
|
||||||
|
f"{esc(h.get('hero_name_loc'))}, {int(h.get('games') or 0)}, {int(h.get('wins') or 0)}, "
|
||||||
|
f"{esc(h.get('winrate'))}, {esc(h.get('last_played'))}, {esc(now)})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
stmts.append({"sql": f"DELETE FROM player_peers WHERE account_id={AID}"})
|
||||||
|
for peer in peers[:8]:
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_peers ("
|
||||||
|
f"account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at) "
|
||||||
|
f"VALUES ({AID}, {int(peer.get('account_id') or 0)}, {esc(peer.get('personaname'))}, "
|
||||||
|
f"{esc(peer.get('avatar'))}, {int(peer.get('games') or 0)}, {int(peer.get('wins') or 0)}, "
|
||||||
|
f"{esc(peer.get('winrate'))}, {esc(now)})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in recent:
|
||||||
|
mid = int(r.get("match_id") or 0)
|
||||||
|
if mid <= 0:
|
||||||
|
continue
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_matches ("
|
||||||
|
f"account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc, "
|
||||||
|
f"kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at) "
|
||||||
|
f"VALUES ({AID}, {mid}, {esc(r.get('start_time'))}, {esc(r.get('duration'))}, "
|
||||||
|
f"{1 if r.get('won') else 0}, {esc(r.get('hero_id'))}, {esc(r.get('hero_key'))}, "
|
||||||
|
f"{esc(r.get('hero_name_loc'))}, {esc(r.get('kills'))}, {esc(r.get('deaths'))}, "
|
||||||
|
f"{esc(r.get('assists'))}, {esc(r.get('kda'))}, {esc(r.get('gpm'))}, {esc(r.get('xpm'))}, "
|
||||||
|
f"{esc(r.get('hero_damage'))}, {esc(r.get('game_mode'))}, {esc(r.get('lobby_type'))}, "
|
||||||
|
f"NULL, {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id, match_id) DO UPDATE SET "
|
||||||
|
f"start_time=excluded.start_time, duration=excluded.duration, won=excluded.won, "
|
||||||
|
f"hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc, "
|
||||||
|
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
|
||||||
|
f"gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage, "
|
||||||
|
f"game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"backfill {AID} from {PROFILE.name}: {len(stmts)} statements …", flush=True)
|
||||||
|
d1_batch(stmts)
|
||||||
|
print("done", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Diagnose Cloudflare API auth without printing secrets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
|
||||||
|
|
||||||
|
def load_staged() -> None:
|
||||||
|
if not STAGED.is_file():
|
||||||
|
return
|
||||||
|
for line in STAGED.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" not in line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
os.environ.setdefault(k.strip(), v.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def probe(path: str, email: str, key: str) -> None:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.cloudflare.com/client/v4" + path,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"User-Agent": "climperor-cf-diag",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||||
|
body = resp.read(120).decode("utf-8", errors="replace")
|
||||||
|
print(f"{path} -> {resp.status} ray={resp.headers.get('cf-ray')} body={body[:80]!r}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read(200).decode("utf-8", errors="replace")
|
||||||
|
print(
|
||||||
|
f"{path} -> HTTP {e.code} ray={e.headers.get('cf-ray') if e.headers else None} "
|
||||||
|
f"body={raw[:120]!r}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{path} -> {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
load_staged()
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
print("missing credentials")
|
||||||
|
return 2
|
||||||
|
print(f"email_len={len(email)} key_len={len(key)} email_has_at={'@' in email}")
|
||||||
|
for path in ("/user", "/accounts", "/user/tokens/verify"):
|
||||||
|
probe(path, email, key)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Quick Cloudflare API reachability check (no secrets printed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
print("missing credentials")
|
||||||
|
return 2
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.cloudflare.com/client/v4/user",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
print(f"ok status={resp.status}")
|
||||||
|
return 0
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"http {e.code}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{type(e).__name__}: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Try R2 create via curl + Global API Key from staged env (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
BUCKET = "climperor-player-data"
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL")
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY")
|
||||||
|
if STAGED.is_file():
|
||||||
|
for line in STAGED.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
if k.strip() == "CLOUDFLARE_EMAIL":
|
||||||
|
email = v.strip()
|
||||||
|
elif k.strip() == "CLOUDFLARE_API_KEY":
|
||||||
|
key = v.strip()
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def curl_json(method: str, url: str, email: str, key: str, body: dict | None = None) -> tuple[int, str]:
|
||||||
|
cmd = [
|
||||||
|
"curl.exe",
|
||||||
|
"-sS",
|
||||||
|
"-w",
|
||||||
|
"\nHTTP_CODE:%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
"60",
|
||||||
|
"-X",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
"-H",
|
||||||
|
f"X-Auth-Email: {email}",
|
||||||
|
"-H",
|
||||||
|
f"X-Auth-Key: {key}",
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
]
|
||||||
|
if body is not None:
|
||||||
|
cmd.extend(["-d", json.dumps(body)])
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
out = proc.stdout or ""
|
||||||
|
code = 0
|
||||||
|
if "HTTP_CODE:" in out:
|
||||||
|
body_text, _, code_s = out.rpartition("HTTP_CODE:")
|
||||||
|
try:
|
||||||
|
code = int(code_s.strip())
|
||||||
|
except ValueError:
|
||||||
|
code = 0
|
||||||
|
out = body_text.strip()
|
||||||
|
else:
|
||||||
|
out = (proc.stderr or out)[:300]
|
||||||
|
return code, out[:400]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email, key = load()
|
||||||
|
base = f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/r2/buckets"
|
||||||
|
code, body = curl_json("GET", f"{base}/{BUCKET}", email, key)
|
||||||
|
print(f"GET bucket -> {code} {body[:160]!r}")
|
||||||
|
if code == 200:
|
||||||
|
print("r2 already exists")
|
||||||
|
return 0
|
||||||
|
code, body = curl_json("POST", base, email, key, {"name": BUCKET})
|
||||||
|
print(f"POST bucket -> {code} {body[:200]!r}")
|
||||||
|
return 0 if code in (200, 201) or "already exists" in body.lower() else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Print Pages deployment_configs keys relevant to bindings (no secrets)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/pages/projects/{PROJ}",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
dc = (data.get("result") or {}).get("deployment_configs") or {}
|
||||||
|
for env in ("production", "preview"):
|
||||||
|
cfg = dc.get(env) or {}
|
||||||
|
print(
|
||||||
|
env,
|
||||||
|
"fail_open=",
|
||||||
|
cfg.get("fail_open"),
|
||||||
|
"placement=",
|
||||||
|
cfg.get("placement"),
|
||||||
|
"d1=",
|
||||||
|
sorted((cfg.get("d1_databases") or {}).keys()),
|
||||||
|
"r2=",
|
||||||
|
sorted((cfg.get("r2_buckets") or {}).keys()),
|
||||||
|
"queues=",
|
||||||
|
sorted((cfg.get("queue_producers") or {}).keys()),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dump Pages env_var names/types and latest deployment id (no secret values)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
|
||||||
|
|
||||||
|
def get(path: str) -> dict:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4{path}",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = get(f"/accounts/{ACCT}/pages/projects/{PROJ}")["result"]
|
||||||
|
dc = p.get("deployment_configs") or {}
|
||||||
|
for env in ("production", "preview"):
|
||||||
|
ev = (dc.get(env) or {}).get("env_vars") or {}
|
||||||
|
print(env, "keys", sorted(ev.keys()))
|
||||||
|
for k, v in sorted(ev.items()):
|
||||||
|
print(
|
||||||
|
" ",
|
||||||
|
k,
|
||||||
|
"type=",
|
||||||
|
(v or {}).get("type"),
|
||||||
|
"has_value=",
|
||||||
|
"value" in (v or {}),
|
||||||
|
)
|
||||||
|
deps = get(f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments?per_page=2")[
|
||||||
|
"result"
|
||||||
|
]
|
||||||
|
for d0 in deps or []:
|
||||||
|
print(
|
||||||
|
"deployment",
|
||||||
|
d0.get("id"),
|
||||||
|
d0.get("environment"),
|
||||||
|
d0.get("created_on"),
|
||||||
|
d0.get("url"),
|
||||||
|
)
|
||||||
|
if d0.get("id"):
|
||||||
|
det = get(
|
||||||
|
f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments/{d0['id']}"
|
||||||
|
)["result"]
|
||||||
|
# Some APIs nest env under build_config / env_vars
|
||||||
|
for k in sorted(det.keys()):
|
||||||
|
if "env" in k.lower() or k in ("aliases", "is_skipped", "production_branch"):
|
||||||
|
val = det.get(k)
|
||||||
|
s = json.dumps(val, ensure_ascii=False) if not isinstance(val, str) else val
|
||||||
|
print(f" {k}: {s[:240]}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Create R2 bucket, bind Pages, redeploy Worker using keyzoo-injected CF creds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORKER = Path(__file__).resolve().parent / "player-sync"
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
BUCKET = "climperor-player-data"
|
||||||
|
PAGES = "climperor-relations"
|
||||||
|
QUEUE = "climperor-player-sync"
|
||||||
|
D1_ID = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
API = "https://api.cloudflare.com/client/v4"
|
||||||
|
RESOURCES = Path(__file__).resolve().parent / ".resources.json"
|
||||||
|
|
||||||
|
|
||||||
|
def creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing CF credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, body: dict | None = None, retries: int = 6) -> dict:
|
||||||
|
email, key = creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
last = None
|
||||||
|
for i in range(retries):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-enable-r2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
last = f"{e.code}: {raw[:200]}"
|
||||||
|
if e.code in (521, 522, 523, 524, 525, 429, 503) and i + 1 < retries:
|
||||||
|
wait = 4 + i * 3
|
||||||
|
print(f"retry {i+1}/{retries} after {e.code}, sleep {wait}s", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise SystemExit(f"CF API {method} {path} -> {last}") from e
|
||||||
|
except Exception as e:
|
||||||
|
last = str(e)
|
||||||
|
if i + 1 < retries:
|
||||||
|
wait = 4 + i * 3
|
||||||
|
print(f"retry {i+1}/{retries} after {type(e).__name__}, sleep {wait}s", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise SystemExit(f"CF API {method} {path} -> {last}") from e
|
||||||
|
raise SystemExit(f"CF API failed: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_r2() -> None:
|
||||||
|
try:
|
||||||
|
api("GET", f"/accounts/{ACCT}/r2/buckets/{BUCKET}", retries=3)
|
||||||
|
print(f"r2 exists: {BUCKET}", flush=True)
|
||||||
|
return
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
payload = api("POST", f"/accounts/{ACCT}/r2/buckets", {"name": BUCKET})
|
||||||
|
if not payload.get("success"):
|
||||||
|
err = str(payload.get("errors") or "")
|
||||||
|
if "already exists" in err.lower() or "10004" in err:
|
||||||
|
print(f"r2 exists: {BUCKET}", flush=True)
|
||||||
|
return
|
||||||
|
raise SystemExit(f"r2 create failed: {payload.get('errors')}")
|
||||||
|
print(f"r2 created: {BUCKET}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_pages() -> None:
|
||||||
|
path = f"/accounts/{ACCT}/pages/projects/{PAGES}"
|
||||||
|
project = api("GET", path)["result"]
|
||||||
|
dc = project.get("deployment_configs") or {}
|
||||||
|
prod = dict(dc.get("production") or {})
|
||||||
|
preview = dict(dc.get("preview") or {})
|
||||||
|
fail_open = prod.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = preview.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = False
|
||||||
|
|
||||||
|
def one(base: dict) -> dict:
|
||||||
|
out = {
|
||||||
|
k: v
|
||||||
|
for k, v in base.items()
|
||||||
|
if k not in ("d1_databases", "queue_producers", "r2_buckets", "fail_open")
|
||||||
|
}
|
||||||
|
d1 = dict(base.get("d1_databases") or {})
|
||||||
|
d1["DB"] = {"id": D1_ID}
|
||||||
|
out["d1_databases"] = d1
|
||||||
|
producers = dict(base.get("queue_producers") or {})
|
||||||
|
producers["SYNC_QUEUE"] = {"name": QUEUE}
|
||||||
|
out["queue_producers"] = producers
|
||||||
|
buckets = dict(base.get("r2_buckets") or {})
|
||||||
|
buckets["MATCHES"] = {"name": BUCKET}
|
||||||
|
out["r2_buckets"] = buckets
|
||||||
|
out["fail_open"] = bool(fail_open)
|
||||||
|
return out
|
||||||
|
|
||||||
|
api(
|
||||||
|
"PATCH",
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"deployment_configs": {
|
||||||
|
"production": one(prod),
|
||||||
|
"preview": one(preview),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print("pages bindings: DB + SYNC_QUEUE + MATCHES", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_resources() -> None:
|
||||||
|
data = {}
|
||||||
|
if RESOURCES.is_file():
|
||||||
|
data = json.loads(RESOURCES.read_text(encoding="utf-8"))
|
||||||
|
data["account_id"] = ACCT
|
||||||
|
data["r2"] = {"name": BUCKET, "ready": True}
|
||||||
|
data.setdefault("d1", {"name": "climperor-users", "id": D1_ID})
|
||||||
|
data.setdefault("queue", {"name": QUEUE})
|
||||||
|
data.setdefault("pages_project", PAGES)
|
||||||
|
data.setdefault("worker", "climperor-player-sync")
|
||||||
|
RESOURCES.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"updated {RESOURCES}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_worker() -> None:
|
||||||
|
email, key = creds()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
|
||||||
|
print("deploying worker…", flush=True)
|
||||||
|
proc = subprocess.run(
|
||||||
|
"npx --yes wrangler@3 deploy",
|
||||||
|
cwd=str(WORKER),
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise SystemExit(f"worker deploy failed: {proc.returncode}")
|
||||||
|
print("worker deployed", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ensure_r2()
|
||||||
|
bind_pages()
|
||||||
|
update_resources()
|
||||||
|
deploy_worker()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Force Worker HTTP login_refresh for TARGET account."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ["CLIMPEROR_FORCE_HTTP_SYNC"] = "1"
|
||||||
|
runpy.run_path(str(Path(__file__).with_name("_trigger_player_sync.py")), run_name="__main__")
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Deep-probe D1 + OpenDota for one account (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||||
|
|
||||||
|
|
||||||
|
def _creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def d1(sql: str) -> list:
|
||||||
|
email, key = _creds()
|
||||||
|
body = json.dumps({"sql": sql}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
out = json.loads(r.read().decode())
|
||||||
|
results = out.get("result") or []
|
||||||
|
if results:
|
||||||
|
return results[0].get("results") or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def od(path: str):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.opendota.com/api{path}",
|
||||||
|
headers={"User-Agent": "climperor-probe"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"_error": e.code, "_body": e.read()[:200].decode("utf-8", "replace")}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("users", json.dumps(d1(f"SELECT * FROM users WHERE account_id={AID}"), ensure_ascii=False))
|
||||||
|
print(
|
||||||
|
"stats",
|
||||||
|
json.dumps(
|
||||||
|
d1(
|
||||||
|
f"SELECT scope, sample, wins, losses, winrate, length(payload_json) AS plen "
|
||||||
|
f"FROM player_stats WHERE account_id={AID}"
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"matches",
|
||||||
|
json.dumps(
|
||||||
|
d1(f"SELECT COUNT(*) AS n FROM player_matches WHERE account_id={AID}"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"heroes",
|
||||||
|
json.dumps(
|
||||||
|
d1(f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={AID}"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"peers",
|
||||||
|
json.dumps(
|
||||||
|
d1(f"SELECT COUNT(*) AS n FROM player_peers WHERE account_id={AID}"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"profile",
|
||||||
|
json.dumps(
|
||||||
|
d1(
|
||||||
|
f"SELECT account_id, rank_tier, leaderboard_rank, "
|
||||||
|
f"availability_status, availability_note, "
|
||||||
|
f"availability_complete, source, fetched_at, enriched_at "
|
||||||
|
f"FROM player_profiles WHERE account_id={AID}"
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"stats_all",
|
||||||
|
json.dumps(
|
||||||
|
d1(
|
||||||
|
f"SELECT scope, sample, wins, losses, kda, avg_gpm "
|
||||||
|
f"FROM player_stats WHERE account_id={AID} ORDER BY scope"
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
player = od(f"/players/{AID}")
|
||||||
|
if isinstance(player, dict) and "profile" in player:
|
||||||
|
p = player.get("profile") or {}
|
||||||
|
print(
|
||||||
|
"OD player",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"personaname": p.get("personaname"),
|
||||||
|
"rank_tier": player.get("rank_tier"),
|
||||||
|
"fh_unavailable": player.get("fh_unavailable"),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("OD player", player)
|
||||||
|
|
||||||
|
wl = od(f"/players/{AID}/wl")
|
||||||
|
print("OD wl", wl)
|
||||||
|
recent = od(f"/players/{AID}/recentMatches")
|
||||||
|
if isinstance(recent, list):
|
||||||
|
print("OD recentMatches", len(recent))
|
||||||
|
if recent:
|
||||||
|
print("OD recent[0].match_id", recent[0].get("match_id"))
|
||||||
|
else:
|
||||||
|
print("OD recentMatches", recent)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Probe which CF API paths work with Global API Key (no secrets printed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PATHS = [
|
||||||
|
"/user",
|
||||||
|
"/accounts",
|
||||||
|
f"/accounts/{ACCT}/d1/database",
|
||||||
|
f"/accounts/{ACCT}/queues",
|
||||||
|
f"/accounts/{ACCT}/r2/buckets",
|
||||||
|
f"/accounts/{ACCT}/pages/projects/climperor-relations",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
for path in PATHS:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.cloudflare.com/client/v4" + path,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"User-Agent": "climperor-probe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=40) as resp:
|
||||||
|
print(f"{path} -> {resp.status}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
snippet = e.read(80).decode("utf-8", errors="replace").replace("\n", " ")
|
||||||
|
print(f"{path} -> HTTP {e.code} {snippet[:60]!r}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{path} -> {type(e).__name__}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Hammer R2 create/list until Cloudflare API stops returning 52x."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
BUCKET = "climperor-player-data"
|
||||||
|
API = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
|
||||||
|
def creds():
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def call(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]:
|
||||||
|
email, key = creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-retry-r2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return e.code, raw[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
list_path = f"/accounts/{ACCT}/r2/buckets"
|
||||||
|
get_path = f"/accounts/{ACCT}/r2/buckets/{BUCKET}"
|
||||||
|
for i in range(20):
|
||||||
|
code, payload = call("GET", get_path)
|
||||||
|
print(f"[{i+1}] GET {BUCKET} -> {code}", flush=True)
|
||||||
|
if code == 200:
|
||||||
|
print("bucket ready", flush=True)
|
||||||
|
return 0
|
||||||
|
if code == 404 or (isinstance(payload, dict) and not payload.get("success")):
|
||||||
|
# not found → create
|
||||||
|
code2, payload2 = call("POST", list_path, {"name": BUCKET})
|
||||||
|
print(f"[{i+1}] POST create -> {code2}", flush=True)
|
||||||
|
if code2 in (200, 201):
|
||||||
|
print("created", flush=True)
|
||||||
|
return 0
|
||||||
|
text = str(payload2).lower()
|
||||||
|
if "already exists" in text or "10004" in text:
|
||||||
|
print("exists", flush=True)
|
||||||
|
return 0
|
||||||
|
# also try list
|
||||||
|
code3, payload3 = call("GET", list_path)
|
||||||
|
names = []
|
||||||
|
if isinstance(payload3, dict):
|
||||||
|
buckets = (payload3.get("result") or {}).get("buckets") or payload3.get(
|
||||||
|
"result"
|
||||||
|
)
|
||||||
|
if isinstance(buckets, list):
|
||||||
|
for b in buckets:
|
||||||
|
if isinstance(b, dict):
|
||||||
|
names.append(b.get("name"))
|
||||||
|
print(f"[{i+1}] LIST -> {code3} names={names}", flush=True)
|
||||||
|
if BUCKET in names:
|
||||||
|
print("bucket ready via list", flush=True)
|
||||||
|
return 0
|
||||||
|
time.sleep(5 + (i % 5))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Load staged CF creds into env, run argv command, then delete the staging file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("usage: _run_with_staged_cf.py <cmd> [args...]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not STAGED.is_file():
|
||||||
|
print("missing staged cf_creds.env", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
env = os.environ.copy()
|
||||||
|
for line in STAGED.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" not in line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
env[k.strip()] = v.strip()
|
||||||
|
try:
|
||||||
|
STAGED.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
env.setdefault("CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48")
|
||||||
|
proc = subprocess.run(sys.argv[1:], cwd=str(Path.cwd()), env=env)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Stage Cloudflare email+key for the next local provision (no echo of secrets)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = (
|
||||||
|
os.environ.get("CLOUDFLARE_EMAIL")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_META_USERNAME")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
key = (
|
||||||
|
os.environ.get("CLOUDFLARE_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not email or not key:
|
||||||
|
print("missing credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUT.write_text(
|
||||||
|
f"CLOUDFLARE_EMAIL={email}\nCLOUDFLARE_API_KEY={key}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.chmod(OUT, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"staged {OUT.name}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Stage Steam API key + session secret for Pages/Worker secret put (no echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REFRESH = Path(__file__).resolve().parents[1] / ".refresh"
|
||||||
|
STEAM_TMP = REFRESH / "steam_key.tmp"
|
||||||
|
SESSION_TMP = REFRESH / "session_secret.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, value: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(value, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
steam = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
session = (
|
||||||
|
os.environ.get("SESSION_SECRET")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not steam or not session:
|
||||||
|
print("missing steam key or session secret", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
_write(STEAM_TMP, steam)
|
||||||
|
_write(SESSION_TMP, session)
|
||||||
|
print(
|
||||||
|
f"staged {STEAM_TMP.name} len={len(steam)}; "
|
||||||
|
f"{SESSION_TMP.name} len={len(session)}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Stage Steam API key to a temp file for the next CF secret put (no echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
key = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not key:
|
||||||
|
print("missing steam key", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUT.write_text(key, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(OUT, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"staged {OUT.name} len={len(key)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Probe D1 for an account, enqueue login_refresh, wait, probe again.
|
||||||
|
|
||||||
|
Uses Cloudflare Global API Key env (no secret echo).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
WORKER = HERE / "player-sync"
|
||||||
|
ACCOUNT_ID = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
QUEUE_ID = "371f11c7b4114f9b99ab10062a38ecd7"
|
||||||
|
TARGET = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||||
|
|
||||||
|
|
||||||
|
def _cf_env() -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
email = env.get("CLOUDFLARE_EMAIL") or env.get("KEYZOO_ASSET_META_USERNAME")
|
||||||
|
key = env.get("CLOUDFLARE_API_KEY") or env.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCOUNT_ID
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def d1_query(sql: str, env: dict[str, str]) -> list:
|
||||||
|
cmd = (
|
||||||
|
f'npx --yes wrangler@3 d1 execute climperor-users --remote --json '
|
||||||
|
f'--command "{sql}"'
|
||||||
|
)
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(WORKER),
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(r.stderr or r.stdout, file=sys.stderr)
|
||||||
|
raise SystemExit(r.returncode)
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(r.stdout)
|
||||||
|
return []
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
return data[0].get("results") or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class CfApiError(RuntimeError):
|
||||||
|
def __init__(self, code: int, body: str):
|
||||||
|
super().__init__(f"CF API {code}")
|
||||||
|
self.code = code
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
def cf_api(method: str, path: str, env: dict[str, str], body: dict | None = None) -> dict:
|
||||||
|
url = f"https://api.cloudflare.com/client/v4{path}"
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": env["CLOUDFLARE_EMAIL"],
|
||||||
|
"X-Auth-Key": env["CLOUDFLARE_API_KEY"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-trigger-sync",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise CfApiError(e.code, raw[:1200]) from e
|
||||||
|
|
||||||
|
|
||||||
|
def probe(env: dict[str, str], label: str) -> None:
|
||||||
|
print(f"\n=== {label} account_id={TARGET} ===", flush=True)
|
||||||
|
users = d1_query(
|
||||||
|
f"SELECT account_id, personaname, last_login_at FROM users "
|
||||||
|
f"WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("users:", json.dumps(users, ensure_ascii=False), flush=True)
|
||||||
|
jobs = d1_query(
|
||||||
|
f"SELECT kind, status, substr(COALESCE(error,''),1,160) AS error, "
|
||||||
|
f"updated_at FROM sync_jobs WHERE account_id={TARGET} "
|
||||||
|
f"ORDER BY updated_at DESC LIMIT 5",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("sync_jobs:", json.dumps(jobs, ensure_ascii=False), flush=True)
|
||||||
|
matches = d1_query(
|
||||||
|
f"SELECT COUNT(*) AS n, "
|
||||||
|
f"SUM(CASE WHEN r2_key IS NOT NULL AND r2_key != '' THEN 1 ELSE 0 END) AS r2 "
|
||||||
|
f"FROM player_matches WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("matches:", json.dumps(matches, ensure_ascii=False), flush=True)
|
||||||
|
stats = d1_query(
|
||||||
|
f"SELECT scope, sample, winrate FROM player_stats "
|
||||||
|
f"WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("stats:", json.dumps(stats, ensure_ascii=False), flush=True)
|
||||||
|
heroes = d1_query(
|
||||||
|
f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("heroes:", json.dumps(heroes, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_http_sync(env: dict[str, str], msg: dict) -> str:
|
||||||
|
"""POST Worker fetch handler. Returns 'http'."""
|
||||||
|
print("Worker HTTP sync …", flush=True)
|
||||||
|
try:
|
||||||
|
cf_api(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{ACCOUNT_ID}/workers/scripts/climperor-player-sync/subdomain",
|
||||||
|
env,
|
||||||
|
{"enabled": True},
|
||||||
|
)
|
||||||
|
except CfApiError as e:
|
||||||
|
print(f"enable subdomain: {e.code} {e.body[:400]}", flush=True)
|
||||||
|
sub_name = ""
|
||||||
|
try:
|
||||||
|
sub = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/subdomain", env)
|
||||||
|
sub_name = ((sub.get("result") or {}).get("subdomain") or "").strip()
|
||||||
|
except CfApiError as e:
|
||||||
|
print(f"get subdomain: {e.code} {e.body[:400]}", flush=True)
|
||||||
|
if not sub_name:
|
||||||
|
raise SystemExit("cannot resolve workers.dev subdomain")
|
||||||
|
url = f"https://climperor-player-sync.{sub_name}.workers.dev/"
|
||||||
|
print(f"POST {url}", flush=True)
|
||||||
|
data = json.dumps(msg).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-trigger-sync",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"worker HTTP {resp.status} len={len(raw)}", flush=True)
|
||||||
|
print(raw[:800], flush=True)
|
||||||
|
return "http"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"worker HTTP {e.code}: {raw[:800]}", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from e
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"worker URL error: {e}", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from e
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue(env: dict[str, str]) -> str:
|
||||||
|
"""Enqueue or HTTP-sync. Returns 'queue' | 'http'."""
|
||||||
|
steamid = str(TARGET + 76561197960265728)
|
||||||
|
msg = {
|
||||||
|
"kind": "login_refresh",
|
||||||
|
"account_id": TARGET,
|
||||||
|
"steamid": steamid,
|
||||||
|
"personaname": "refining",
|
||||||
|
}
|
||||||
|
if os.environ.get("CLIMPEROR_FORCE_HTTP_SYNC", "").strip() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
):
|
||||||
|
return worker_http_sync(env, msg)
|
||||||
|
|
||||||
|
print("\nenqueue login_refresh …", flush=True)
|
||||||
|
# https://developers.cloudflare.com/queues/configuration/javascript-apis/#producer
|
||||||
|
# HTTP: POST /accounts/:account_id/queues/:queue_id/messages
|
||||||
|
attempts = [
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
|
||||||
|
{"body": msg},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
|
||||||
|
{"messages": [{"body": json.dumps(msg)}]},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
|
||||||
|
{"messages": [{"body": msg}]},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
|
||||||
|
{"messages": [{"body": json.dumps(msg)}]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for path, body in attempts:
|
||||||
|
try:
|
||||||
|
out = cf_api("POST", path, env, body)
|
||||||
|
except CfApiError as e:
|
||||||
|
print(f"try {path.split('/')[-1]} HTTP {e.code}: {e.body[:400]}", flush=True)
|
||||||
|
continue
|
||||||
|
ok = bool(out.get("success"))
|
||||||
|
print(f"try {path.split('/')[-1]} success={ok}", flush=True)
|
||||||
|
if ok:
|
||||||
|
return "queue"
|
||||||
|
print(json.dumps(out, ensure_ascii=False)[:600], flush=True)
|
||||||
|
# Last resort: enable workers.dev and POST the Worker fetch handler.
|
||||||
|
return worker_http_sync(env, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def check_worker(env: dict[str, str]) -> None:
|
||||||
|
out = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/scripts", env)
|
||||||
|
names = [r.get("id") or r.get("name") for r in (out.get("result") or [])]
|
||||||
|
print("workers:", ", ".join(n for n in names if n)[:500], flush=True)
|
||||||
|
has = "climperor-player-sync" in names
|
||||||
|
print(f"climperor-player-sync deployed={has}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
env = _cf_env()
|
||||||
|
check_worker(env)
|
||||||
|
probe(env, "before")
|
||||||
|
mode = enqueue(env)
|
||||||
|
waits = 2 if mode == "http" else 8
|
||||||
|
for i in range(1, waits + 1):
|
||||||
|
delay = 3 if mode == "http" else 8
|
||||||
|
time.sleep(delay)
|
||||||
|
probe(env, f"after wait #{i} ({i * delay}s)")
|
||||||
|
users = d1_query(
|
||||||
|
f"SELECT account_id FROM users WHERE account_id={TARGET}", env
|
||||||
|
)
|
||||||
|
stats = d1_query(
|
||||||
|
f"SELECT scope FROM player_stats WHERE account_id={TARGET}", env
|
||||||
|
)
|
||||||
|
filled = d1_query(
|
||||||
|
f"SELECT scope, sample FROM player_stats WHERE account_id={TARGET} "
|
||||||
|
f"AND sample > 0",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
if users and filled:
|
||||||
|
print("\nSYNC OK — user + non-empty stats", flush=True)
|
||||||
|
return 0
|
||||||
|
print("\nSYNC PENDING/FAILED — no non-empty stats after waits", flush=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Deploy climperor-player-sync Worker via wrangler (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
WORKER = HERE / "player-sync"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
print("missing Cloudflare credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = env.get(
|
||||||
|
"CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
)
|
||||||
|
cmd = "npx --yes wrangler@3 deploy"
|
||||||
|
print("deploying climperor-player-sync …", flush=True)
|
||||||
|
return subprocess.call(cmd, cwd=str(WORKER), env=env, shell=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
-- Climperor multi-user player data (D1 climperor-users)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
account_id INTEGER PRIMARY KEY,
|
||||||
|
steamid TEXT NOT NULL UNIQUE,
|
||||||
|
personaname TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
public_share INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_profiles (
|
||||||
|
account_id INTEGER PRIMARY KEY REFERENCES users(account_id),
|
||||||
|
rank_tier INTEGER,
|
||||||
|
leaderboard_rank INTEGER,
|
||||||
|
availability_status TEXT,
|
||||||
|
availability_note TEXT,
|
||||||
|
availability_complete INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT,
|
||||||
|
fetched_at TEXT,
|
||||||
|
enriched_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_stats (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
scope TEXT NOT NULL, -- career | recent20 | recent180
|
||||||
|
sample INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winrate REAL,
|
||||||
|
kills INTEGER,
|
||||||
|
deaths INTEGER,
|
||||||
|
assists INTEGER,
|
||||||
|
kda REAL,
|
||||||
|
avg_kills REAL,
|
||||||
|
avg_deaths REAL,
|
||||||
|
avg_assists REAL,
|
||||||
|
avg_gpm REAL,
|
||||||
|
avg_xpm REAL,
|
||||||
|
avg_hero_damage REAL,
|
||||||
|
payload_json TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_heroes (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
hero_id INTEGER NOT NULL,
|
||||||
|
hero_key TEXT,
|
||||||
|
hero_name_loc TEXT,
|
||||||
|
games INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winrate REAL,
|
||||||
|
last_played INTEGER,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, hero_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_matches (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
match_id INTEGER NOT NULL,
|
||||||
|
start_time INTEGER,
|
||||||
|
duration INTEGER,
|
||||||
|
won INTEGER,
|
||||||
|
hero_id INTEGER,
|
||||||
|
hero_key TEXT,
|
||||||
|
hero_name_loc TEXT,
|
||||||
|
kills INTEGER,
|
||||||
|
deaths INTEGER,
|
||||||
|
assists INTEGER,
|
||||||
|
kda REAL,
|
||||||
|
gpm INTEGER,
|
||||||
|
xpm INTEGER,
|
||||||
|
hero_damage INTEGER,
|
||||||
|
game_mode INTEGER,
|
||||||
|
lobby_type INTEGER,
|
||||||
|
r2_key TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, match_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_player_matches_start
|
||||||
|
ON player_matches(account_id, start_time DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_peers (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
peer_account_id INTEGER NOT NULL,
|
||||||
|
personaname TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
games INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winrate REAL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, peer_account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- login_refresh | publish_match | backfill
|
||||||
|
match_id INTEGER,
|
||||||
|
status TEXT NOT NULL, -- queued | running | done | error
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lease_until TEXT,
|
||||||
|
error TEXT,
|
||||||
|
next_retry_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_status
|
||||||
|
ON sync_jobs(status, next_retry_at);
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
export function utcNow() {
|
||||||
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertUser(db, user) {
|
||||||
|
const now = utcNow();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
|
||||||
|
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
steamid=excluded.steamid,
|
||||||
|
personaname=COALESCE(excluded.personaname, users.personaname),
|
||||||
|
avatar=COALESCE(excluded.avatar, users.avatar),
|
||||||
|
public_share=COALESCE(excluded.public_share, users.public_share),
|
||||||
|
last_login_at=excluded.last_login_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
user.account_id,
|
||||||
|
String(user.steamid),
|
||||||
|
user.personaname || null,
|
||||||
|
user.avatar || null,
|
||||||
|
user.public_share == null ? null : user.public_share ? 1 : 0,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertProfile(db, accountId, profile) {
|
||||||
|
const now = utcNow();
|
||||||
|
const avail = profile.availability || {};
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_profiles (
|
||||||
|
account_id, rank_tier, leaderboard_rank, availability_status, availability_note,
|
||||||
|
availability_complete, source, fetched_at, enriched_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
rank_tier=COALESCE(excluded.rank_tier, player_profiles.rank_tier),
|
||||||
|
leaderboard_rank=COALESCE(excluded.leaderboard_rank, player_profiles.leaderboard_rank),
|
||||||
|
availability_status=excluded.availability_status,
|
||||||
|
availability_note=excluded.availability_note,
|
||||||
|
availability_complete=excluded.availability_complete,
|
||||||
|
source=excluded.source,
|
||||||
|
fetched_at=excluded.fetched_at,
|
||||||
|
enriched_at=excluded.enriched_at,
|
||||||
|
updated_at=excluded.updated_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
profile.rank_tier ?? null,
|
||||||
|
profile.leaderboard_rank ?? null,
|
||||||
|
avail.status || null,
|
||||||
|
avail.note || null,
|
||||||
|
avail.complete ? 1 : 0,
|
||||||
|
avail.source || "opendota",
|
||||||
|
avail.fetched_at || now,
|
||||||
|
profile.enriched_at || now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertStats(db, accountId, scope, stats) {
|
||||||
|
if (!stats) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_stats (
|
||||||
|
account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda,
|
||||||
|
avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id, scope) DO UPDATE SET
|
||||||
|
sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate,
|
||||||
|
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
|
||||||
|
avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists,
|
||||||
|
avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage,
|
||||||
|
payload_json=excluded.payload_json, updated_at=excluded.updated_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
scope,
|
||||||
|
stats.sample ?? stats.games ?? 0,
|
||||||
|
stats.wins ?? 0,
|
||||||
|
stats.losses ?? 0,
|
||||||
|
stats.winrate ?? null,
|
||||||
|
stats.kills ?? null,
|
||||||
|
stats.deaths ?? null,
|
||||||
|
stats.assists ?? null,
|
||||||
|
stats.kda ?? null,
|
||||||
|
stats.avg_kills ?? null,
|
||||||
|
stats.avg_deaths ?? null,
|
||||||
|
stats.avg_assists ?? null,
|
||||||
|
stats.avg_gpm ?? null,
|
||||||
|
stats.avg_xpm ?? null,
|
||||||
|
stats.avg_hero_damage ?? null,
|
||||||
|
JSON.stringify(stats),
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceHeroes(db, accountId, heroes) {
|
||||||
|
// Empty OpenDota heroes must not wipe a good cache.
|
||||||
|
if (!Array.isArray(heroes) || heroes.length === 0) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
|
||||||
|
for (const h of heroes) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_heroes (
|
||||||
|
account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
h.hero_id,
|
||||||
|
h.hero_key || null,
|
||||||
|
h.hero_name_loc || null,
|
||||||
|
h.games || 0,
|
||||||
|
h.wins || 0,
|
||||||
|
h.winrate ?? null,
|
||||||
|
h.last_played ?? null,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replacePeers(db, accountId, peers) {
|
||||||
|
// Empty OpenDota peers must not wipe a good cache.
|
||||||
|
if (!Array.isArray(peers) || peers.length === 0) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
|
||||||
|
for (const p of peers) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_peers (
|
||||||
|
account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
p.account_id,
|
||||||
|
p.personaname || null,
|
||||||
|
p.avatar || null,
|
||||||
|
p.games || 0,
|
||||||
|
p.wins || 0,
|
||||||
|
p.winrate ?? null,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMatches(db, accountId, recent) {
|
||||||
|
const now = utcNow();
|
||||||
|
for (const r of recent || []) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_matches (
|
||||||
|
account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc,
|
||||||
|
kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id, match_id) DO UPDATE SET
|
||||||
|
start_time=excluded.start_time, duration=excluded.duration, won=excluded.won,
|
||||||
|
hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc,
|
||||||
|
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
|
||||||
|
gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage,
|
||||||
|
game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
r.match_id,
|
||||||
|
r.start_time ?? null,
|
||||||
|
r.duration ?? null,
|
||||||
|
r.won ? 1 : 0,
|
||||||
|
r.hero_id ?? null,
|
||||||
|
r.hero_key || null,
|
||||||
|
r.hero_name_loc || null,
|
||||||
|
r.kills ?? null,
|
||||||
|
r.deaths ?? null,
|
||||||
|
r.assists ?? null,
|
||||||
|
r.kda ?? null,
|
||||||
|
r.gpm ?? null,
|
||||||
|
r.xpm ?? null,
|
||||||
|
r.hero_damage ?? null,
|
||||||
|
r.game_mode ?? null,
|
||||||
|
r.lobby_type ?? null,
|
||||||
|
r.r2_key || null,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlayerBundle(db, accountId) {
|
||||||
|
const user = await db
|
||||||
|
.prepare(`SELECT * FROM users WHERE account_id = ?`)
|
||||||
|
.bind(accountId)
|
||||||
|
.first();
|
||||||
|
if (!user) return null;
|
||||||
|
const profile = await db
|
||||||
|
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
|
||||||
|
.bind(accountId)
|
||||||
|
.first();
|
||||||
|
const statsRows = await db
|
||||||
|
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
const heroes = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
const peers = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
const recent = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const statsByScope = {};
|
||||||
|
for (const row of (statsRows && statsRows.results) || []) {
|
||||||
|
try {
|
||||||
|
statsByScope[row.scope] = row.payload_json
|
||||||
|
? JSON.parse(row.payload_json)
|
||||||
|
: row;
|
||||||
|
} catch {
|
||||||
|
statsByScope[row.scope] = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account_id: accountId,
|
||||||
|
personaname: user.personaname,
|
||||||
|
avatar: user.avatar,
|
||||||
|
public_share: !!user.public_share,
|
||||||
|
rank_tier: profile && profile.rank_tier,
|
||||||
|
leaderboard_rank: profile && profile.leaderboard_rank,
|
||||||
|
availability: profile
|
||||||
|
? {
|
||||||
|
status: profile.availability_status,
|
||||||
|
note: profile.availability_note,
|
||||||
|
complete: !!profile.availability_complete,
|
||||||
|
source: profile.source,
|
||||||
|
fetched_at: profile.fetched_at,
|
||||||
|
stale: false,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
career: statsByScope.career || null,
|
||||||
|
recent_20: statsByScope.recent20 || null,
|
||||||
|
activity_180: statsByScope.recent180 || null,
|
||||||
|
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
|
||||||
|
hero_id: h.hero_id,
|
||||||
|
hero_key: h.hero_key,
|
||||||
|
hero_name_loc: h.hero_name_loc,
|
||||||
|
games: h.games,
|
||||||
|
wins: h.wins,
|
||||||
|
winrate: h.winrate,
|
||||||
|
last_played: h.last_played,
|
||||||
|
})),
|
||||||
|
peers: ((peers && peers.results) || []).map((p) => ({
|
||||||
|
account_id: p.peer_account_id,
|
||||||
|
personaname: p.personaname,
|
||||||
|
avatar: p.avatar,
|
||||||
|
games: p.games,
|
||||||
|
wins: p.wins,
|
||||||
|
winrate: p.winrate,
|
||||||
|
})),
|
||||||
|
recent: ((recent && recent.results) || []).map((r) => ({
|
||||||
|
match_id: r.match_id,
|
||||||
|
start_time: r.start_time,
|
||||||
|
duration: r.duration,
|
||||||
|
won: !!r.won,
|
||||||
|
hero_id: r.hero_id,
|
||||||
|
hero_key: r.hero_key,
|
||||||
|
hero_name_loc: r.hero_name_loc,
|
||||||
|
kills: r.kills,
|
||||||
|
deaths: r.deaths,
|
||||||
|
assists: r.assists,
|
||||||
|
kda: r.kda,
|
||||||
|
gpm: r.gpm,
|
||||||
|
xpm: r.xpm,
|
||||||
|
hero_damage: r.hero_damage,
|
||||||
|
game_mode: r.game_mode,
|
||||||
|
lobby_type: r.lobby_type,
|
||||||
|
})),
|
||||||
|
updated_at: (profile && profile.updated_at) || user.last_login_at,
|
||||||
|
enriched_at: profile && profile.enriched_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Queue consumer: refresh player stats from OpenDota into D1 (+ optional R2 match detail).
|
||||||
|
*
|
||||||
|
* Message shapes:
|
||||||
|
* { kind: "login_refresh"|"backfill", account_id, steamid?, personaname?, avatar? }
|
||||||
|
* { kind: "publish_match", account_id, match_id }
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
loadPlayerBundle,
|
||||||
|
replaceHeroes,
|
||||||
|
replacePeers,
|
||||||
|
upsertMatches,
|
||||||
|
upsertProfile,
|
||||||
|
upsertStats,
|
||||||
|
upsertUser,
|
||||||
|
utcNow,
|
||||||
|
} from "./db.js";
|
||||||
|
import {
|
||||||
|
loadHeroMap,
|
||||||
|
normalizeMatch,
|
||||||
|
odFetch,
|
||||||
|
steamMatchHistoryStatus,
|
||||||
|
summaryFromRecentRow,
|
||||||
|
} from "./opendota.js";
|
||||||
|
import {
|
||||||
|
aggregateFromRows,
|
||||||
|
careerFromOpenDota,
|
||||||
|
mergeAvailability,
|
||||||
|
winrate,
|
||||||
|
} from "./stats.js";
|
||||||
|
|
||||||
|
function winrateGames(wins, games) {
|
||||||
|
return winrate(wins, Math.max(0, games - wins));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAccount(env, msg) {
|
||||||
|
const accountId = Number(msg.account_id);
|
||||||
|
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||||||
|
throw new Error("bad account_id");
|
||||||
|
}
|
||||||
|
const steamid =
|
||||||
|
msg.steamid || String(BigInt(accountId) + 76561197960265728n);
|
||||||
|
await upsertUser(env.DB, {
|
||||||
|
account_id: accountId,
|
||||||
|
steamid,
|
||||||
|
personaname: msg.personaname || null,
|
||||||
|
avatar: msg.avatar || null,
|
||||||
|
public_share: msg.public_share,
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroMap = await loadHeroMap(env);
|
||||||
|
// Serial OpenDota calls — CF edge IPs hit 429 hard under Promise.all.
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const player = await odFetch(`/players/${accountId}`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const wl = await odFetch(`/players/${accountId}/wl`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const totals = await odFetch(`/players/${accountId}/totals`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const heroes = await odFetch(`/players/${accountId}/heroes`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const peers = await odFetch(`/players/${accountId}/peers`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const recentRaw = await odFetch(`/players/${accountId}/recentMatches`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const matches180 = await odFetch(`/players/${accountId}/matches`, env, {
|
||||||
|
date: 180,
|
||||||
|
significant: 0,
|
||||||
|
});
|
||||||
|
const steamStatus = await steamMatchHistoryStatus(accountId, env);
|
||||||
|
|
||||||
|
const profileBlock = player && player.profile ? player.profile : {};
|
||||||
|
const personaname = profileBlock.personaname || msg.personaname || null;
|
||||||
|
const avatar =
|
||||||
|
profileBlock.avatarfull ||
|
||||||
|
profileBlock.avatarmedium ||
|
||||||
|
profileBlock.avatar ||
|
||||||
|
msg.avatar ||
|
||||||
|
null;
|
||||||
|
await upsertUser(env.DB, {
|
||||||
|
account_id: accountId,
|
||||||
|
steamid,
|
||||||
|
personaname,
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recent = [];
|
||||||
|
if (Array.isArray(recentRaw)) {
|
||||||
|
for (const row of recentRaw) {
|
||||||
|
const summary = summaryFromRecentRow(row, heroMap);
|
||||||
|
if (summary) recent.push(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recent.sort((a, b) => (b.start_time || 0) - (a.start_time || 0));
|
||||||
|
const recent20 = recent.slice(0, 20);
|
||||||
|
|
||||||
|
let career = careerFromOpenDota(wl, totals);
|
||||||
|
// Do not wipe previous career on empty OpenDota response.
|
||||||
|
if (!career) {
|
||||||
|
const existing = await loadPlayerBundle(env.DB, accountId);
|
||||||
|
if (existing && existing.career && existing.career.games > 0) {
|
||||||
|
career = existing.career;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topHeroes = [];
|
||||||
|
if (Array.isArray(heroes)) {
|
||||||
|
const scored = heroes
|
||||||
|
.filter((h) => h && Number(h.games) > 0)
|
||||||
|
.sort((a, b) => Number(b.games) - Number(a.games))
|
||||||
|
.slice(0, 5);
|
||||||
|
for (const h of scored) {
|
||||||
|
const hid = Number(h.hero_id) || 0;
|
||||||
|
const meta = heroMap.get(hid) || {};
|
||||||
|
const games = Number(h.games) || 0;
|
||||||
|
const wins = Number(h.win) || 0;
|
||||||
|
topHeroes.push({
|
||||||
|
hero_id: hid || null,
|
||||||
|
hero_key: meta.key || null,
|
||||||
|
hero_name_loc: meta.name_loc || meta.key || null,
|
||||||
|
games,
|
||||||
|
wins,
|
||||||
|
winrate: winrateGames(wins, games),
|
||||||
|
last_played: h.last_played != null ? Number(h.last_played) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerRows = [];
|
||||||
|
if (Array.isArray(peers)) {
|
||||||
|
for (const p of peers.slice(0, 8)) {
|
||||||
|
if (!p || !p.account_id) continue;
|
||||||
|
const games = Number(p.games) || 0;
|
||||||
|
if (games <= 0) continue;
|
||||||
|
const wins = Number(p.win) || 0;
|
||||||
|
peerRows.push({
|
||||||
|
account_id: Number(p.account_id),
|
||||||
|
personaname: p.personaname || `玩家 ${p.account_id}`,
|
||||||
|
avatar: p.avatarfull || p.avatar || null,
|
||||||
|
games,
|
||||||
|
wins,
|
||||||
|
winrate: winrateGames(wins, games),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activity180 = null;
|
||||||
|
if (Array.isArray(matches180) && matches180.length) {
|
||||||
|
const byDay = new Map();
|
||||||
|
let wins = 0;
|
||||||
|
let losses = 0;
|
||||||
|
let maxKills = null;
|
||||||
|
let maxAssists = null;
|
||||||
|
let maxGpm = null;
|
||||||
|
for (const row of matches180) {
|
||||||
|
if (!row || row.start_time == null) continue;
|
||||||
|
const st = Number(row.start_time);
|
||||||
|
const day = new Date(st * 1000).toISOString().slice(0, 10);
|
||||||
|
const cell = byDay.get(day) || { games: 0, wins: 0 };
|
||||||
|
cell.games += 1;
|
||||||
|
const slot = Number(row.player_slot) || 0;
|
||||||
|
const won = slot < 128 ? !!row.radiant_win : !row.radiant_win;
|
||||||
|
if (won) {
|
||||||
|
cell.wins += 1;
|
||||||
|
wins += 1;
|
||||||
|
} else losses += 1;
|
||||||
|
byDay.set(day, cell);
|
||||||
|
const kills = Number(row.kills) || 0;
|
||||||
|
const assists = Number(row.assists) || 0;
|
||||||
|
const gpm = Number(row.gold_per_min) || 0;
|
||||||
|
const heroId = Number(row.hero_id) || null;
|
||||||
|
const mid = Number(row.match_id) || 0;
|
||||||
|
if (!maxKills || kills > maxKills.value) {
|
||||||
|
maxKills = { value: kills, hero_id: heroId, match_id: mid };
|
||||||
|
}
|
||||||
|
if (!maxAssists || assists > maxAssists.value) {
|
||||||
|
maxAssists = { value: assists, hero_id: heroId, match_id: mid };
|
||||||
|
}
|
||||||
|
if (gpm > 0 && (!maxGpm || gpm > maxGpm.value)) {
|
||||||
|
maxGpm = { value: gpm, hero_id: heroId, match_id: mid };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activity180 = {
|
||||||
|
days: 180,
|
||||||
|
sample: wins + losses,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winrate: winrate(wins, losses),
|
||||||
|
heatmap: [...byDay.entries()]
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
.map(([date, v]) => ({ date, games: v.games, wins: v.wins })),
|
||||||
|
highs: { kills: maxKills, assists: maxAssists, gpm: maxGpm },
|
||||||
|
label: "最近 180 天样本",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await loadPlayerBundle(env.DB, accountId);
|
||||||
|
const existingRecentN = Array.isArray(existing && existing.recent)
|
||||||
|
? existing.recent.length
|
||||||
|
: 0;
|
||||||
|
const existingR20 =
|
||||||
|
existing && existing.recent_20 ? Number(existing.recent_20.sample) || 0 : 0;
|
||||||
|
const odRecentOk = Array.isArray(recentRaw) && recentRaw.length > 0;
|
||||||
|
|
||||||
|
const fetched = utcNow();
|
||||||
|
const availability = mergeAvailability({
|
||||||
|
opendotaRecentN: odRecentOk ? recentRaw.length : 0,
|
||||||
|
career,
|
||||||
|
steamHistoryStatus: steamStatus,
|
||||||
|
fetchedAt: fetched,
|
||||||
|
});
|
||||||
|
if (profileBlock.fh_unavailable && availability.status === "unknown") {
|
||||||
|
availability.status = "private";
|
||||||
|
availability.note = "未公开比赛数据";
|
||||||
|
}
|
||||||
|
// Cold account + Steam public + empty OpenDota → rate-limit; retry queue.
|
||||||
|
if (
|
||||||
|
!odRecentOk &&
|
||||||
|
!career &&
|
||||||
|
existingRecentN === 0 &&
|
||||||
|
!(existing && existing.career && existing.career.games > 0) &&
|
||||||
|
steamStatus === 1
|
||||||
|
) {
|
||||||
|
throw new Error("OpenDota empty while Steam public — retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
rank_tier: player && player.rank_tier != null ? Number(player.rank_tier) : null,
|
||||||
|
leaderboard_rank:
|
||||||
|
player && player.leaderboard_rank != null
|
||||||
|
? Number(player.leaderboard_rank)
|
||||||
|
: null,
|
||||||
|
availability,
|
||||||
|
enriched_at: fetched,
|
||||||
|
};
|
||||||
|
// Partial OpenDota (career ok, recent empty): keep prior freshness so /me
|
||||||
|
// stays soft-stale and do not pretend the row is fully refreshed.
|
||||||
|
if (!odRecentOk && (existingR20 > 0 || existingRecentN > 0)) {
|
||||||
|
profile.enriched_at =
|
||||||
|
(existing && existing.enriched_at) || profile.enriched_at;
|
||||||
|
if (existing && existing.availability && existing.availability.fetched_at) {
|
||||||
|
availability.fetched_at = existing.availability.fetched_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertProfile(env.DB, accountId, profile);
|
||||||
|
if (career) await upsertStats(env.DB, accountId, "career", career);
|
||||||
|
const recentAgg = aggregateFromRows(recent20, 20);
|
||||||
|
if ((recentAgg.sample || 0) > 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
await upsertMatches(env.DB, accountId, recent20);
|
||||||
|
} else if (existingR20 <= 0 && existingRecentN <= 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
}
|
||||||
|
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
|
||||||
|
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||||
|
await replacePeers(env.DB, accountId, peerRows);
|
||||||
|
|
||||||
|
// Optional: store published/ensured match detail (normalized) into R2.
|
||||||
|
if (
|
||||||
|
(msg.kind === "publish_match" || msg.kind === "ensure_match") &&
|
||||||
|
msg.match_id &&
|
||||||
|
env.MATCHES
|
||||||
|
) {
|
||||||
|
const matchId = Number(msg.match_id);
|
||||||
|
const match = await odFetch(`/matches/${matchId}`, env);
|
||||||
|
const detail =
|
||||||
|
match && Array.isArray(match.players)
|
||||||
|
? normalizeMatch(match, heroMap, accountId)
|
||||||
|
: null;
|
||||||
|
if (detail) {
|
||||||
|
const key = `matches/${matchId}.json`;
|
||||||
|
await env.MATCHES.put(key, JSON.stringify(detail), {
|
||||||
|
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
|
||||||
|
)
|
||||||
|
.bind(key, utcNow(), accountId, matchId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadPlayerBundle(env.DB, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markJob(env, jobId, patch) {
|
||||||
|
if (!jobId) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE sync_jobs SET status = ?, attempts = COALESCE(attempts, 0) + ?,
|
||||||
|
error = ?, lease_until = ?, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
patch.status,
|
||||||
|
patch.bumpAttempts ? 1 : 0,
|
||||||
|
patch.error || null,
|
||||||
|
patch.lease_until || null,
|
||||||
|
now,
|
||||||
|
jobId
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async queue(batch, env) {
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
let body = message.body;
|
||||||
|
if (typeof body === "string") {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
message.ack();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jobId = body && body.job_id;
|
||||||
|
try {
|
||||||
|
if (jobId) {
|
||||||
|
await markJob(env, jobId, {
|
||||||
|
status: "running",
|
||||||
|
lease_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await syncAccount(env, body || {});
|
||||||
|
if (jobId) await markJob(env, jobId, { status: "done" });
|
||||||
|
message.ack();
|
||||||
|
} catch (e) {
|
||||||
|
const err = String((e && e.message) || e);
|
||||||
|
if (jobId) {
|
||||||
|
await markJob(env, jobId, {
|
||||||
|
status: "error",
|
||||||
|
bumpAttempts: true,
|
||||||
|
error: err.slice(0, 500),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manual HTTP trigger for smoke tests (requires SYNC_HTTP_TOKEN secret).
|
||||||
|
async fetch(request, env) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response("climperor-player-sync", { status: 200 });
|
||||||
|
}
|
||||||
|
const token = (env.SYNC_HTTP_TOKEN || "").trim();
|
||||||
|
const auth = (request.headers.get("Authorization") || "").trim();
|
||||||
|
if (!token || auth !== `Bearer ${token}`) {
|
||||||
|
return new Response("unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const out = await syncAccount(env, body);
|
||||||
|
return new Response(JSON.stringify(out), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
const OPENDOTA = "https://api.opendota.com/api";
|
||||||
|
const STEAM_API = "https://api.steampowered.com";
|
||||||
|
|
||||||
|
export async function odFetch(path, env, query = {}) {
|
||||||
|
const url = new URL(`${OPENDOTA}${path}`);
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v != null) url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
const key = (env.OPENDOTA_API_KEY || "").trim();
|
||||||
|
if (key) url.searchParams.set("api_key", key);
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "climperor-player-sync",
|
||||||
|
};
|
||||||
|
// CF edge IPs are often rate-limited; retry 429/5xx before giving up.
|
||||||
|
let lastStatus = 0;
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1)));
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString(), { headers });
|
||||||
|
lastStatus = res.status;
|
||||||
|
if (res.status === 403 || res.status === 404) return null;
|
||||||
|
if (res.status === 429 || res.status >= 500) continue;
|
||||||
|
if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
if (lastStatus === 429 || lastStatus >= 500) return null;
|
||||||
|
throw new Error(`OpenDota ${lastStatus} ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function steamMatchHistoryStatus(accountId, env) {
|
||||||
|
const key = (env.STEAM_API_KEY || "").trim();
|
||||||
|
if (!key) return null;
|
||||||
|
const url = new URL(`${STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/`);
|
||||||
|
url.searchParams.set("key", key);
|
||||||
|
url.searchParams.set("account_id", String(accountId));
|
||||||
|
url.searchParams.set("matches_requested", "1");
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "User-Agent": "climperor-player-sync" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
const status = data && data.result && data.result.status;
|
||||||
|
return status == null ? null : Number(status);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summaryFromRecentRow(row, heroMap) {
|
||||||
|
const mid = Number(row.match_id) || 0;
|
||||||
|
if (mid <= 0) return null;
|
||||||
|
const heroId = Number(row.hero_id) || 0;
|
||||||
|
const hero = heroMap.get(heroId) || {};
|
||||||
|
const kills = Number(row.kills) || 0;
|
||||||
|
const deaths = Number(row.deaths) || 0;
|
||||||
|
const assists = Number(row.assists) || 0;
|
||||||
|
const playerSlot = Number(row.player_slot) || 0;
|
||||||
|
const radiantWin = !!row.radiant_win;
|
||||||
|
const isRadiant = playerSlot < 128;
|
||||||
|
return {
|
||||||
|
match_id: mid,
|
||||||
|
start_time: row.start_time != null ? Number(row.start_time) : null,
|
||||||
|
duration: Number(row.duration) || 0,
|
||||||
|
won: isRadiant ? radiantWin : !radiantWin,
|
||||||
|
hero_id: heroId || null,
|
||||||
|
hero_key: hero.key || null,
|
||||||
|
hero_name_loc: hero.name_loc || hero.key || null,
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10,
|
||||||
|
gpm: row.gold_per_min != null ? Number(row.gold_per_min) || 0 : null,
|
||||||
|
xpm: row.xp_per_min != null ? Number(row.xp_per_min) || 0 : null,
|
||||||
|
hero_damage: row.hero_damage != null ? Number(row.hero_damage) || 0 : null,
|
||||||
|
game_mode: row.game_mode != null ? Number(row.game_mode) : null,
|
||||||
|
lobby_type: row.lobby_type != null ? Number(row.lobby_type) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHeroMap(env) {
|
||||||
|
const rows = await odFetch("/heroes", env);
|
||||||
|
const map = new Map();
|
||||||
|
if (!Array.isArray(rows)) return map;
|
||||||
|
for (const h of rows) {
|
||||||
|
if (!h || h.id == null) continue;
|
||||||
|
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
|
||||||
|
map.set(Number(h.id), { key, name_loc: h.localized_name || key });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asInt(v, fallback = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemIds(player) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const iid = asInt(player[`item_${i}`], 0);
|
||||||
|
if (iid > 0) out.push(iid);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kda(kills, deaths, assists) {
|
||||||
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mvpScore(p) {
|
||||||
|
const k = asInt(p.kills);
|
||||||
|
const d = asInt(p.deaths);
|
||||||
|
const a = asInt(p.assists);
|
||||||
|
const dmg = asInt(p.hero_damage);
|
||||||
|
const nw = asInt(p.net_worth);
|
||||||
|
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / Math.max(d, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Climperor match-detail JSON (same shape as pc/player_pages.normalize_match). */
|
||||||
|
export function normalizeMatch(match, heroMap, focusAccountId = null) {
|
||||||
|
const playersRaw = match && match.players;
|
||||||
|
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
|
||||||
|
const matchId = asInt(match.match_id, 0);
|
||||||
|
if (matchId <= 0) return null;
|
||||||
|
|
||||||
|
const radiantWin = !!match.radiant_win;
|
||||||
|
const duration = asInt(match.duration);
|
||||||
|
let startTime = null;
|
||||||
|
if (match.start_time != null) {
|
||||||
|
const t = asInt(match.start_time, NaN);
|
||||||
|
startTime = Number.isFinite(t) ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamKills = [0, 0];
|
||||||
|
const teamNw = [0, 0];
|
||||||
|
const teamDmg = [0, 0];
|
||||||
|
const slim = [];
|
||||||
|
|
||||||
|
for (const p of playersRaw) {
|
||||||
|
if (!p || typeof p !== "object") continue;
|
||||||
|
const slot = asInt(p.player_slot);
|
||||||
|
const isRadiant = slot < 128;
|
||||||
|
const side = isRadiant ? 0 : 1;
|
||||||
|
const kills = asInt(p.kills);
|
||||||
|
const deaths = asInt(p.deaths);
|
||||||
|
const assists = asInt(p.assists);
|
||||||
|
const heroDamage = asInt(p.hero_damage);
|
||||||
|
let netWorth = asInt(p.net_worth);
|
||||||
|
if (netWorth <= 0) netWorth = asInt(p.gold) + asInt(p.gold_spent);
|
||||||
|
teamKills[side] += kills;
|
||||||
|
teamNw[side] += netWorth;
|
||||||
|
teamDmg[side] += heroDamage;
|
||||||
|
|
||||||
|
const heroId = asInt(p.hero_id);
|
||||||
|
const hero = (heroMap && heroMap.get(heroId)) || {};
|
||||||
|
let accountId = null;
|
||||||
|
if (p.account_id != null) {
|
||||||
|
const a = asInt(p.account_id, NaN);
|
||||||
|
accountId = Number.isFinite(a) ? a : null;
|
||||||
|
}
|
||||||
|
let personaname = p.personaname;
|
||||||
|
if (typeof personaname === "string") {
|
||||||
|
personaname = personaname.trim() || null;
|
||||||
|
} else {
|
||||||
|
personaname = null;
|
||||||
|
}
|
||||||
|
let partyId = null;
|
||||||
|
if (p.party_id != null) {
|
||||||
|
const pid = asInt(p.party_id, NaN);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) partyId = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
slim.push({
|
||||||
|
account_id: accountId,
|
||||||
|
personaname,
|
||||||
|
hero_id: heroId,
|
||||||
|
hero_key: hero.key || null,
|
||||||
|
hero_name_loc: hero.name_loc || hero.key || null,
|
||||||
|
level: asInt(p.level),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: kda(kills, deaths, assists),
|
||||||
|
hero_damage: heroDamage,
|
||||||
|
net_worth: netWorth,
|
||||||
|
party_id: partyId,
|
||||||
|
party_label: null,
|
||||||
|
items: itemIds(p),
|
||||||
|
is_radiant: isRadiant,
|
||||||
|
won: isRadiant ? radiantWin : !radiantWin,
|
||||||
|
_mvp: mvpScore(p),
|
||||||
|
_side: side,
|
||||||
|
_slot: slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slim.length < 2) return null;
|
||||||
|
|
||||||
|
const partyCounts = new Map();
|
||||||
|
for (const p of slim) {
|
||||||
|
if (typeof p.party_id === "number" && p.party_id > 0) {
|
||||||
|
partyCounts.set(p.party_id, (partyCounts.get(p.party_id) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const partyLabels = new Map();
|
||||||
|
for (const [pid, n] of [...partyCounts.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
if (n >= 2) partyLabels.set(pid, String.fromCharCode(65 + partyLabels.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of slim) {
|
||||||
|
const side = p._side;
|
||||||
|
const tk = teamKills[side] || 1;
|
||||||
|
const td = teamDmg[side] || 1;
|
||||||
|
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
|
||||||
|
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
|
||||||
|
p.party_label =
|
||||||
|
typeof p.party_id === "number" ? partyLabels.get(p.party_id) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mvp = slim.reduce((best, p) => (p._mvp > best._mvp ? p : best), slim[0]);
|
||||||
|
const mvpAccount = mvp.account_id;
|
||||||
|
const mvpSlot = mvp._slot;
|
||||||
|
for (const p of slim) {
|
||||||
|
p.is_mvp =
|
||||||
|
mvpAccount != null ? p.account_id === mvpAccount : p._slot === mvpSlot;
|
||||||
|
delete p._mvp;
|
||||||
|
delete p._side;
|
||||||
|
delete p._slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
match_id: matchId,
|
||||||
|
start_time: startTime,
|
||||||
|
duration,
|
||||||
|
radiant_win: radiantWin,
|
||||||
|
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
|
||||||
|
dire: { kills: teamKills[1], net_worth: teamNw[1] },
|
||||||
|
mvp_account_id: mvpAccount,
|
||||||
|
players: slim,
|
||||||
|
focus_account_id: focusAccountId,
|
||||||
|
fetched_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||||
|
source: "opendota",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/** Shared aggregate helpers for the player-sync Worker (mirrors pc/player_stats.py). */
|
||||||
|
|
||||||
|
export function kda(kills, deaths, assists) {
|
||||||
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function winrate(wins, losses) {
|
||||||
|
const total = wins + losses;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
return Math.round((wins / total) * 1000) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateFromRows(rows, limit = 20) {
|
||||||
|
const sample = (Array.isArray(rows) ? rows : []).slice(0, limit);
|
||||||
|
let wins = 0;
|
||||||
|
let losses = 0;
|
||||||
|
let kills = 0;
|
||||||
|
let deaths = 0;
|
||||||
|
let assists = 0;
|
||||||
|
let gpmSum = 0;
|
||||||
|
let xpmSum = 0;
|
||||||
|
let dmgSum = 0;
|
||||||
|
let gpmN = 0;
|
||||||
|
let xpmN = 0;
|
||||||
|
let dmgN = 0;
|
||||||
|
const heroes = [];
|
||||||
|
for (const r of sample) {
|
||||||
|
if (!r || typeof r !== "object") continue;
|
||||||
|
if (r.won) wins += 1;
|
||||||
|
else losses += 1;
|
||||||
|
const k = Number(r.kills) || 0;
|
||||||
|
const d = Number(r.deaths) || 0;
|
||||||
|
const a = Number(r.assists) || 0;
|
||||||
|
kills += k;
|
||||||
|
deaths += d;
|
||||||
|
assists += a;
|
||||||
|
if (r.gpm != null) {
|
||||||
|
gpmSum += Number(r.gpm) || 0;
|
||||||
|
gpmN += 1;
|
||||||
|
}
|
||||||
|
if (r.xpm != null) {
|
||||||
|
xpmSum += Number(r.xpm) || 0;
|
||||||
|
xpmN += 1;
|
||||||
|
}
|
||||||
|
if (r.hero_damage != null) {
|
||||||
|
dmgSum += Number(r.hero_damage) || 0;
|
||||||
|
dmgN += 1;
|
||||||
|
}
|
||||||
|
heroes.push({
|
||||||
|
match_id: Number(r.match_id) || 0,
|
||||||
|
hero_id: r.hero_id ?? null,
|
||||||
|
hero_key: r.hero_key || null,
|
||||||
|
hero_name_loc: r.hero_name_loc || null,
|
||||||
|
won: !!r.won,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const n = wins + losses;
|
||||||
|
return {
|
||||||
|
sample: n,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winrate: winrate(wins, losses),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: n ? kda(kills, deaths, assists) : null,
|
||||||
|
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
|
||||||
|
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
|
||||||
|
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
|
||||||
|
avg_gpm: gpmN ? Math.round(gpmSum / gpmN) : null,
|
||||||
|
avg_xpm: xpmN ? Math.round(xpmSum / xpmN) : null,
|
||||||
|
avg_hero_damage: dmgN ? Math.round(dmgSum / dmgN) : null,
|
||||||
|
heroes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function careerFromOpenDota(wl, totals) {
|
||||||
|
const wins = Number(wl && wl.win) || 0;
|
||||||
|
const losses = Number(wl && wl.lose) || 0;
|
||||||
|
if (wins <= 0 && losses <= 0) return null;
|
||||||
|
const byField = new Map();
|
||||||
|
if (Array.isArray(totals)) {
|
||||||
|
for (const row of totals) {
|
||||||
|
if (row && row.field) byField.set(String(row.field), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sumOf = (f) => Number((byField.get(f) || {}).sum) || 0;
|
||||||
|
const nOf = (f) => Number((byField.get(f) || {}).n) || 0;
|
||||||
|
const n = wins + losses;
|
||||||
|
const kills = sumOf("kills");
|
||||||
|
const deaths = sumOf("deaths");
|
||||||
|
const assists = sumOf("assists");
|
||||||
|
const gpmN = nOf("gold_per_min");
|
||||||
|
const xpmN = nOf("xp_per_min");
|
||||||
|
const dmgN = nOf("hero_damage");
|
||||||
|
return {
|
||||||
|
games: n,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winrate: winrate(wins, losses),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: n ? kda(kills, deaths, assists) : null,
|
||||||
|
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
|
||||||
|
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
|
||||||
|
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
|
||||||
|
avg_gpm: gpmN ? Math.round(sumOf("gold_per_min") / gpmN) : null,
|
||||||
|
avg_xpm: xpmN ? Math.round(sumOf("xp_per_min") / xpmN) : null,
|
||||||
|
avg_hero_damage: dmgN ? Math.round(sumOf("hero_damage") / dmgN) : null,
|
||||||
|
source: "opendota",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAvailability({ opendotaRecentN, career, steamHistoryStatus, fetchedAt }) {
|
||||||
|
const odPublic = opendotaRecentN > 0 || !!(career && career.games);
|
||||||
|
const steamAllowed = steamHistoryStatus === 1;
|
||||||
|
const steamDenied = steamHistoryStatus === 15;
|
||||||
|
let status = "unknown";
|
||||||
|
let complete = false;
|
||||||
|
let note = "暂无公开战绩";
|
||||||
|
if (odPublic) {
|
||||||
|
status = "public";
|
||||||
|
complete = true;
|
||||||
|
note = null;
|
||||||
|
} else if (steamAllowed) {
|
||||||
|
status = "syncing";
|
||||||
|
note = "Steam 已公开,OpenDota 同步中";
|
||||||
|
} else if (steamDenied) {
|
||||||
|
status = "private";
|
||||||
|
note = "未公开比赛数据";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
complete,
|
||||||
|
opendota_public: odPublic,
|
||||||
|
steam_history_status: steamHistoryStatus ?? null,
|
||||||
|
source: "opendota+steam",
|
||||||
|
fetched_at: fetchedAt,
|
||||||
|
note,
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** Node smoke tests for Worker stats helpers (no wrangler). */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
aggregateFromRows,
|
||||||
|
careerFromOpenDota,
|
||||||
|
kda,
|
||||||
|
mergeAvailability,
|
||||||
|
winrate,
|
||||||
|
} from "./src/stats.js";
|
||||||
|
|
||||||
|
assert.equal(kda(10, 0, 5), 15);
|
||||||
|
assert.equal(winrate(0, 0), null);
|
||||||
|
assert.equal(winrate(1, 1), 50);
|
||||||
|
|
||||||
|
const emptyCareer = careerFromOpenDota({ win: 0, lose: 0 }, []);
|
||||||
|
assert.equal(emptyCareer, null);
|
||||||
|
|
||||||
|
const career = careerFromOpenDota(
|
||||||
|
{ win: 2, lose: 1 },
|
||||||
|
[
|
||||||
|
{ field: "kills", sum: 30, n: 3 },
|
||||||
|
{ field: "deaths", sum: 6, n: 3 },
|
||||||
|
{ field: "assists", sum: 15, n: 3 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(career.games, 3);
|
||||||
|
assert.equal(career.winrate, 66.7);
|
||||||
|
assert.ok(career.kda > 0);
|
||||||
|
|
||||||
|
const recent = aggregateFromRows(
|
||||||
|
[
|
||||||
|
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 },
|
||||||
|
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 }, // duplicate row ok in unit
|
||||||
|
{ match_id: 2, won: false, kills: 0, deaths: 0, assists: 2, gpm: 400 },
|
||||||
|
],
|
||||||
|
20
|
||||||
|
);
|
||||||
|
assert.equal(recent.sample, 3);
|
||||||
|
assert.equal(recent.wins, 2);
|
||||||
|
|
||||||
|
const syncing = mergeAvailability({
|
||||||
|
opendotaRecentN: 0,
|
||||||
|
career: null,
|
||||||
|
steamHistoryStatus: 1,
|
||||||
|
fetchedAt: "2026-07-31T00:00:00Z",
|
||||||
|
});
|
||||||
|
assert.equal(syncing.status, "syncing");
|
||||||
|
assert.match(syncing.note, /OpenDota/);
|
||||||
|
|
||||||
|
const priv = mergeAvailability({
|
||||||
|
opendotaRecentN: 0,
|
||||||
|
career: null,
|
||||||
|
steamHistoryStatus: 15,
|
||||||
|
fetchedAt: "2026-07-31T00:00:00Z",
|
||||||
|
});
|
||||||
|
assert.equal(priv.status, "private");
|
||||||
|
|
||||||
|
console.log("stats.js ok");
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name = "climperor-player-sync"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2024-11-01"
|
||||||
|
workers_dev = false
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "climperor-users"
|
||||||
|
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "MATCHES"
|
||||||
|
bucket_name = "climperor-player-data"
|
||||||
|
|
||||||
|
[[queues.consumers]]
|
||||||
|
queue = "climperor-player-sync"
|
||||||
|
max_batch_size = 5
|
||||||
|
max_retries = 5
|
||||||
|
dead_letter_queue = "climperor-player-sync-dlq"
|
||||||
|
|
||||||
|
[[queues.producers]]
|
||||||
|
binding = "SYNC_QUEUE"
|
||||||
|
queue = "climperor-player-sync"
|
||||||
|
|
||||||
|
# Secrets (wrangler secret put):
|
||||||
|
# STEAM_API_KEY
|
||||||
|
# OPENDOTA_API_KEY (optional)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Create Cloudflare D1 / R2 / Queues for Climperor player data and bind Pages.
|
||||||
|
|
||||||
|
Credentials: CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY (or keyzoo refining/cloudflare).
|
||||||
|
Writes web/cloudflare/.resources.json with ids (no secrets).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
OUT = Path(__file__).resolve().parent / ".resources.json"
|
||||||
|
MIGRATION = Path(__file__).resolve().parent / "migrations" / "0001_init.sql"
|
||||||
|
PAGES_PROJECT = "climperor-relations"
|
||||||
|
D1_NAME = "climperor-users"
|
||||||
|
R2_NAME = "climperor-player-data"
|
||||||
|
QUEUE_NAME = "climperor-player-sync"
|
||||||
|
DLQ_NAME = "climperor-player-sync-dlq"
|
||||||
|
WORKER_NAME = "climperor-player-sync"
|
||||||
|
API = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
|
||||||
|
def creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, body: dict | None = None) -> dict:
|
||||||
|
email, key = creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-provision",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
payload = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise SystemExit(f"CF API {method} {path} -> {e.code}: {raw[:400]}") from e
|
||||||
|
if not payload.get("success"):
|
||||||
|
raise SystemExit(f"CF API failed: {payload.get('errors')}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def account_id() -> str:
|
||||||
|
rows = api("GET", "/accounts")["result"]
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit("no Cloudflare accounts")
|
||||||
|
return rows[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_d1(acct: str) -> str:
|
||||||
|
listed = api("GET", f"/accounts/{acct}/d1/database")["result"] or []
|
||||||
|
for row in listed:
|
||||||
|
if row.get("name") == D1_NAME:
|
||||||
|
print(f"d1 exists: {row['uuid']}")
|
||||||
|
return row["uuid"]
|
||||||
|
created = api(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{acct}/d1/database",
|
||||||
|
{"name": D1_NAME},
|
||||||
|
)["result"]
|
||||||
|
print(f"d1 created: {created['uuid']}")
|
||||||
|
return created["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_migration(acct: str, db_id: str) -> None:
|
||||||
|
sql = MIGRATION.read_text(encoding="utf-8")
|
||||||
|
api(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{acct}/d1/database/{db_id}/query",
|
||||||
|
{"sql": sql},
|
||||||
|
)
|
||||||
|
print("d1 migration applied")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_r2(acct: str) -> bool:
|
||||||
|
"""Return True if bucket exists/created. False if R2 not enabled on account."""
|
||||||
|
try:
|
||||||
|
api("GET", f"/accounts/{acct}/r2/buckets/{R2_NAME}")
|
||||||
|
print(f"r2 exists: {R2_NAME}")
|
||||||
|
return True
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
api("POST", f"/accounts/{acct}/r2/buckets", {"name": R2_NAME})
|
||||||
|
print(f"r2 created: {R2_NAME}")
|
||||||
|
return True
|
||||||
|
except SystemExit as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "already exists" in msg.lower() or "10004" in msg:
|
||||||
|
print(f"r2 exists: {R2_NAME}")
|
||||||
|
return True
|
||||||
|
if "10042" in msg or "enable R2" in msg:
|
||||||
|
print(
|
||||||
|
"r2 skipped: enable R2 in Cloudflare Dashboard "
|
||||||
|
"(https://dash.cloudflare.com/?to=/:account/r2), then re-run"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_queue(acct: str, name: str) -> str:
|
||||||
|
listed = api("GET", f"/accounts/{acct}/queues")["result"] or []
|
||||||
|
# API shape may be {result: [...]} or {result: {queues: [...]}}
|
||||||
|
rows = listed if isinstance(listed, list) else (listed.get("queues") or [])
|
||||||
|
for row in rows:
|
||||||
|
if row.get("queue_name") == name or row.get("name") == name:
|
||||||
|
qid = row.get("queue_id") or row.get("id")
|
||||||
|
print(f"queue exists: {name} ({qid})")
|
||||||
|
return qid
|
||||||
|
created = api("POST", f"/accounts/{acct}/queues", {"queue_name": name})["result"]
|
||||||
|
qid = created.get("queue_id") or created.get("id")
|
||||||
|
print(f"queue created: {name} ({qid})")
|
||||||
|
return qid
|
||||||
|
|
||||||
|
|
||||||
|
def patch_wrangler(d1_id: str) -> None:
|
||||||
|
path = Path(__file__).resolve().parent / "player-sync" / "wrangler.toml"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
text = text.replace("REPLACE_D1_ID", d1_id)
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print(f"updated {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_resources(
|
||||||
|
acct: str,
|
||||||
|
d1_id: str,
|
||||||
|
queue_id: str | None,
|
||||||
|
dlq_id: str | None,
|
||||||
|
*,
|
||||||
|
r2_ok: bool,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"account_id": acct,
|
||||||
|
"d1": {"name": D1_NAME, "id": d1_id},
|
||||||
|
"r2": {"name": R2_NAME, "ready": r2_ok},
|
||||||
|
"queue": {"name": QUEUE_NAME, "id": queue_id},
|
||||||
|
"dlq": {"name": DLQ_NAME, "id": dlq_id},
|
||||||
|
"worker": WORKER_NAME,
|
||||||
|
"pages_project": PAGES_PROJECT,
|
||||||
|
}
|
||||||
|
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_queue_soft(acct: str, name: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return ensure_queue(acct, name)
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"queue skipped ({name}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bindings(
|
||||||
|
base: dict,
|
||||||
|
*,
|
||||||
|
d1_id: str,
|
||||||
|
queue_id: str | None,
|
||||||
|
r2_ok: bool,
|
||||||
|
fail_open: bool | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build one environment's deployment_config with required bindings."""
|
||||||
|
out = {
|
||||||
|
k: v
|
||||||
|
for k, v in base.items()
|
||||||
|
if k
|
||||||
|
not in (
|
||||||
|
"d1_databases",
|
||||||
|
"queue_producers",
|
||||||
|
"r2_buckets",
|
||||||
|
"fail_open",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
d1_bindings = dict(base.get("d1_databases") or {})
|
||||||
|
d1_bindings["DB"] = {"id": d1_id}
|
||||||
|
out["d1_databases"] = d1_bindings
|
||||||
|
if queue_id:
|
||||||
|
producers = dict(base.get("queue_producers") or {})
|
||||||
|
producers["SYNC_QUEUE"] = {"name": QUEUE_NAME}
|
||||||
|
out["queue_producers"] = producers
|
||||||
|
if r2_ok:
|
||||||
|
buckets = dict(base.get("r2_buckets") or {})
|
||||||
|
buckets["MATCHES"] = {"name": R2_NAME}
|
||||||
|
out["r2_buckets"] = buckets
|
||||||
|
# Cloudflare requires fail_open equal on production and preview.
|
||||||
|
if fail_open is not None:
|
||||||
|
out["fail_open"] = bool(fail_open)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bind_pages(
|
||||||
|
acct: str,
|
||||||
|
d1_id: str,
|
||||||
|
queue_id: str | None,
|
||||||
|
*,
|
||||||
|
r2_ok: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Attach D1 (+ Queue / R2) to Pages production and preview bindings."""
|
||||||
|
path = f"/accounts/{acct}/pages/projects/{PAGES_PROJECT}"
|
||||||
|
try:
|
||||||
|
project = api("GET", path)["result"]
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"pages bind skipped (project missing?): {e}")
|
||||||
|
return
|
||||||
|
dc = project.get("deployment_configs") or {}
|
||||||
|
prod = dict(dc.get("production") or {})
|
||||||
|
preview = dict(dc.get("preview") or {})
|
||||||
|
fail_open = prod.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = preview.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = False
|
||||||
|
body = {
|
||||||
|
"deployment_configs": {
|
||||||
|
"production": _env_bindings(
|
||||||
|
prod,
|
||||||
|
d1_id=d1_id,
|
||||||
|
queue_id=queue_id,
|
||||||
|
r2_ok=r2_ok,
|
||||||
|
fail_open=fail_open,
|
||||||
|
),
|
||||||
|
"preview": _env_bindings(
|
||||||
|
preview,
|
||||||
|
d1_id=d1_id,
|
||||||
|
queue_id=queue_id,
|
||||||
|
r2_ok=r2_ok,
|
||||||
|
fail_open=fail_open,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
api("PATCH", path, body)
|
||||||
|
print(f"pages bindings updated on {PAGES_PROJECT} (prod+preview)")
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"pages bind soft-fail (set manually): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
acct = account_id()
|
||||||
|
print(f"account_id: {acct}")
|
||||||
|
d1_id = ensure_d1(acct)
|
||||||
|
run_migration(acct, d1_id)
|
||||||
|
r2_ok = ensure_r2(acct)
|
||||||
|
queue_id = ensure_queue_soft(acct, QUEUE_NAME)
|
||||||
|
dlq_id = ensure_queue_soft(acct, DLQ_NAME)
|
||||||
|
patch_wrangler(d1_id)
|
||||||
|
bind_pages(acct, d1_id, queue_id, r2_ok=r2_ok)
|
||||||
|
write_resources(acct, d1_id, queue_id, dlq_id, r2_ok=r2_ok)
|
||||||
|
print(
|
||||||
|
"next: deploy worker with wrangler + bind D1/R2/Queue to Pages project "
|
||||||
|
f"{PAGES_PROJECT} (see web/cloudflare/README.md)"
|
||||||
|
)
|
||||||
|
return 0 if d1_id else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Put STEAM_API_KEY + SESSION_SECRET on climperor-relations Pages (prod+preview).
|
||||||
|
|
||||||
|
Reads secrets from env or staged temp files under web/.refresh/ (no secret echo).
|
||||||
|
Prefers `wrangler pages secret put`; falls back to Pages project PATCH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
FRONTEND = HERE.parents[0] / "frontend"
|
||||||
|
REFRESH = Path(__file__).resolve().parents[1] / ".refresh"
|
||||||
|
STEAM_TMP = REFRESH / "steam_key.tmp"
|
||||||
|
SESSION_TMP = REFRESH / "session_secret.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def _creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def _secret(name: str, env_keys: list[str], staged: Path) -> str:
|
||||||
|
for k in env_keys:
|
||||||
|
v = (os.environ.get(k) or "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
if staged.is_file():
|
||||||
|
v = staged.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
staged.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
raise SystemExit(f"missing {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def cf(method: str, path: str, body: dict | None = None) -> dict:
|
||||||
|
email, key = _creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-put-pages-secrets",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise SystemExit(f"CF API {e.code}: {raw[:500]}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _wrangler_put(name: str, value: str) -> None:
|
||||||
|
email, key = _creds()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
|
||||||
|
env["CI"] = "true"
|
||||||
|
print(f"wrangler pages secret put {name} …", flush=True)
|
||||||
|
cmd = (
|
||||||
|
f'npx --yes wrangler@3 pages secret put {name} '
|
||||||
|
f'--project-name {PROJ}'
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
input=value + "\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=env,
|
||||||
|
cwd=str(FRONTEND if FRONTEND.is_dir() else HERE),
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr or proc.stdout, file=sys.stderr)
|
||||||
|
raise SystemExit(proc.returncode)
|
||||||
|
tail = (proc.stdout or "").strip().splitlines()
|
||||||
|
if tail:
|
||||||
|
print(tail[-1], flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
steam = _secret(
|
||||||
|
"STEAM_API_KEY",
|
||||||
|
["STEAM_API_KEY", "KEYZOO_ASSET_SECRET_WEB_API_KEY"],
|
||||||
|
STEAM_TMP,
|
||||||
|
)
|
||||||
|
session = _secret(
|
||||||
|
"SESSION_SECRET",
|
||||||
|
["SESSION_SECRET", "KEYZOO_ASSET_SECRET_SESSION_SECRET"],
|
||||||
|
SESSION_TMP,
|
||||||
|
)
|
||||||
|
# Official path: wrangler pages secret put (production).
|
||||||
|
_wrangler_put("STEAM_API_KEY", steam)
|
||||||
|
_wrangler_put("SESSION_SECRET", session)
|
||||||
|
|
||||||
|
# Also patch preview so *.pages.dev preview deploys work.
|
||||||
|
proj = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}")
|
||||||
|
result = proj.get("result") or {}
|
||||||
|
dc = result.get("deployment_configs") or {}
|
||||||
|
secrets = {
|
||||||
|
"STEAM_API_KEY": {"type": "secret_text", "value": steam},
|
||||||
|
"SESSION_SECRET": {"type": "secret_text", "value": session},
|
||||||
|
}
|
||||||
|
preview_cfg = dc.get("preview") or {}
|
||||||
|
preview_entry: dict = {"env_vars": secrets}
|
||||||
|
wch = preview_cfg.get("wrangler_config_hash")
|
||||||
|
if wch:
|
||||||
|
preview_entry["wrangler_config_hash"] = wch
|
||||||
|
out = cf(
|
||||||
|
"PATCH",
|
||||||
|
f"/accounts/{ACCT}/pages/projects/{PROJ}",
|
||||||
|
{"deployment_configs": {"preview": preview_entry}},
|
||||||
|
)
|
||||||
|
if not out.get("success"):
|
||||||
|
print("preview patch soft-fail", flush=True)
|
||||||
|
|
||||||
|
check = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}")
|
||||||
|
cdc = (check.get("result") or {}).get("deployment_configs") or {}
|
||||||
|
for env_name in ("production", "preview"):
|
||||||
|
ev = ((cdc.get(env_name) or {}).get("env_vars") or {})
|
||||||
|
names = sorted(ev.keys())
|
||||||
|
types = {
|
||||||
|
k: (ev[k] or {}).get("type") for k in ("STEAM_API_KEY", "SESSION_SECRET")
|
||||||
|
}
|
||||||
|
print(f"{env_name}: env_vars={names} secret_types={types}", flush=True)
|
||||||
|
print("ok", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Put STEAM_API_KEY on climperor-player-sync from staged temp or env."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WORKER = Path(__file__).resolve().parent / "player-sync"
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def put_secret(name: str, value: str, env: dict) -> int:
|
||||||
|
print(f"putting secret {name} …", flush=True)
|
||||||
|
proc = subprocess.run(
|
||||||
|
f"npx --yes wrangler@3 secret put {name}",
|
||||||
|
cwd=str(WORKER),
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
input=value + "\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr or proc.stdout, file=sys.stderr)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
cf_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
steam = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not steam and STAGED.is_file():
|
||||||
|
steam = STAGED.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
STAGED.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not email or not cf_key:
|
||||||
|
print("missing Cloudflare credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not steam:
|
||||||
|
print("missing STEAM_API_KEY (stage via _stage_steam_key.py first)", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = cf_key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
code = put_secret("STEAM_API_KEY", steam, env)
|
||||||
|
print("ok" if code == 0 else "failed", flush=True)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -91,6 +91,12 @@
|
|||||||
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
||||||
],
|
],
|
||||||
"remove": []
|
"remove": []
|
||||||
|
},
|
||||||
|
"tiny": {
|
||||||
|
"add": [
|
||||||
|
{ "item": "hydras_breath", "reason": "瘴毒按最大生命百分比持续消耗高血量小小", "tags": [] }
|
||||||
|
],
|
||||||
|
"remove": ["lotus_orb"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "rules+valve+opendota",
|
"source": "rules+valve+opendota",
|
||||||
"attribution": "derived from items_meta.json + hero_abilities.json",
|
"attribution": "derived from items_meta.json + hero_abilities.json",
|
||||||
"fetched_at": "2026-07-28T16:06:06.680383+00:00",
|
"fetched_at": "2026-07-31T19:14:17.668808+00:00",
|
||||||
"top_n": 8,
|
"top_n": 8,
|
||||||
"heroes": 127,
|
"heroes": 127,
|
||||||
"overrides": "data/hero_fear_overrides.json",
|
"overrides": "data/hero_fear_overrides.json",
|
||||||
@@ -1485,21 +1485,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tiny": [
|
"tiny": [
|
||||||
{
|
|
||||||
"item": "lotus_orb",
|
|
||||||
"name_loc": "清莲宝珠",
|
|
||||||
"tags": [
|
|
||||||
"spell_reflect"
|
|
||||||
],
|
|
||||||
"reason": "反射点目标技能",
|
|
||||||
"stats": {
|
|
||||||
"games": 541,
|
|
||||||
"purchase_rate": 0.164738,
|
|
||||||
"win_rate": 0.57671,
|
|
||||||
"purchase_lift": -0.013412,
|
|
||||||
"win_delta": 0.00486
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"item": "black_king_bar",
|
"item": "black_king_bar",
|
||||||
"name_loc": "黑皇杖",
|
"name_loc": "黑皇杖",
|
||||||
@@ -1515,6 +1500,12 @@
|
|||||||
"win_delta": 0.00552
|
"win_delta": 0.00552
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"item": "hydras_breath",
|
||||||
|
"name_loc": "怪蛇之息",
|
||||||
|
"tags": [],
|
||||||
|
"reason": "瘴毒按最大生命百分比持续消耗高血量小小"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"item": "sphere",
|
"item": "sphere",
|
||||||
"name_loc": "林肯法球",
|
"name_loc": "林肯法球",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"note": "Manual Chinese short names / nicknames merged into items_meta.aliases (search + tooltips). Do not duplicate name_loc."
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"skadi": ["冰眼"],
|
||||||
|
"hydras_breath": ["蛇矛"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "dota2.com.cn/itemscategory+opendota+valve",
|
"source": "dota2.com.cn/itemscategory+opendota+valve",
|
||||||
"layout": "cn_shop_columns",
|
"layout": "cn_shop_columns",
|
||||||
"fetched_at": "2026-07-26T16:51:48.394780+00:00",
|
"fetched_at": "2026-07-31T01:51:04.979414+00:00",
|
||||||
"basic_count": 75,
|
"basic_count": 75,
|
||||||
"upgraded_count": 108,
|
"upgraded_count": 108,
|
||||||
"craft_refs": 171
|
"craft_refs": 171
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "valve+opendota",
|
"source": "valve+opendota",
|
||||||
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
||||||
"fetched_at": "2026-07-28T07:51:14.752604+00:00",
|
"fetched_at": "2026-07-31T19:15:55.325814+00:00",
|
||||||
"tag_order": [
|
"tag_order": [
|
||||||
"basic_dispel",
|
"basic_dispel",
|
||||||
"strong_dispel",
|
"strong_dispel",
|
||||||
@@ -601,7 +601,8 @@
|
|||||||
"desc_loc": "切换:转变\n在25%技能增强加成和250攻击力加成之间切换。\n\n被动:永恒\n 死亡后掉落,而且无法被摧毁。\n\n当拥有者的队友拾取后,仅在返还给拥有者后才会有效。一旦被敌人拾取,圣剑将不再受此限制,信使无法拾取掉落的圣剑。",
|
"desc_loc": "切换:转变\n在25%技能增强加成和250攻击力加成之间切换。\n\n被动:永恒\n 死亡后掉落,而且无法被摧毁。\n\n当拥有者的队友拾取后,仅在返还给拥有者后才会有效。一旦被敌人拾取,圣剑将不再受此限制,信使无法拾取掉落的圣剑。",
|
||||||
"desc_en": "Toggle: Transmute\n Toggle to gain either 25% bonus spell amplification or 250 bonus attack damage.\n\nPassive: Everlasting\n Dropped on death, and cannot be destroyed.\n\nBecomes unusable if picked up by an ally of its owner until it is returned to its owner. It is immediately usable by anybody if an enemy of the owner picks it up and is killed. A dropped Rapier cannot be picked up by a courier.",
|
"desc_en": "Toggle: Transmute\n Toggle to gain either 25% bonus spell amplification or 250 bonus attack damage.\n\nPassive: Everlasting\n Dropped on death, and cannot be destroyed.\n\nBecomes unusable if picked up by an ally of its owner until it is returned to its owner. It is immediately usable by anybody if an enemy of the owner picks it up and is killed. A dropped Rapier cannot be picked up by a courier.",
|
||||||
"notes_loc": [
|
"notes_loc": [
|
||||||
"如果圣剑掉落后被拥有者的敌人拾取,那么只有在敌人死亡后才会再次掉落。"
|
"如果圣剑掉落后被拥有者的敌人拾取,那么只有在敌人死亡后才会再次掉落。",
|
||||||
|
"多个圣剑的技能增强不会叠加。"
|
||||||
],
|
],
|
||||||
"dispellable": 0,
|
"dispellable": 0,
|
||||||
"immunity": 0,
|
"immunity": 0,
|
||||||
@@ -669,11 +670,13 @@
|
|||||||
"dname": "Daedalus",
|
"dname": "Daedalus",
|
||||||
"name_loc": "代达罗斯之殇",
|
"name_loc": "代达罗斯之殇",
|
||||||
"cost": 5100,
|
"cost": 5100,
|
||||||
"desc_loc": "",
|
"desc_loc": "被动:致命一击\n普通攻击有30%几率造成225%伤害。",
|
||||||
"desc_en": "Passive: Critical Strike\nGrants each attack a ?% chance to deal ?% damage.",
|
"desc_en": "Passive: Critical Strike\nGrants each attack a 30% chance to deal 225% damage.",
|
||||||
"notes_loc": [],
|
"notes_loc": [
|
||||||
"dispellable": null,
|
"致命一击对建筑无效。"
|
||||||
"immunity": null,
|
],
|
||||||
|
"dispellable": 0,
|
||||||
|
"immunity": 0,
|
||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
@@ -865,15 +868,18 @@
|
|||||||
"dname": "Eye of Skadi",
|
"dname": "Eye of Skadi",
|
||||||
"name_loc": "斯嘉蒂之眼",
|
"name_loc": "斯嘉蒂之眼",
|
||||||
"cost": 5900,
|
"cost": 5900,
|
||||||
"desc_loc": "被动:霜冻攻击\n攻击对敌方远程目标会减缓-50%移动速度,对敌方近战目标会减缓-25%移动速度。攻击还会降低敌人-20%攻击速度和50%生命回复。持续3秒。",
|
"desc_loc": "被动:霜冻攻击\n攻击对敌方远程目标会减缓-50%移动速度,对敌方近战目标会减缓-25%移动速度。攻击还会降低敌人-25%攻击速度和50%生命回复。持续3秒。",
|
||||||
"desc_en": "Passive: Cold Attack\n Attacks lower enemy movement by -25% if they are melee and -50% if they are ranged. Attacks also lower enemy attack speed by -20% and Health Restoration by 50%. Lasts for 3 seconds.",
|
"desc_en": "Passive: Cold Attack\n Attacks lower enemy movement by -25% if they are melee and -50% if they are ranged. Attacks also lower enemy attack speed by -25% and Health Restoration by 50%. Lasts for 3 seconds.",
|
||||||
"notes_loc": [],
|
"notes_loc": [],
|
||||||
"dispellable": 0,
|
"dispellable": 0,
|
||||||
"immunity": 0,
|
"immunity": 0,
|
||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
"tags": []
|
"tags": [],
|
||||||
|
"aliases": [
|
||||||
|
"冰眼"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"162": {
|
"162": {
|
||||||
"id": 162,
|
"id": 162,
|
||||||
@@ -969,8 +975,8 @@
|
|||||||
"dname": "Mask of Madness",
|
"dname": "Mask of Madness",
|
||||||
"name_loc": "疯狂面具",
|
"name_loc": "疯狂面具",
|
||||||
"cost": 1900,
|
"cost": 1900,
|
||||||
"desc_loc": "主动:狂热\n攻击速度提升100,移动速度提升8%/12%(远程/近战),减速抗性提升30%,但是护甲降低7点,并且被沉默。持续6秒。\n\n被动:吸血\n攻击者每次攻击都将根据造成伤害的一定百分比回复生命值。",
|
"desc_loc": "主动:狂热\n攻击速度提升100,移动速度提升6%/12%(远程/近战),减速抗性提升15%/30%(远程/近战),但是护甲降低7点,并且被沉默。持续6秒。\n\n被动:吸血\n攻击者每次攻击都将根据造成伤害的一定百分比回复生命值。",
|
||||||
"desc_en": "Active: Berserk\nGives 100 attack speed, 8% / 12% movement speed (ranged/melee), and 30% slow resistance, but reduces your armor by 7 and silences you. Lasts 6 seconds.\n\nPassive: Lifesteal\nHeals the attacker for a percentage of physical damage dealt.",
|
"desc_en": "Active: Berserk\nGives 100 attack speed, 6% / 12% movement speed (ranged/melee), and 15%/30% slow resistance (ranged/melee), but reduces your armor by 7 and silences you. Lasts 6 seconds.\n\nPassive: Lifesteal\nHeals the attacker for a percentage of physical damage dealt.",
|
||||||
"notes_loc": [],
|
"notes_loc": [],
|
||||||
"dispellable": 2,
|
"dispellable": 2,
|
||||||
"immunity": 0,
|
"immunity": 0,
|
||||||
@@ -1251,7 +1257,7 @@
|
|||||||
"dname": "Heaven's Halberd",
|
"dname": "Heaven's Halberd",
|
||||||
"name_loc": "天堂之戟",
|
"name_loc": "天堂之戟",
|
||||||
"cost": 3400,
|
"cost": 3400,
|
||||||
"desc_loc": "主动:缴械\n缴械目标在3.5秒内进行攻击。",
|
"desc_loc": "主动:缴械\n阻止目标在3.5秒内进行攻击。",
|
||||||
"desc_en": "Active: Disarm\nPrevents a target from attacking for 3.5 seconds.",
|
"desc_en": "Active: Disarm\nPrevents a target from attacking for 3.5 seconds.",
|
||||||
"notes_loc": [],
|
"notes_loc": [],
|
||||||
"dispellable": 1,
|
"dispellable": 1,
|
||||||
@@ -1555,8 +1561,8 @@
|
|||||||
"dname": "Hurricane Pike",
|
"dname": "Hurricane Pike",
|
||||||
"name_loc": "飓风长戟",
|
"name_loc": "飓风长戟",
|
||||||
"cost": 4450,
|
"cost": 4450,
|
||||||
"desc_loc": "主动:飓风之力\n 将自己和敌方目标朝相反方向各推开425距离,并且接下来对目标的5次攻击将不受英雄攻击距离的限制,同时获得+100攻击速度,持续6秒。\n\n对自身或友军使用,向面对方向推进600距离。\n对敌军施法距离:425",
|
"desc_loc": "主动:飓风之力\n 将自己和敌方目标朝相反方向各推开425距离,并且接下来对目标的5次攻击将不受英雄攻击距离的限制,同时获得+100攻击速度,持续5秒。\n\n对自身或友军使用,向面对方向推进600距离。\n对敌军施法距离:425",
|
||||||
"desc_en": "Active: Hurricane Thrust\n Pushes you and target enemy 425 units away from each other, and for 6 seconds, allows you to make 5 attacks against the target without range restrictions and with +100 attack speed.\n\nCan be cast on self or allies to push the target 600 units in the direction it is facing.\nEnemy Range: 425",
|
"desc_en": "Active: Hurricane Thrust\n Pushes you and target enemy 425 units away from each other, and for 5 seconds, allows you to make 5 attacks against the target without range restrictions and with +100 attack speed.\n\nCan be cast on self or allies to push the target 600 units in the direction it is facing.\nEnemy Range: 425",
|
||||||
"notes_loc": [
|
"notes_loc": [
|
||||||
"对自身施法会对自己使用飓风长戟。",
|
"对自身施法会对自己使用飓风长戟。",
|
||||||
"飓风长戟不会打断目标的动作。",
|
"飓风长戟不会打断目标的动作。",
|
||||||
@@ -2033,10 +2039,10 @@
|
|||||||
"dname": "Consecrated Wraps",
|
"dname": "Consecrated Wraps",
|
||||||
"name_loc": "圣化护服",
|
"name_loc": "圣化护服",
|
||||||
"cost": 2600,
|
"cost": 2600,
|
||||||
"desc_loc": "被动:神圣化\n每4秒获得一点能量,最多为3点。获得一点能量时,移动速度提升15%,持续5秒。\n\n只要受到来自玩家控制单位或肉山的伤害,所有能量都会被消耗,并获得全伤害护盾,持续5秒,每点能量可以吸收120点伤害(最高为360点)。",
|
"desc_loc": "被动:神圣化\n每4秒获得一点能量,最多为3点。获得一点能量时,移动速度提升15%,持续5秒。\n\n只要受到来自玩家控制来源或肉山的伤害,所有能量都会被消耗,并获得全伤害护盾,持续5秒,每点能量可以吸收120点伤害(最高为360点)。",
|
||||||
"desc_en": "Passive: Hallowed\n Gain a charge every 4s, up to a maximum of 3 charges. Upon gaining a charge, your movement speed is increased by 15% for 5s.\n\nWhenever you take damage from a player-controlled unit or Roshan, consume all charges to gain an all damage barrier for 5s that absorbs 120 damage per charge (360 max).",
|
"desc_en": "Passive: Hallowed\n Gain a charge every 4s, up to a maximum of 3 charges. Upon gaining a charge, your movement speed is increased by 15% for 5s.\n\nWhenever you take damage from a player-controlled source or Roshan, consume all charges to gain an all damage barrier for 5s that absorbs 120 damage per charge (360 max).",
|
||||||
"notes_loc": [
|
"notes_loc": [
|
||||||
"受到来自玩家控制单位或肉山的伤害后%stack_gain_time%秒内无法获得能量。",
|
"受到来自玩家控制来源或肉山的伤害后%stack_gain_time%秒内无法获得能量。",
|
||||||
"对自身的伤害和生命流失伤害无法移除能量。",
|
"对自身的伤害和生命流失伤害无法移除能量。",
|
||||||
"移动速度加成效果不会叠加。"
|
"移动速度加成效果不会叠加。"
|
||||||
],
|
],
|
||||||
@@ -2053,8 +2059,8 @@
|
|||||||
"dname": "Crella's Crozier",
|
"dname": "Crella's Crozier",
|
||||||
"name_loc": "克莱拉牧杖",
|
"name_loc": "克莱拉牧杖",
|
||||||
"cost": 4800,
|
"cost": 4800,
|
||||||
"desc_loc": "主动:鲁姆斯克仪式\n进入鬼魂形态,持续4秒,对物理伤害免疫,但是无法攻击,并且承受的魔法伤害增加-30%。\n\n每秒从900范围内的敌方英雄窃取6%移动速度。移动速度的窃取效果持续1.5秒。\n\n腐化光环效果提升至75%。每秒所有减少的生命回复都会转移到身上。\n\n被动:腐化光环\n附近敌方英雄的生命回复减少30%。\n\n作用范围:900",
|
"desc_loc": "主动:鲁姆斯克仪式\n进入鬼魂形态,持续4秒,对物理伤害免疫,但是无法攻击,并且承受的魔法伤害增加-30%。\n\n每秒从900范围内的敌方英雄窃取6%移动速度。移动速度的窃取效果持续2秒。\n\n腐化光环效果提升至90%。每秒所有减少的生命回复都会转移到身上。\n\n被动:腐化光环\n附近敌方英雄的生命回复减少30%。\n\n作用范围:900",
|
||||||
"desc_en": "Active: Rite of Rumusque\n You enter ghost form for 4 seconds, becoming immune to physical damage, but are unable to attack and -30% more vulnerable to magic damage.\n\nSteal 6% movement speed from enemy heroes in 900 range every second. Movement speed steal lasts 1.5s.\n\nPutrefaction Aura's effect is increased to 75%. All of the lost Health Restoration is redirected to you every second.\n\nPassive: Putrefaction Aura\nReduces nearby enemy heroes' Health Restoration by 30%.\n\nRadius: 900",
|
"desc_en": "Active: Rite of Rumusque\n You enter ghost form for 4 seconds, becoming immune to physical damage, but are unable to attack and -30% more vulnerable to magic damage.\n\nSteal 6% movement speed from enemy heroes in 900 range every second. Movement speed steal lasts 2s.\n\nPutrefaction Aura's effect is increased to 90%. All of the lost Health Restoration is redirected to you every second.\n\nPassive: Putrefaction Aura\nReduces nearby enemy heroes' Health Restoration by 30%.\n\nRadius: 900",
|
||||||
"notes_loc": [
|
"notes_loc": [
|
||||||
"如果在开启期间进入减益免疫状态,幽魂效果将会中止。如果在减益免疫状态下开启权杖将不会拥有幽魂效果。",
|
"如果在开启期间进入减益免疫状态,幽魂效果将会中止。如果在减益免疫状态下开启权杖将不会拥有幽魂效果。",
|
||||||
"和幽魂权杖以及升级物品共享冷却时间。",
|
"和幽魂权杖以及升级物品共享冷却时间。",
|
||||||
@@ -2084,7 +2090,10 @@
|
|||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
"tags": []
|
"tags": [],
|
||||||
|
"aliases": [
|
||||||
|
"蛇矛"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"note": "Hand-authored patch readings for Climperor Web. Keep only the latest version entry; replace on each new patch.",
|
||||||
|
"updated_at": "2026-07-31"
|
||||||
|
},
|
||||||
|
"by_version": {
|
||||||
|
"7.41e": {
|
||||||
|
"version": "7.41e",
|
||||||
|
"headline": "本版重点收紧慧核蓝回复,并堵住灵龛等在储藏处挂机充能;缠绕可打断双生门/传送读条。电炎绝手与多核远程降温,飞机/黑鸟/冰魂大招小幅加强;多把圣剑技能增强不再叠加,宙斯神杖刷圣剑玩法基本结束。",
|
||||||
|
"themes": [
|
||||||
|
"缠绕可打断双生门与孽主恶魔之扉持续施法,圣堂陷阱传送也会被打断",
|
||||||
|
"慧光系魔法恢复增强全面下调,纯慧核蓝线更紧",
|
||||||
|
"灵龛/灵瓮/精之灵器在储藏处不再因附近阵亡获得充能",
|
||||||
|
"多把圣剑的技能增强不再叠加,宙斯神杖刷新多圣剑流派难以成立",
|
||||||
|
"诡计之雾伪装时长固定,不再吃增益持续时间增强",
|
||||||
|
"多项开关技能不再破隐(宙斯霹雳之手、美杜莎分裂箭、巨魔姿态、琼英神枪等)"
|
||||||
|
],
|
||||||
|
"buffs": [
|
||||||
|
{ "key": "gyrocopter", "note": "弹幕耗蓝下降,追踪导弹耗蓝与冷却全面改善" },
|
||||||
|
{ "key": "obsidian_destroyer", "note": "责难改瞬时施法且可边走边放,护盾与冷却上调" },
|
||||||
|
{ "key": "ancient_apparition", "note": "冰晶爆轰前两级冷却缩短" },
|
||||||
|
{ "key": "morphling", "note": "变形冷却降低;神杖幻象去掉视野惩罚" },
|
||||||
|
{ "key": "queenofpain", "note": "暗影突袭耗蓝下降;连续超声冲击波伤害可叠加" },
|
||||||
|
{ "key": "death_prophet", "note": "敏捷与攻击力成长上调" },
|
||||||
|
{ "key": "troll_warlord", "note": "基础属性小幅上调;战斗专注附带减速抗性" },
|
||||||
|
{ "key": "venomancer", "note": "毒蛇撕咬初始伤害提升" },
|
||||||
|
{ "key": "omniknight", "note": "纯洁之锤治疗更集中、总量略升" },
|
||||||
|
{ "key": "chaos_knight", "note": "混乱之箭弹道速度提升" }
|
||||||
|
],
|
||||||
|
"nerfs": [
|
||||||
|
{ "key": "snapfire", "note": "天赋与饼干/绝吻/散射多项下调,本版重点降温对象" },
|
||||||
|
{ "key": "drow_ranger", "note": "数箭齐发耗蓝升、伤害降,射程公式重做" },
|
||||||
|
{ "key": "zuus", "note": "大招伤害与霹雳之手攻速下调;叠圣剑刷技能增强被装备改动堵死" },
|
||||||
|
{ "key": "axe", "note": "基础敏捷与战斗饥渴伤害下调" },
|
||||||
|
{ "key": "nevermore", "note": "基础智力、毁灭阴影叠加时长与关键天赋收紧" },
|
||||||
|
{ "key": "hoodwink", "note": "穿心耗蓝与旋镖冷却上调,林渊转向几率后期变慢" },
|
||||||
|
{ "key": "spectre", "note": "鬼影重重与幻象天赋攻击力回调" },
|
||||||
|
{ "key": "necrolyte", "note": "死亡搜寻施法距离缩短,施虐之心高等级恢复变缓" },
|
||||||
|
{ "key": "invoker", "note": "幽灵漫步时长缩短,魔晶不再加范围" },
|
||||||
|
{ "key": "centaur", "note": "反伤力量转化与魔晶状态时长下调" }
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{ "key": "kaya", "note": "慧光及对剑/陨星锤的魔法恢复增强集体下调" },
|
||||||
|
{ "key": "urn_of_shadows", "note": "灵龛系在储藏处不再蹭人头充能;灵龛自身蓝恢复也降" },
|
||||||
|
{ "key": "smoke_of_deceit", "note": "伪装时长固定,不再被增益持续时间延长" },
|
||||||
|
{ "key": "butterfly", "note": "敏捷下调、攻击力上调,属性取向微调" },
|
||||||
|
{ "key": "abyssal_blade", "note": "力量加成提升" },
|
||||||
|
{ "key": "rapier", "note": "多把圣剑技能增强不再叠加,宙斯神杖刷新圣剑流派不复存在" },
|
||||||
|
{ "key": "satanic", "note": "不洁狂热冷却从 30 秒增至 40 秒" },
|
||||||
|
{ "key": "mask_of_madness", "note": "远程移速加成下降,减速抗性改为近战更高" }
|
||||||
|
],
|
||||||
|
"takeaways": [
|
||||||
|
"出门与转场更吃解缠绕:门/传送通道不再能硬顶缠绕读条",
|
||||||
|
"慧核与蓝耗英雄更早感受到慧光系蓝回缩水,出装节奏会前移",
|
||||||
|
"支援装「储藏处挂机充能」被堵,灵龛/灵瓮更依赖当面参团",
|
||||||
|
"电炎绝手、卓尔、宙斯(含刷圣剑)本版优先降权;飞机、黑鸟、冰魂大招等可多看一眼"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"fetched_at": "2026-07-28T14:20:00+00:00",
|
"fetched_at": "2026-07-29T18:37:21.491922+00:00",
|
||||||
"source": "manual+douyin+bilibili",
|
"source": "manual+douyin+bilibili+douyu",
|
||||||
"platform_meta": {
|
"platform_meta": {
|
||||||
"douyin": {
|
"douyin": {
|
||||||
"label_zh": "抖音",
|
"label_zh": "抖音",
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
"bilibili": {
|
"bilibili": {
|
||||||
"label_zh": "哔哩哔哩",
|
"label_zh": "哔哩哔哩",
|
||||||
"icon": "ui-icon/platform_bilibili.png"
|
"icon": "ui-icon/platform_bilibili.png"
|
||||||
|
},
|
||||||
|
"douyu": {
|
||||||
|
"label_zh": "斗鱼",
|
||||||
|
"icon": "ui-icon/platform_douyu.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streamers": [
|
"streamers": [
|
||||||
@@ -60,6 +64,7 @@
|
|||||||
{
|
{
|
||||||
"id": "yaseguilai",
|
"id": "yaseguilai",
|
||||||
"platform": "douyin",
|
"platform": "douyin",
|
||||||
|
"live_url": "https://live.douyin.com/146909015971",
|
||||||
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
|
"profile_url": "https://v.douyin.com/gQDc0B9yPv4/",
|
||||||
"heroes": [
|
"heroes": [
|
||||||
"slark"
|
"slark"
|
||||||
@@ -80,6 +85,7 @@
|
|||||||
{
|
{
|
||||||
"id": "gudu",
|
"id": "gudu",
|
||||||
"platform": "douyin",
|
"platform": "douyin",
|
||||||
|
"live_url": "https://live.douyin.com/750711463576",
|
||||||
"profile_url": "https://v.douyin.com/411MHPZgDeY/",
|
"profile_url": "https://v.douyin.com/411MHPZgDeY/",
|
||||||
"heroes": [
|
"heroes": [
|
||||||
"kez"
|
"kez"
|
||||||
@@ -119,6 +125,7 @@
|
|||||||
{
|
{
|
||||||
"id": "dadigua",
|
"id": "dadigua",
|
||||||
"platform": "douyin",
|
"platform": "douyin",
|
||||||
|
"live_url": "https://live.douyin.com/499355244796",
|
||||||
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
|
"profile_url": "https://v.douyin.com/t1E-4M1xrJ4/",
|
||||||
"heroes": [
|
"heroes": [
|
||||||
"axe"
|
"axe"
|
||||||
@@ -477,6 +484,218 @@
|
|||||||
"profile_fetched_at": "2026-07-29T09:54:00+00:00",
|
"profile_fetched_at": "2026-07-29T09:54:00+00:00",
|
||||||
"is_live": true,
|
"is_live": true,
|
||||||
"live_probed_at": "2026-07-29T09:54:00+00:00"
|
"live_probed_at": "2026-07-29T09:54:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gouhuang",
|
||||||
|
"platform": "douyu",
|
||||||
|
"live_url": "https://www.douyu.com/957090",
|
||||||
|
"profile_url": "https://v.douyu.com/author/EqAvg1lQD75L",
|
||||||
|
"tagline": "米波绝活 · 狗皇",
|
||||||
|
"heroes": [
|
||||||
|
"meepo",
|
||||||
|
"broodmother",
|
||||||
|
"alchemist"
|
||||||
|
],
|
||||||
|
"nickname": "踏上征途167",
|
||||||
|
"unique_id": "957090",
|
||||||
|
"signature": "娱乐DOTA 轻松氛围",
|
||||||
|
"avatar": "streamer_avatars/gouhuang.jpg",
|
||||||
|
"is_live": false,
|
||||||
|
"live_probed_at": "2026-07-29T18:16:27.171378+00:00",
|
||||||
|
"profile_fetched_at": "2026-07-29T18:37:18.228078+00:00",
|
||||||
|
"following_count": 868,
|
||||||
|
"follower_count": 280201,
|
||||||
|
"total_favorited": 9673470
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "juezhongjue",
|
||||||
|
"platform": "bilibili",
|
||||||
|
"live_url": "https://live.bilibili.com/1747114599",
|
||||||
|
"profile_url": "https://space.bilibili.com/520653835",
|
||||||
|
"tagline": "绝活打野炼金",
|
||||||
|
"heroes": [
|
||||||
|
"alchemist"
|
||||||
|
],
|
||||||
|
"nickname": "Dota2绝中绝",
|
||||||
|
"unique_id": "520653835",
|
||||||
|
"signature": "绝活打野炼金。鄙人不擅长对线对线,但极其擅长与野怪掰头!",
|
||||||
|
"follower_count": 433,
|
||||||
|
"avatar": "streamer_avatars/juezhongjue.jpg",
|
||||||
|
"is_live": false,
|
||||||
|
"live_probed_at": "2026-07-29T18:08:20.096701+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "k9",
|
||||||
|
"platform": "douyu",
|
||||||
|
"live_url": "https://www.douyu.com/235520",
|
||||||
|
"profile_url": "https://v.douyu.com/author/JPw9YOLKlw5X",
|
||||||
|
"tagline": "绝活卡尔",
|
||||||
|
"heroes": [
|
||||||
|
"invoker"
|
||||||
|
],
|
||||||
|
"nickname": "18yearsold天残少年K9",
|
||||||
|
"unique_id": "235520",
|
||||||
|
"signature": "k9:午夜牢车教室!.",
|
||||||
|
"avatar": "streamer_avatars/k9.jpg",
|
||||||
|
"is_live": true,
|
||||||
|
"live_probed_at": "2026-07-29T18:16:27.995333+00:00",
|
||||||
|
"following_count": 16,
|
||||||
|
"follower_count": 465781,
|
||||||
|
"total_favorited": 26074078,
|
||||||
|
"profile_fetched_at": "2026-07-29T18:23:59.723287+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "yezi",
|
||||||
|
"platform": "douyu",
|
||||||
|
"live_url": "https://www.douyu.com/246195",
|
||||||
|
"profile_url": "https://v.douyu.com/author/Kqy70jkrpdXG",
|
||||||
|
"tagline": "绝活火猫",
|
||||||
|
"heroes": [
|
||||||
|
"ember_spirit"
|
||||||
|
],
|
||||||
|
"nickname": "叶子长青K",
|
||||||
|
"unique_id": "246195",
|
||||||
|
"signature": "QQ:394867894 网易云搜用户 淡然听听歌",
|
||||||
|
"avatar": "streamer_avatars/yezi.jpg",
|
||||||
|
"is_live": false,
|
||||||
|
"live_probed_at": "2026-07-29T18:16:28.416689+00:00",
|
||||||
|
"profile_fetched_at": "2026-07-29T18:37:20.691635+00:00",
|
||||||
|
"following_count": 134,
|
||||||
|
"follower_count": 286391,
|
||||||
|
"total_favorited": 6336746
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "zhedaxiaoyuwang",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/tSe_vsZ8r7o/",
|
||||||
|
"tagline": "小鱼人",
|
||||||
|
"heroes": [
|
||||||
|
"slark"
|
||||||
|
],
|
||||||
|
"nickname": "浙大小鱼王—dota2",
|
||||||
|
"unique_id": "89748079578",
|
||||||
|
"signature": "本硕985,浙大在读 9700冲万分",
|
||||||
|
"following_count": 52,
|
||||||
|
"follower_count": 1802,
|
||||||
|
"total_favorited": 842,
|
||||||
|
"avatar": "streamer_avatars/zhedaxiaoyuwang.jpg",
|
||||||
|
"profile_fetched_at": "2026-07-29T18:26:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xiaolu",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/wLV8LDRwC8k/",
|
||||||
|
"tagline": "科研小鹿",
|
||||||
|
"heroes": [
|
||||||
|
"enchantress"
|
||||||
|
],
|
||||||
|
"nickname": "科颜熊",
|
||||||
|
"unique_id": "894277260",
|
||||||
|
"signature": "玩点不一样的dota 周更1~2期dota冷知识",
|
||||||
|
"following_count": 82,
|
||||||
|
"follower_count": 7263,
|
||||||
|
"total_favorited": 71000,
|
||||||
|
"avatar": "streamer_avatars/xiaolu.jpg",
|
||||||
|
"video": "streamer_videos/xiaolu.mp4",
|
||||||
|
"video_aspect": "544/960",
|
||||||
|
"video_fit": "cover",
|
||||||
|
"video_title": "【科研】冠绝游龙中单小鹿",
|
||||||
|
"profile_fetched_at": "2026-07-30T09:14:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "emo",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/VYTQ95nmB-M/",
|
||||||
|
"tagline": "火女教学 · Emo",
|
||||||
|
"heroes": [
|
||||||
|
"lina"
|
||||||
|
],
|
||||||
|
"nickname": "emo219",
|
||||||
|
"unique_id": "62189255381",
|
||||||
|
"signature": "前iG LGD职业中单\n2021新加坡major冠军 Ti10殿军\n合作 z135790x (不是本人,注明来意)\n每天晚上7-8点开播到凌晨",
|
||||||
|
"following_count": 43,
|
||||||
|
"follower_count": 25000,
|
||||||
|
"total_favorited": 7745,
|
||||||
|
"avatar": "streamer_avatars/emo.jpg",
|
||||||
|
"video": "streamer_videos/emo.mp4",
|
||||||
|
"video_aspect": "1280/720",
|
||||||
|
"video_title": "Emo教学系列 · 火女 Lina",
|
||||||
|
"profile_fetched_at": "2026-07-30T10:16:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nailv",
|
||||||
|
"platform": "douyin",
|
||||||
|
"live_url": "https://live.douyin.com/844269724484",
|
||||||
|
"profile_url": "https://v.douyin.com/i6kbtAc7OwY/",
|
||||||
|
"tagline": "奶绿王",
|
||||||
|
"heroes": [
|
||||||
|
"muerta"
|
||||||
|
],
|
||||||
|
"nickname": "dota2不落💭",
|
||||||
|
"unique_id": "CoxcomB69515",
|
||||||
|
"signature": "奶绿王!娱乐兼技术主播,每天都直播!上车打号可私!",
|
||||||
|
"following_count": 38,
|
||||||
|
"follower_count": 4710,
|
||||||
|
"total_favorited": 1127,
|
||||||
|
"avatar": "streamer_avatars/nailv.jpg",
|
||||||
|
"video": "streamer_videos/nailv.mp4",
|
||||||
|
"video_aspect": "1280/720",
|
||||||
|
"video_title": "暴走就是那么简单!哪里有人点哪里!",
|
||||||
|
"profile_fetched_at": "2026-07-30T12:23:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "baixi",
|
||||||
|
"platform": "douyin",
|
||||||
|
"live_url": "https://live.douyin.com/433962392415",
|
||||||
|
"profile_url": "https://v.douyin.com/eiq1FHV8gdI/",
|
||||||
|
"tagline": "百戏大王",
|
||||||
|
"heroes": [
|
||||||
|
"ringmaster"
|
||||||
|
],
|
||||||
|
"nickname": "百戏大王",
|
||||||
|
"unique_id": "meng289949554",
|
||||||
|
"signature": "不需要你教我玩百戏。",
|
||||||
|
"following_count": 5351,
|
||||||
|
"follower_count": 559,
|
||||||
|
"total_favorited": 59,
|
||||||
|
"avatar": "streamer_avatars/baixi.jpg",
|
||||||
|
"profile_fetched_at": "2026-08-01T00:00:00+00:00",
|
||||||
|
"is_live": true,
|
||||||
|
"live_probed_at": "2026-08-01T00:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xianzhi",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/Ye1g2PqAbyQ/",
|
||||||
|
"tagline": "先知",
|
||||||
|
"heroes": [
|
||||||
|
"furion"
|
||||||
|
],
|
||||||
|
"nickname": "小小要单排",
|
||||||
|
"unique_id": "rtdfke38051",
|
||||||
|
"signature": "我也有喜欢的人了",
|
||||||
|
"following_count": 1211,
|
||||||
|
"follower_count": 65000,
|
||||||
|
"total_favorited": 78000,
|
||||||
|
"avatar": "streamer_avatars/xianzhi.jpg",
|
||||||
|
"profile_fetched_at": "2026-08-01T00:00:00+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bingnv",
|
||||||
|
"platform": "douyin",
|
||||||
|
"profile_url": "https://v.douyin.com/0f4UYn_dfig/",
|
||||||
|
"tagline": "冰女",
|
||||||
|
"heroes": [
|
||||||
|
"crystal_maiden"
|
||||||
|
],
|
||||||
|
"nickname": "烤烤你的鸭",
|
||||||
|
"unique_id": "213145729",
|
||||||
|
"signature": "9000分国服百强辅助,在线讲解所有辅助知识 直播不定时中单",
|
||||||
|
"following_count": 156,
|
||||||
|
"follower_count": 1599,
|
||||||
|
"total_favorited": 410,
|
||||||
|
"avatar": "streamer_avatars/bingnv.jpg",
|
||||||
|
"profile_fetched_at": "2026-08-01T00:00:00+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ def check_integrity(dist: Path) -> None:
|
|||||||
tag = "MISSING (abort)" if required else "empty (warn)"
|
tag = "MISSING (abort)" if required else "empty (warn)"
|
||||||
problems.append(f" {sub}/: {tag}")
|
problems.append(f" {sub}/: {tag}")
|
||||||
hint = {
|
hint = {
|
||||||
"portrait": "run: python fetch_cdn_templates.py && python fetch_hero_portraits.py",
|
"portrait": "run: python fetch_hero_portraits.py",
|
||||||
"ability": "run: python fetch_hero_abilities.py --icons-only",
|
"ability": "run: python fetch_hero_abilities.py --icons-only",
|
||||||
"item": "run: python fetch_hero_items.py",
|
"item": "run: python fetch_hero_items.py",
|
||||||
"attr": "assets/attr_icons is committed; check git checkout",
|
"attr": "assets/attr_icons is committed; check git checkout",
|
||||||
@@ -409,6 +409,42 @@ def bind_domain(
|
|||||||
ensure_cname(email, api_key, domain, project)
|
ensure_cname(email, api_key, domain, project)
|
||||||
|
|
||||||
|
|
||||||
|
def check_player_bindings(email: str, api_key: str, account_id: str, project: str) -> None:
|
||||||
|
"""Warn (do not abort) if D1/Queue bindings for player pages are missing."""
|
||||||
|
data = cf_api(
|
||||||
|
"GET",
|
||||||
|
f"/accounts/{account_id}/pages/projects/{project}",
|
||||||
|
email=email,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
if not data.get("success"):
|
||||||
|
print("warning: could not verify Pages player bindings")
|
||||||
|
return
|
||||||
|
prod = ((data.get("result") or {}).get("deployment_configs") or {}).get(
|
||||||
|
"production"
|
||||||
|
) or {}
|
||||||
|
d1 = prod.get("d1_databases") or {}
|
||||||
|
queues = prod.get("queue_producers") or {}
|
||||||
|
r2 = prod.get("r2_buckets") or {}
|
||||||
|
missing = []
|
||||||
|
if "DB" not in d1:
|
||||||
|
missing.append("D1:DB")
|
||||||
|
if "SYNC_QUEUE" not in queues:
|
||||||
|
missing.append("Queue:SYNC_QUEUE")
|
||||||
|
if missing:
|
||||||
|
print(
|
||||||
|
"warning: Pages missing player bindings "
|
||||||
|
f"{', '.join(missing)} — run python web/cloudflare/provision.py"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("pages player bindings: DB + SYNC_QUEUE ok")
|
||||||
|
if "MATCHES" not in r2:
|
||||||
|
print(
|
||||||
|
"note: R2 MATCHES not bound yet "
|
||||||
|
"(enable R2 in Dashboard, then re-run provision.py)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
|
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
|
||||||
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
|
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
|
||||||
@@ -441,6 +477,7 @@ def main() -> None:
|
|||||||
print(f"account_id: {account_id}")
|
print(f"account_id: {account_id}")
|
||||||
|
|
||||||
ensure_project(email, api_key, account_id, args.project_name)
|
ensure_project(email, api_key, account_id, args.project_name)
|
||||||
|
check_player_bindings(email, api_key, account_id, args.project_name)
|
||||||
|
|
||||||
if not args.no_export:
|
if not args.no_export:
|
||||||
run_export(args.ability_video_base, args.static_asset_base)
|
run_export(args.ability_video_base, args.static_asset_base)
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python export_relations_site.py [--out dist/relations] [--with-videos]
|
python export_relations_site.py [--out dist/relations] [--with-videos]
|
||||||
[--ability-video-base URL] [--static-asset-base URL]
|
[--ability-video-base URL] [--static-asset-base URL] [--site-origin URL]
|
||||||
|
|
||||||
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
|
Copies web/frontend/ + a snapshot of the /api/data payload (data.json) +
|
||||||
the referenced image assets into one directory, ready for any static host
|
the referenced image assets into one directory, ready for any static host
|
||||||
(GitHub Pages, Cloudflare Pages, nginx, ...).
|
(GitHub Pages, Cloudflare Pages, nginx, ...).
|
||||||
|
|
||||||
|
Also runs seo_prerender: crawlable HTML for heroes/mechanics/top pages,
|
||||||
|
plus sitemap.xml / llms.txt / robots.txt / _redirects (History SPA).
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Only already-cached assets are exported. For full ability-icon coverage
|
- Only already-cached assets are exported. For full ability-icon coverage
|
||||||
run `python fetch_hero_abilities.py --icons-only` first.
|
run `python fetch_hero_abilities.py --icons-only` first.
|
||||||
@@ -45,14 +48,14 @@ from shared.paths import (
|
|||||||
ROOT,
|
ROOT,
|
||||||
STREAMER_AVATARS,
|
STREAMER_AVATARS,
|
||||||
STREAMER_VIDEOS,
|
STREAMER_VIDEOS,
|
||||||
TEMPLATES_CDN,
|
|
||||||
UI_ICONS,
|
UI_ICONS,
|
||||||
WEB_DIST,
|
WEB_DIST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||||
from serve_relations import WEB_DIR, build_payload
|
from serve_relations import WEB_DIR, build_payload
|
||||||
|
|
||||||
SITE_VERSION = "0.5.109"
|
SITE_VERSION = "0.6.57"
|
||||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||||
|
|
||||||
|
|
||||||
@@ -102,28 +105,40 @@ def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
|
|||||||
n_vid += 1
|
n_vid += 1
|
||||||
counts["streamer-video"] = n_vid
|
counts["streamer-video"] = n_vid
|
||||||
|
|
||||||
|
# Wide Heroes-page cards only — never fall back to pc/templates/cdn
|
||||||
|
# (96×96 match crops). That fallback previously overwrote OSS portraits.
|
||||||
portrait_dst = out / "portrait"
|
portrait_dst = out / "portrait"
|
||||||
portrait_dst.mkdir(exist_ok=True)
|
portrait_dst.mkdir(exist_ok=True)
|
||||||
n_portrait = 0
|
n_portrait = 0
|
||||||
|
missing: list[str] = []
|
||||||
for hero in payload.get("heroes") or []:
|
for hero in payload.get("heroes") or []:
|
||||||
key = hero.get("key")
|
key = hero.get("key")
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
src = HERO_PORTRAITS / f"{key}.png"
|
src = HERO_PORTRAITS / f"{key}.png"
|
||||||
if not src.is_file():
|
|
||||||
src = TEMPLATES_CDN / f"{key}.png"
|
|
||||||
if src.is_file():
|
if src.is_file():
|
||||||
shutil.copy2(src, portrait_dst / f"{key}.png")
|
shutil.copy2(src, portrait_dst / f"{key}.png")
|
||||||
n_portrait += 1
|
n_portrait += 1
|
||||||
|
else:
|
||||||
|
missing.append(key)
|
||||||
|
# Patch-only units (e.g. spirit_bear) are optional; copy when present.
|
||||||
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
|
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
|
||||||
key = cell.get("key") if isinstance(cell, dict) else None
|
key = cell.get("key") if isinstance(cell, dict) else None
|
||||||
if not key:
|
if not key or (portrait_dst / f"{key}.png").is_file():
|
||||||
continue
|
continue
|
||||||
src = HERO_PORTRAITS / f"{key}.png"
|
src = HERO_PORTRAITS / f"{key}.png"
|
||||||
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
|
if src.is_file():
|
||||||
shutil.copy2(src, portrait_dst / f"{key}.png")
|
shutil.copy2(src, portrait_dst / f"{key}.png")
|
||||||
n_portrait += 1
|
n_portrait += 1
|
||||||
counts["portrait"] = n_portrait
|
counts["portrait"] = n_portrait
|
||||||
|
if missing:
|
||||||
|
sample = ", ".join(missing[:8])
|
||||||
|
more = f" (+{len(missing) - 8} more)" if len(missing) > 8 else ""
|
||||||
|
raise SystemExit(
|
||||||
|
f"missing {len(missing)} wide hero portrait(s) under "
|
||||||
|
f"{HERO_PORTRAITS}: {sample}{more}. "
|
||||||
|
"Run: python web/fetch_hero_portraits.py"
|
||||||
|
)
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
@@ -133,16 +148,22 @@ def write_config_js(
|
|||||||
ability_video_base: str,
|
ability_video_base: str,
|
||||||
static_asset_base: str,
|
static_asset_base: str,
|
||||||
site_version: str,
|
site_version: str,
|
||||||
|
site_origin: str,
|
||||||
|
players_asset_base: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write config.js consumed by app.js."""
|
"""Write config.js consumed by app.js."""
|
||||||
video = (ability_video_base or "").strip().rstrip("/")
|
video = (ability_video_base or "").strip().rstrip("/")
|
||||||
static = (static_asset_base or "").strip().rstrip("/")
|
static = (static_asset_base or "").strip().rstrip("/")
|
||||||
|
players = (players_asset_base or "").strip().rstrip("/") or static
|
||||||
ver = (site_version or "").strip()
|
ver = (site_version or "").strip()
|
||||||
|
origin = (site_origin or "").strip().rstrip("/")
|
||||||
(out / "config.js").write_text(
|
(out / "config.js").write_text(
|
||||||
"/* generated by export_relations_site.py — do not edit */\n"
|
"/* generated by export_relations_site.py — do not edit */\n"
|
||||||
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
|
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
|
||||||
|
f"var SITE_ORIGIN = {json.dumps(origin, ensure_ascii=False)};\n"
|
||||||
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
|
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
|
||||||
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
|
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n"
|
||||||
|
f"var PLAYERS_ASSET_BASE = {json.dumps(players, ensure_ascii=False)};\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,6 +191,12 @@ def main() -> None:
|
|||||||
"when set, image dirs are not copied into dist; "
|
"when set, image dirs are not copied into dist; "
|
||||||
"falls back to STATIC_ASSET_BASE env, else empty",
|
"falls back to STATIC_ASSET_BASE env, else empty",
|
||||||
)
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--site-origin",
|
||||||
|
default=None,
|
||||||
|
help="canonical site origin for SEO (sitemap / og / config SITE_ORIGIN); "
|
||||||
|
"falls back to SITE_ORIGIN env, else https://dota2.refining.dev",
|
||||||
|
)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
video_base = (
|
video_base = (
|
||||||
@@ -182,6 +209,11 @@ def main() -> None:
|
|||||||
if args.static_asset_base is not None
|
if args.static_asset_base is not None
|
||||||
else os.environ.get("STATIC_ASSET_BASE", "")
|
else os.environ.get("STATIC_ASSET_BASE", "")
|
||||||
)
|
)
|
||||||
|
site_origin = (
|
||||||
|
args.site_origin
|
||||||
|
if args.site_origin is not None
|
||||||
|
else os.environ.get("SITE_ORIGIN", DEFAULT_SITE_ORIGIN)
|
||||||
|
)
|
||||||
|
|
||||||
out = Path(args.out).resolve()
|
out = Path(args.out).resolve()
|
||||||
if out == ROOT.resolve() or out.parent == out:
|
if out == ROOT.resolve() or out.parent == out:
|
||||||
@@ -190,10 +222,22 @@ def main() -> None:
|
|||||||
shutil.rmtree(out)
|
shutil.rmtree(out)
|
||||||
out.mkdir(parents=True)
|
out.mkdir(parents=True)
|
||||||
|
|
||||||
for name in ("index.html", "router.js", "app.js", "style.css", "mobile-gate.js", "_headers"):
|
for name in (
|
||||||
|
"index.html",
|
||||||
|
"router.js",
|
||||||
|
"app.js",
|
||||||
|
"style.css",
|
||||||
|
"mobile-gate.js",
|
||||||
|
"_headers",
|
||||||
|
"_redirects",
|
||||||
|
"robots.txt",
|
||||||
|
):
|
||||||
src = WEB_DIR / name
|
src = WEB_DIR / name
|
||||||
if src.is_file():
|
if src.is_file():
|
||||||
shutil.copy2(src, out / name)
|
shutil.copy2(src, out / name)
|
||||||
|
fonts_src = WEB_DIR / "fonts"
|
||||||
|
if fonts_src.is_dir():
|
||||||
|
shutil.copytree(fonts_src, out / "fonts")
|
||||||
# Cloudflare Pages Functions (functions/api/*.js -> /api/*).
|
# Cloudflare Pages Functions (functions/api/*.js -> /api/*).
|
||||||
functions_src = WEB_DIR / "functions"
|
functions_src = WEB_DIR / "functions"
|
||||||
n_functions = 0
|
n_functions = 0
|
||||||
@@ -205,6 +249,7 @@ def main() -> None:
|
|||||||
ability_video_base=video_base,
|
ability_video_base=video_base,
|
||||||
static_asset_base=static_base,
|
static_asset_base=static_base,
|
||||||
site_version=SITE_VERSION,
|
site_version=SITE_VERSION,
|
||||||
|
site_origin=site_origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = build_payload()
|
payload = build_payload()
|
||||||
@@ -217,6 +262,14 @@ def main() -> None:
|
|||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
template_html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
|
seo_counts = write_seo_bundle(
|
||||||
|
out,
|
||||||
|
template_html,
|
||||||
|
payload,
|
||||||
|
site_origin=site_origin,
|
||||||
|
)
|
||||||
|
|
||||||
if static_base:
|
if static_base:
|
||||||
counts = {
|
counts = {
|
||||||
name: 0
|
name: 0
|
||||||
@@ -244,13 +297,19 @@ def main() -> None:
|
|||||||
print(f"exported static site -> {out}")
|
print(f"exported static site -> {out}")
|
||||||
for name, n in counts.items():
|
for name, n in counts.items():
|
||||||
print(f" {name}/: {n} files")
|
print(f" {name}/: {n} files")
|
||||||
|
print(
|
||||||
|
f" seo prerender: top={seo_counts['top']} "
|
||||||
|
f"heroes={seo_counts['heroes']} mechanics={seo_counts['mechanics']} "
|
||||||
|
f"(+ sitemap.xml / llms.txt / robots.txt)"
|
||||||
|
)
|
||||||
if n_functions:
|
if n_functions:
|
||||||
print(f" functions/: {n_functions} files (Pages Functions)")
|
print(f" functions/: {n_functions} files (Pages Functions)")
|
||||||
if args.with_videos:
|
if args.with_videos:
|
||||||
print(f" ability-video/: {n_videos} files")
|
print(f" ability-video/: {n_videos} files")
|
||||||
print(
|
print(
|
||||||
f" config.js SITE_VERSION={SITE_VERSION!r} "
|
f" config.js SITE_VERSION={SITE_VERSION!r} SITE_ORIGIN={site_origin!r} "
|
||||||
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r} "
|
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r} "
|
||||||
|
f"PLAYERS_ASSET_BASE={static_base!r}"
|
||||||
)
|
)
|
||||||
print(f" total: {total / 1e6:.1f} MB")
|
print(f" total: {total / 1e6:.1f} MB")
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -61,6 +61,34 @@ from shared.http_utils import download_icons, http_json, write_json_atomic
|
|||||||
from shared.paths import DATA, ITEM_ICONS
|
from shared.paths import DATA, ITEM_ICONS
|
||||||
|
|
||||||
OPENDOTA = "https://api.opendota.com/api"
|
OPENDOTA = "https://api.opendota.com/api"
|
||||||
|
# Consecutive OpenDota 429s before aborting the remaining hero batch.
|
||||||
|
_429_STREAK = 0
|
||||||
|
_429_STREAK_LIMIT = 3
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitTripped(Exception):
|
||||||
|
"""Enough consecutive OpenDota 429s to stop the rest of this run."""
|
||||||
|
|
||||||
|
|
||||||
|
def _opendota_json(url: str):
|
||||||
|
"""Fail-fast OpenDota JSON (no 5/10/20/40s retry chain on 429)."""
|
||||||
|
global _429_STREAK
|
||||||
|
try:
|
||||||
|
data = http_json(url, retries=0)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429:
|
||||||
|
_429_STREAK += 1
|
||||||
|
print(
|
||||||
|
f"HTTP 429 {url} — streak {_429_STREAK}/{_429_STREAK_LIMIT}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if _429_STREAK >= _429_STREAK_LIMIT:
|
||||||
|
raise RateLimitTripped(
|
||||||
|
f"opendota 429 x{_429_STREAK}"
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
|
_429_STREAK = 0
|
||||||
|
return data
|
||||||
ABILITY_IDS_URL = (
|
ABILITY_IDS_URL = (
|
||||||
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/ability_ids.json"
|
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/ability_ids.json"
|
||||||
)
|
)
|
||||||
@@ -605,7 +633,9 @@ def opendota_url(path: str) -> str:
|
|||||||
|
|
||||||
def fetch_match(match_id: int) -> dict | None:
|
def fetch_match(match_id: int) -> dict | None:
|
||||||
try:
|
try:
|
||||||
raw = http_json(opendota_url(f"/matches/{match_id}"))
|
raw = _opendota_json(opendota_url(f"/matches/{match_id}"))
|
||||||
|
except RateLimitTripped:
|
||||||
|
raise
|
||||||
except (
|
except (
|
||||||
urllib.error.HTTPError,
|
urllib.error.HTTPError,
|
||||||
urllib.error.URLError,
|
urllib.error.URLError,
|
||||||
@@ -620,7 +650,9 @@ def fetch_match(match_id: int) -> dict | None:
|
|||||||
def league_match_ids(hero_id: int, limit: int) -> list[dict]:
|
def league_match_ids(hero_id: int, limit: int) -> list[dict]:
|
||||||
"""Return win list metas from /heroes/{id}/matches (up to candidate cap)."""
|
"""Return win list metas from /heroes/{id}/matches (up to candidate cap)."""
|
||||||
try:
|
try:
|
||||||
raw = http_json(opendota_url(f"/heroes/{hero_id}/matches"))
|
raw = _opendota_json(opendota_url(f"/heroes/{hero_id}/matches"))
|
||||||
|
except RateLimitTripped:
|
||||||
|
raise
|
||||||
except (
|
except (
|
||||||
urllib.error.HTTPError,
|
urllib.error.HTTPError,
|
||||||
urllib.error.URLError,
|
urllib.error.URLError,
|
||||||
@@ -700,7 +732,9 @@ def public_match_ids(
|
|||||||
sep = "&" if "?" in url else "?"
|
sep = "&" if "?" in url else "?"
|
||||||
url = f"{url}{sep}less_than_match_id={less_than}"
|
url = f"{url}{sep}less_than_match_id={less_than}"
|
||||||
try:
|
try:
|
||||||
raw = http_json(url)
|
raw = _opendota_json(url)
|
||||||
|
except RateLimitTripped:
|
||||||
|
raise
|
||||||
except (
|
except (
|
||||||
urllib.error.HTTPError,
|
urllib.error.HTTPError,
|
||||||
urllib.error.URLError,
|
urllib.error.URLError,
|
||||||
@@ -1401,6 +1435,27 @@ def main() -> None:
|
|||||||
public_region=public_region,
|
public_region=public_region,
|
||||||
workers=workers,
|
workers=workers,
|
||||||
)
|
)
|
||||||
|
except RateLimitTripped as exc:
|
||||||
|
print(
|
||||||
|
f" rate-limited; keeping prior cache for remaining ({exc})",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
prior = existing.get("by_hero") if isinstance(existing, dict) else {}
|
||||||
|
if not isinstance(prior, dict):
|
||||||
|
prior = {}
|
||||||
|
for rest_key in pending[i - 1 :]:
|
||||||
|
prev = prior.get(rest_key)
|
||||||
|
if prev is not None:
|
||||||
|
by_hero[rest_key] = prev
|
||||||
|
write_out(
|
||||||
|
args.out,
|
||||||
|
by_hero,
|
||||||
|
source=args.source,
|
||||||
|
limit=limit,
|
||||||
|
item_catalog=item_catalog,
|
||||||
|
public_region=public_region,
|
||||||
|
)
|
||||||
|
break
|
||||||
except (
|
except (
|
||||||
urllib.error.HTTPError,
|
urllib.error.HTTPError,
|
||||||
urllib.error.URLError,
|
urllib.error.URLError,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import urllib.error
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from shared.grid import hero_table
|
from shared.grid import hero_table
|
||||||
@@ -169,7 +170,15 @@ def main() -> None:
|
|||||||
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
||||||
|
|
||||||
print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True)
|
print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True)
|
||||||
raw = http_json(OPENDOTA_HERO_STATS)
|
# Fail-fast on 429: do not burn the shared 5/10/20/40s retry budget.
|
||||||
|
try:
|
||||||
|
raw = http_json(OPENDOTA_HERO_STATS, retries=0)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429:
|
||||||
|
raise SystemExit(
|
||||||
|
"OpenDota rate-limited (HTTP 429); keeping previous cache"
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
|
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|||||||
@@ -337,8 +337,17 @@ def main() -> None:
|
|||||||
saved, skipped, fail = download_shop_icons(icon_keys, force=args.force_icons)
|
saved, skipped, fail = download_shop_icons(icon_keys, force=args.force_icons)
|
||||||
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
|
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
|
||||||
print(f"downloading {len(cat_files)} category icons...", flush=True)
|
print(f"downloading {len(cat_files)} category icons...", flush=True)
|
||||||
|
# CAT_ICON_BY_LABEL values already include ".png"; download_icons appends
|
||||||
|
# another ".png", so pass bare stems + a full URL template.
|
||||||
|
cat_keys = {
|
||||||
|
name[:-4] if name.endswith(".png") else name for name in cat_files
|
||||||
|
}
|
||||||
c_saved, c_skipped, c_fail = download_icons(
|
c_saved, c_skipped, c_fail = download_icons(
|
||||||
cat_files, CAT_ICON_BASE + "{key}", ITEM_CAT_ICONS, force=args.force_icons, delay=0.05
|
cat_keys,
|
||||||
|
CAT_ICON_BASE + "{key}.png",
|
||||||
|
ITEM_CAT_ICONS,
|
||||||
|
force=args.force_icons,
|
||||||
|
delay=0.05,
|
||||||
)
|
)
|
||||||
print(f" cat icons saved={c_saved} skipped={c_skipped} fail={c_fail}", flush=True)
|
print(f" cat icons saved={c_saved} skipped={c_skipped} fail={c_fail}", flush=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
|
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
|
||||||
|
|
||||||
|
Also merges Chinese nicknames from data/item_alias_overrides.json → aliases.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- OpenDota items.json (structure, EN ability text)
|
- OpenDota items.json (structure, EN ability text)
|
||||||
- Valve itemlist / itemdata (schinese names + descriptions)
|
- Valve itemlist / itemdata (schinese names + descriptions)
|
||||||
@@ -43,6 +45,7 @@ ICON_URL = (
|
|||||||
)
|
)
|
||||||
OUT = DATA / "items_meta.json"
|
OUT = DATA / "items_meta.json"
|
||||||
OVERRIDES = DATA / "item_tag_overrides.json"
|
OVERRIDES = DATA / "item_tag_overrides.json"
|
||||||
|
ALIAS_OVERRIDES = DATA / "item_alias_overrides.json"
|
||||||
|
|
||||||
MIN_CREATED_COST = 1400
|
MIN_CREATED_COST = 1400
|
||||||
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
|
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
|
||||||
@@ -262,6 +265,38 @@ def apply_overrides(key: str, tags: list[str], overrides: dict[str, dict]) -> li
|
|||||||
return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER)
|
return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def load_alias_overrides() -> dict[str, list[str]]:
|
||||||
|
"""key → Chinese nicknames (冰眼 / 蛇矛); empty if file missing."""
|
||||||
|
if not ALIAS_OVERRIDES.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(ALIAS_OVERRIDES.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for key, aliases in (raw.get("items") or {}).items():
|
||||||
|
if not isinstance(aliases, list):
|
||||||
|
continue
|
||||||
|
clean = [str(a).strip() for a in aliases if str(a).strip()]
|
||||||
|
if clean:
|
||||||
|
out[str(key)] = list(dict.fromkeys(clean))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_alias_overrides(items_out: dict[str, dict], aliases: dict[str, list[str]]) -> None:
|
||||||
|
for row in items_out.values():
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
key = str(row.get("key") or "")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
nick = aliases.get(key)
|
||||||
|
if nick:
|
||||||
|
row["aliases"] = list(nick)
|
||||||
|
else:
|
||||||
|
row.pop("aliases", None)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--delay", type=float, default=0.15)
|
ap.add_argument("--delay", type=float, default=0.15)
|
||||||
@@ -301,6 +336,7 @@ def main() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
overrides = load_overrides()
|
overrides = load_overrides()
|
||||||
|
alias_overrides = load_alias_overrides()
|
||||||
items_out: dict[str, dict] = {}
|
items_out: dict[str, dict] = {}
|
||||||
pending = []
|
pending = []
|
||||||
for iid in sorted(candidates):
|
for iid in sorted(candidates):
|
||||||
@@ -408,6 +444,7 @@ def main() -> None:
|
|||||||
}
|
}
|
||||||
write_json_atomic(args.out, payload)
|
write_json_atomic(args.out, payload)
|
||||||
|
|
||||||
|
apply_alias_overrides(items_out, alias_overrides)
|
||||||
payload = {
|
payload = {
|
||||||
"meta": {
|
"meta": {
|
||||||
"source": "valve+opendota",
|
"source": "valve+opendota",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from shared.http_utils import http_bytes, http_json, write_json_atomic
|
from shared.http_utils import download_icons, http_bytes, http_json, write_json_atomic
|
||||||
from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
|
from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
|
||||||
|
|
||||||
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
|
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
|
||||||
@@ -336,8 +336,6 @@ def download_unit_portraits(*, delay: float) -> None:
|
|||||||
|
|
||||||
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
||||||
"""Pull referenced item + ability icons into assets/ (skip existing)."""
|
"""Pull referenced item + ability icons into assets/ (skip existing)."""
|
||||||
from http_utils import download_icons
|
|
||||||
|
|
||||||
item_keys = {v["key"] for v in (lookup.get("items") or {}).values() if v.get("key")}
|
item_keys = {v["key"] for v in (lookup.get("items") or {}).values() if v.get("key")}
|
||||||
# Recipe scrolls share one generic icon on Steam CDN.
|
# Recipe scrolls share one generic icon on Steam CDN.
|
||||||
recipe_keys = {k for k in item_keys if k.startswith("recipe_")}
|
recipe_keys = {k for k in item_keys if k.startswith("recipe_")}
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ Per player: /players/{id}/matches per lobby_type (practice/tournament, and
|
|||||||
ranked with --include-pubs), up to --limit newest each; then /matches/{id}
|
ranked with --include-pubs), up to --limit newest each; then /matches/{id}
|
||||||
for final items + skill builds. League rows do not crowd out ranked pubs.
|
for final items + skill builds. League rows do not crowd out ranked pubs.
|
||||||
|
|
||||||
|
Daily refresh uses --refresh-limit to rotate the oldest / missing pros so the
|
||||||
|
OpenDota anonymous quota is not burned on a full 90-player crawl every night.
|
||||||
|
Optional OPENDOTA_API_KEY raises rate limits. Consecutive 429s trip a circuit
|
||||||
|
breaker: remaining pros keep prior cache and the script still writes.
|
||||||
|
|
||||||
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
|
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python web/fetch_pro_matches.py
|
python web/fetch_pro_matches.py
|
||||||
|
python web/fetch_pro_matches.py --include-pubs --refresh-limit 15
|
||||||
python web/fetch_pro_matches.py --include-pubs --limit 8
|
python web/fetch_pro_matches.py --include-pubs --limit 8
|
||||||
python web/fetch_pro_matches.py --players 898754153,Ame
|
python web/fetch_pro_matches.py --players 898754153,Ame
|
||||||
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
|
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
|
||||||
@@ -36,8 +42,8 @@ from shared.paths import DATA
|
|||||||
from fetch_hero_matches import (
|
from fetch_hero_matches import (
|
||||||
collect_item_ids,
|
collect_item_ids,
|
||||||
extract_player_row,
|
extract_player_row,
|
||||||
fetch_match,
|
|
||||||
load_ability_id_map,
|
load_ability_id_map,
|
||||||
|
opendota_url,
|
||||||
)
|
)
|
||||||
from fetch_hero_items import load_item_catalog
|
from fetch_hero_items import load_item_catalog
|
||||||
from fetch_pro_builds import fetch_pro_index
|
from fetch_pro_builds import fetch_pro_index
|
||||||
@@ -47,10 +53,48 @@ OUT = DATA / "pro_matches.json"
|
|||||||
WATCHLIST = DATA / "pro_player_watchlist.json"
|
WATCHLIST = DATA / "pro_player_watchlist.json"
|
||||||
DEFAULT_LIMIT = 8
|
DEFAULT_LIMIT = 8
|
||||||
DEFAULT_LIMIT_PROS = 40
|
DEFAULT_LIMIT_PROS = 40
|
||||||
|
DEFAULT_REFRESH_LIMIT = 0 # 0 = refresh all selected pros
|
||||||
|
DEFAULT_429_STREAK = 3
|
||||||
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
|
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
|
||||||
LOBBY_LEAGUE = (1, 2)
|
LOBBY_LEAGUE = (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitTripped(Exception):
|
||||||
|
"""OpenDota returned enough consecutive 429s to abort the remaining batch."""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenDotaClient:
|
||||||
|
"""Fail-fast OpenDota JSON client with consecutive-429 circuit breaker."""
|
||||||
|
|
||||||
|
def __init__(self, *, consecutive_limit: int = DEFAULT_429_STREAK) -> None:
|
||||||
|
self.consecutive_limit = max(1, int(consecutive_limit))
|
||||||
|
self.consecutive_429 = 0
|
||||||
|
self.tripped = False
|
||||||
|
|
||||||
|
def json(self, path: str) -> dict | list:
|
||||||
|
if self.tripped:
|
||||||
|
raise RateLimitTripped("opendota rate-limit circuit open")
|
||||||
|
url = opendota_url(path)
|
||||||
|
try:
|
||||||
|
# No 5/10/20/40s retry chain — daily must finish within budget.
|
||||||
|
data = http_json(url, retries=0)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429:
|
||||||
|
self.consecutive_429 += 1
|
||||||
|
_log(
|
||||||
|
f"HTTP 429 {url} — streak "
|
||||||
|
f"{self.consecutive_429}/{self.consecutive_limit}"
|
||||||
|
)
|
||||||
|
if self.consecutive_429 >= self.consecutive_limit:
|
||||||
|
self.tripped = True
|
||||||
|
raise RateLimitTripped(
|
||||||
|
f"opendota 429 x{self.consecutive_429}"
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
|
self.consecutive_429 = 0
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _log(msg: str) -> None:
|
def _log(msg: str) -> None:
|
||||||
try:
|
try:
|
||||||
print(msg, flush=True)
|
print(msg, flush=True)
|
||||||
@@ -113,6 +157,58 @@ def load_watchlist(path: Path) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing(path: Path) -> dict:
|
||||||
|
"""Load prior pro_matches.json; empty dict when missing/unreadable."""
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return raw if isinstance(raw, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ts(value: object) -> datetime | None:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cell_fetched_at(cell: dict | None) -> datetime | None:
|
||||||
|
if not isinstance(cell, dict):
|
||||||
|
return None
|
||||||
|
return parse_ts(cell.get("fetched_at"))
|
||||||
|
|
||||||
|
|
||||||
|
def select_refresh_batch(
|
||||||
|
picked: list[tuple[int, dict]],
|
||||||
|
existing_by_pro: dict[str, dict],
|
||||||
|
refresh_limit: int,
|
||||||
|
) -> tuple[list[tuple[int, dict]], list[tuple[int, dict]]]:
|
||||||
|
"""Split watchlist into (to_refresh, to_retain) by staleness.
|
||||||
|
|
||||||
|
Missing / unscored cells sort oldest. ``refresh_limit <= 0`` refreshes all.
|
||||||
|
"""
|
||||||
|
if refresh_limit <= 0 or refresh_limit >= len(picked):
|
||||||
|
return list(picked), []
|
||||||
|
|
||||||
|
ranked: list[tuple[float, int, tuple[int, dict]]] = []
|
||||||
|
for idx, item in enumerate(picked):
|
||||||
|
aid, _prof = item
|
||||||
|
ts = cell_fetched_at(existing_by_pro.get(str(aid)))
|
||||||
|
# Missing timestamp => oldest (refresh first).
|
||||||
|
score = ts.timestamp() if ts is not None else float("-inf")
|
||||||
|
ranked.append((score, idx, item))
|
||||||
|
ranked.sort(key=lambda row: (row[0], row[1]))
|
||||||
|
to_refresh = [item for _score, _idx, item in ranked[:refresh_limit]]
|
||||||
|
refresh_ids = {aid for aid, _ in to_refresh}
|
||||||
|
to_retain = [item for item in picked if item[0] not in refresh_ids]
|
||||||
|
return to_refresh, to_retain
|
||||||
|
|
||||||
|
|
||||||
def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
|
def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
|
||||||
"""Comma-separated account ids or registered pro names."""
|
"""Comma-separated account ids or registered pro names."""
|
||||||
if not raw.strip():
|
if not raw.strip():
|
||||||
@@ -219,6 +315,7 @@ def player_match_metas(
|
|||||||
account_id: int,
|
account_id: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
*,
|
*,
|
||||||
|
client: OpenDotaClient,
|
||||||
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
|
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Recent match list rows for a pro (deduped, newest first).
|
"""Recent match list rows for a pro (deduped, newest first).
|
||||||
@@ -229,9 +326,11 @@ def player_match_metas(
|
|||||||
"""
|
"""
|
||||||
by_id: dict[int, dict] = {}
|
by_id: dict[int, dict] = {}
|
||||||
for lt in lobby_types:
|
for lt in lobby_types:
|
||||||
url = f"{OPENDOTA}/players/{account_id}/matches?limit={limit}&lobby_type={lt}"
|
path = f"/players/{account_id}/matches?limit={limit}&lobby_type={lt}"
|
||||||
try:
|
try:
|
||||||
raw = http_json(url)
|
raw = client.json(path)
|
||||||
|
except RateLimitTripped:
|
||||||
|
raise
|
||||||
except (
|
except (
|
||||||
urllib.error.HTTPError,
|
urllib.error.HTTPError,
|
||||||
urllib.error.URLError,
|
urllib.error.URLError,
|
||||||
@@ -269,9 +368,26 @@ def player_match_metas(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_match_detail(client: OpenDotaClient, match_id: int) -> dict | None:
|
||||||
|
try:
|
||||||
|
raw = client.json(f"/matches/{match_id}")
|
||||||
|
except RateLimitTripped:
|
||||||
|
raise
|
||||||
|
except (
|
||||||
|
urllib.error.HTTPError,
|
||||||
|
urllib.error.URLError,
|
||||||
|
TimeoutError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
OSError,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def fetch_player_matches(
|
def fetch_player_matches(
|
||||||
account_id: int,
|
account_id: int,
|
||||||
*,
|
*,
|
||||||
|
client: OpenDotaClient,
|
||||||
limit: int,
|
limit: int,
|
||||||
id_map: dict[int, str],
|
id_map: dict[int, str],
|
||||||
catalog: dict[int, dict],
|
catalog: dict[int, dict],
|
||||||
@@ -279,7 +395,9 @@ def fetch_player_matches(
|
|||||||
delay: float,
|
delay: float,
|
||||||
lobby_types: tuple[int, ...],
|
lobby_types: tuple[int, ...],
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
metas = player_match_metas(account_id, limit, lobby_types=lobby_types)
|
metas = player_match_metas(
|
||||||
|
account_id, limit, client=client, lobby_types=lobby_types
|
||||||
|
)
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for meta in metas:
|
for meta in metas:
|
||||||
try:
|
try:
|
||||||
@@ -295,7 +413,7 @@ def fetch_player_matches(
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
lt_i = None
|
lt_i = None
|
||||||
row_origin = "public" if lt_i == 7 else "pro"
|
row_origin = "public" if lt_i == 7 else "pro"
|
||||||
detail = fetch_match(mid)
|
detail = fetch_match_detail(client, mid)
|
||||||
if delay > 0:
|
if delay > 0:
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
if not detail:
|
if not detail:
|
||||||
@@ -363,6 +481,32 @@ def build_indexes(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def retain_cell(
|
||||||
|
aid: int,
|
||||||
|
prof: dict,
|
||||||
|
existing: dict | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Keep prior matches for a pro not refreshed this round."""
|
||||||
|
if isinstance(existing, dict) and isinstance(existing.get("matches"), list):
|
||||||
|
cell = dict(existing)
|
||||||
|
cell["account_id"] = aid
|
||||||
|
for key in ("name", "team_tag", "team_name", "country_code"):
|
||||||
|
if prof.get(key) and not cell.get(key):
|
||||||
|
cell[key] = prof.get(key)
|
||||||
|
cell["match_count"] = len(cell.get("matches") or [])
|
||||||
|
return cell
|
||||||
|
return {
|
||||||
|
"account_id": aid,
|
||||||
|
"name": prof.get("name"),
|
||||||
|
"team_tag": prof.get("team_tag"),
|
||||||
|
"team_name": prof.get("team_name"),
|
||||||
|
"country_code": prof.get("country_code"),
|
||||||
|
"match_count": 0,
|
||||||
|
"matches": [],
|
||||||
|
"fetched_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def write_out(
|
def write_out(
|
||||||
path: Path,
|
path: Path,
|
||||||
*,
|
*,
|
||||||
@@ -374,6 +518,10 @@ def write_out(
|
|||||||
limit_pros: int,
|
limit_pros: int,
|
||||||
lobby_types: tuple[int, ...],
|
lobby_types: tuple[int, ...],
|
||||||
player_source: str,
|
player_source: str,
|
||||||
|
refreshed_count: int,
|
||||||
|
retained_count: int,
|
||||||
|
refresh_limit: int,
|
||||||
|
rate_limited: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
used = collect_item_ids(by_hero)
|
used = collect_item_ids(by_hero)
|
||||||
items_out = {
|
items_out = {
|
||||||
@@ -402,10 +550,15 @@ def write_out(
|
|||||||
"pro_count": len(by_pro),
|
"pro_count": len(by_pro),
|
||||||
"match_count": match_count,
|
"match_count": match_count,
|
||||||
"hero_count": len(by_hero),
|
"hero_count": len(by_hero),
|
||||||
|
"refresh_limit": refresh_limit,
|
||||||
|
"refreshed_count": refreshed_count,
|
||||||
|
"retained_count": retained_count,
|
||||||
|
"rate_limited": bool(rate_limited),
|
||||||
"note_zh": (
|
"note_zh": (
|
||||||
"OpenDota 明星选手近期联赛/锦标赛对局(可选含天梯 lobby_type=7);"
|
"OpenDota 明星选手近期联赛/锦标赛对局(可选含天梯 lobby_type=7);"
|
||||||
"默认名单见 web/data/pro_player_watchlist.json;"
|
"默认名单见 web/data/pro_player_watchlist.json;"
|
||||||
"每种 lobby 各保留最近 limit 场,避免联赛挤掉天梯;"
|
"每种 lobby 各保留最近 limit 场,避免联赛挤掉天梯;"
|
||||||
|
"daily 按 fetched_at 轮换最陈旧选手(--refresh-limit);"
|
||||||
"lobby_type 1=训练/practice、2=tournament、7=ranked;"
|
"lobby_type 1=训练/practice、2=tournament、7=ranked;"
|
||||||
"含终局出装、加点与联赛名(若有)。"
|
"含终局出装、加点与联赛名(若有)。"
|
||||||
),
|
),
|
||||||
@@ -431,7 +584,7 @@ def main() -> None:
|
|||||||
"--limit",
|
"--limit",
|
||||||
type=int,
|
type=int,
|
||||||
default=DEFAULT_LIMIT,
|
default=DEFAULT_LIMIT,
|
||||||
help=f"Matches per pro (default: {DEFAULT_LIMIT})",
|
help=f"Matches per lobby per pro (default: {DEFAULT_LIMIT})",
|
||||||
)
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--limit-pros",
|
"--limit-pros",
|
||||||
@@ -439,6 +592,16 @@ def main() -> None:
|
|||||||
default=DEFAULT_LIMIT_PROS,
|
default=DEFAULT_LIMIT_PROS,
|
||||||
help=f"Max pros when --all-pros (default: {DEFAULT_LIMIT_PROS})",
|
help=f"Max pros when --all-pros (default: {DEFAULT_LIMIT_PROS})",
|
||||||
)
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--refresh-limit",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_REFRESH_LIMIT,
|
||||||
|
help=(
|
||||||
|
"Max pros to refresh this run by oldest fetched_at "
|
||||||
|
f"(0=all; default: {DEFAULT_REFRESH_LIMIT}). "
|
||||||
|
"Ignored when --players is set."
|
||||||
|
),
|
||||||
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--players",
|
"--players",
|
||||||
default="",
|
default="",
|
||||||
@@ -466,24 +629,29 @@ def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Also include ranked pub lobby_type=7 (high-MMR scrims)",
|
help="Also include ranked pub lobby_type=7 (high-MMR scrims)",
|
||||||
)
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--429-streak",
|
||||||
|
dest="streak_429",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_429_STREAK,
|
||||||
|
help=f"Consecutive 429s before aborting remaining pros (default: {DEFAULT_429_STREAK})",
|
||||||
|
)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
limit = max(1, int(args.limit))
|
limit = max(1, int(args.limit))
|
||||||
limit_pros = max(1, int(args.limit_pros))
|
limit_pros = max(1, int(args.limit_pros))
|
||||||
|
refresh_limit = max(0, int(args.refresh_limit))
|
||||||
active_days = int(args.active_days) if args.active_days > 0 else None
|
active_days = int(args.active_days) if args.active_days > 0 else None
|
||||||
lobby_types: tuple[int, ...] = LOBBY_LEAGUE
|
lobby_types: tuple[int, ...] = LOBBY_LEAGUE
|
||||||
if args.include_pubs:
|
if args.include_pubs:
|
||||||
lobby_types = LOBBY_LEAGUE + (7,)
|
lobby_types = LOBBY_LEAGUE + (7,)
|
||||||
|
|
||||||
_log("loading pro players ...")
|
client = OpenDotaClient(consecutive_limit=int(args.streak_429))
|
||||||
pro_index = fetch_pro_index()
|
|
||||||
_log(f" {len(pro_index)} registered pros")
|
|
||||||
|
|
||||||
player_ids = parse_pro_filter(args.players, pro_index)
|
player_ids_raw = (args.players or "").strip()
|
||||||
watchlist: list[dict] | None = None
|
watchlist: list[dict] | None = None
|
||||||
player_source = "cli"
|
player_source = "cli"
|
||||||
|
if player_ids_raw:
|
||||||
if player_ids:
|
|
||||||
player_source = "cli"
|
player_source = "cli"
|
||||||
elif not args.all_pros:
|
elif not args.all_pros:
|
||||||
watchlist = load_watchlist(args.watchlist)
|
watchlist = load_watchlist(args.watchlist)
|
||||||
@@ -497,6 +665,60 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
player_source = "proPlayers"
|
player_source = "proPlayers"
|
||||||
|
|
||||||
|
_log("loading pro players ...")
|
||||||
|
pro_index: dict[int, dict] = {}
|
||||||
|
if args.all_pros and not player_ids_raw:
|
||||||
|
pro_index = fetch_pro_index()
|
||||||
|
else:
|
||||||
|
# Fail-fast: watchlist runs do not need /proPlayers to refresh matches.
|
||||||
|
try:
|
||||||
|
raw = http_json(f"{OPENDOTA}/proPlayers", retries=0)
|
||||||
|
except (
|
||||||
|
urllib.error.HTTPError,
|
||||||
|
urllib.error.URLError,
|
||||||
|
TimeoutError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
OSError,
|
||||||
|
) as exc:
|
||||||
|
_log(f" warn: proPlayers unavailable ({exc}); watchlist names only")
|
||||||
|
raw = None
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for row in raw:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
aid = int(row.get("account_id") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if aid <= 0:
|
||||||
|
continue
|
||||||
|
name = row.get("name")
|
||||||
|
if isinstance(name, str):
|
||||||
|
name = name.strip() or None
|
||||||
|
else:
|
||||||
|
name = None
|
||||||
|
team_tag = row.get("team_tag")
|
||||||
|
if isinstance(team_tag, str):
|
||||||
|
team_tag = team_tag.strip() or None
|
||||||
|
else:
|
||||||
|
team_tag = None
|
||||||
|
team_name = row.get("team_name")
|
||||||
|
if isinstance(team_name, str):
|
||||||
|
team_name = team_name.strip() or None
|
||||||
|
else:
|
||||||
|
team_name = None
|
||||||
|
pro_index[aid] = {
|
||||||
|
"account_id": aid,
|
||||||
|
"name": name,
|
||||||
|
"team_tag": team_tag,
|
||||||
|
"team_name": team_name,
|
||||||
|
"country_code": row.get("country_code"),
|
||||||
|
"last_match_time": row.get("last_match_time"),
|
||||||
|
}
|
||||||
|
_log(f" {len(pro_index)} registered pros")
|
||||||
|
|
||||||
|
player_ids = parse_pro_filter(args.players, pro_index)
|
||||||
|
|
||||||
picked = filter_pros(
|
picked = filter_pros(
|
||||||
pro_index,
|
pro_index,
|
||||||
with_team=args.with_team,
|
with_team=args.with_team,
|
||||||
@@ -508,6 +730,25 @@ def main() -> None:
|
|||||||
if not picked:
|
if not picked:
|
||||||
raise SystemExit("No pros matched filters")
|
raise SystemExit("No pros matched filters")
|
||||||
|
|
||||||
|
existing = load_existing(args.out)
|
||||||
|
existing_by_pro = existing.get("by_pro") if isinstance(existing.get("by_pro"), dict) else {}
|
||||||
|
assert isinstance(existing_by_pro, dict)
|
||||||
|
|
||||||
|
# Explicit --players means refresh those fully (no rotation).
|
||||||
|
if player_ids:
|
||||||
|
to_refresh, to_retain = list(picked), []
|
||||||
|
effective_refresh_limit = 0
|
||||||
|
else:
|
||||||
|
to_refresh, to_retain = select_refresh_batch(
|
||||||
|
picked, existing_by_pro, refresh_limit
|
||||||
|
)
|
||||||
|
effective_refresh_limit = refresh_limit
|
||||||
|
|
||||||
|
_log(
|
||||||
|
f" refresh batch={len(to_refresh)} retain={len(to_retain)} "
|
||||||
|
f"refresh_limit={effective_refresh_limit or 'all'}"
|
||||||
|
)
|
||||||
|
|
||||||
heroes = hero_table()
|
heroes = hero_table()
|
||||||
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
||||||
catalog, _ = load_item_catalog()
|
catalog, _ = load_item_catalog()
|
||||||
@@ -516,12 +757,31 @@ def main() -> None:
|
|||||||
by_pro: dict[str, dict] = {}
|
by_pro: dict[str, dict] = {}
|
||||||
pros_meta: dict[str, dict] = {}
|
pros_meta: dict[str, dict] = {}
|
||||||
total_matches = 0
|
total_matches = 0
|
||||||
|
refreshed_count = 0
|
||||||
|
rate_limited = False
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
for i, (aid, prof) in enumerate(picked, 1):
|
for aid, prof in to_retain:
|
||||||
|
sid = str(aid)
|
||||||
|
cell = retain_cell(aid, prof, existing_by_pro.get(sid))
|
||||||
|
by_pro[sid] = cell
|
||||||
|
pros_meta[sid] = {
|
||||||
|
"account_id": aid,
|
||||||
|
"name": cell.get("name") or prof.get("name"),
|
||||||
|
"team_tag": cell.get("team_tag") or prof.get("team_tag"),
|
||||||
|
"team_name": cell.get("team_name") or prof.get("team_name"),
|
||||||
|
"country_code": cell.get("country_code") or prof.get("country_code"),
|
||||||
|
}
|
||||||
|
total_matches += len(cell.get("matches") or [])
|
||||||
|
|
||||||
|
for i, (aid, prof) in enumerate(to_refresh, 1):
|
||||||
label = prof.get("name") or prof.get("team_tag") or str(aid)
|
label = prof.get("name") or prof.get("team_tag") or str(aid)
|
||||||
_log(f"[{i}/{len(picked)}] {label} ({aid}) ...")
|
sid = str(aid)
|
||||||
|
_log(f"[{i}/{len(to_refresh)}] {label} ({aid}) ...")
|
||||||
|
try:
|
||||||
matches = fetch_player_matches(
|
matches = fetch_player_matches(
|
||||||
aid,
|
aid,
|
||||||
|
client=client,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
id_map=id_map,
|
id_map=id_map,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
@@ -529,7 +789,25 @@ def main() -> None:
|
|||||||
delay=args.delay,
|
delay=args.delay,
|
||||||
lobby_types=lobby_types,
|
lobby_types=lobby_types,
|
||||||
)
|
)
|
||||||
sid = str(aid)
|
except RateLimitTripped as exc:
|
||||||
|
rate_limited = True
|
||||||
|
_log(f" rate-limited; keeping prior cache for remaining ({exc})")
|
||||||
|
# Keep old (or empty) for this pro and every leftover refresh target.
|
||||||
|
remaining = to_refresh[i - 1 :]
|
||||||
|
for raid, rprof in remaining:
|
||||||
|
rsid = str(raid)
|
||||||
|
cell = retain_cell(raid, rprof, existing_by_pro.get(rsid))
|
||||||
|
by_pro[rsid] = cell
|
||||||
|
pros_meta[rsid] = {
|
||||||
|
"account_id": raid,
|
||||||
|
"name": cell.get("name") or rprof.get("name"),
|
||||||
|
"team_tag": cell.get("team_tag") or rprof.get("team_tag"),
|
||||||
|
"team_name": cell.get("team_name") or rprof.get("team_name"),
|
||||||
|
"country_code": cell.get("country_code") or rprof.get("country_code"),
|
||||||
|
}
|
||||||
|
total_matches += len(cell.get("matches") or [])
|
||||||
|
break
|
||||||
|
|
||||||
by_pro[sid] = {
|
by_pro[sid] = {
|
||||||
"account_id": aid,
|
"account_id": aid,
|
||||||
"name": prof.get("name"),
|
"name": prof.get("name"),
|
||||||
@@ -538,6 +816,7 @@ def main() -> None:
|
|||||||
"country_code": prof.get("country_code"),
|
"country_code": prof.get("country_code"),
|
||||||
"match_count": len(matches),
|
"match_count": len(matches),
|
||||||
"matches": matches,
|
"matches": matches,
|
||||||
|
"fetched_at": now_iso,
|
||||||
}
|
}
|
||||||
pros_meta[sid] = {
|
pros_meta[sid] = {
|
||||||
"account_id": aid,
|
"account_id": aid,
|
||||||
@@ -546,9 +825,11 @@ def main() -> None:
|
|||||||
"team_name": prof.get("team_name"),
|
"team_name": prof.get("team_name"),
|
||||||
"country_code": prof.get("country_code"),
|
"country_code": prof.get("country_code"),
|
||||||
}
|
}
|
||||||
|
refreshed_count += 1
|
||||||
total_matches += len(matches)
|
total_matches += len(matches)
|
||||||
_log(f" {len(matches)} matches")
|
_log(f" {len(matches)} matches")
|
||||||
|
|
||||||
|
retained_count = len(by_pro) - refreshed_count
|
||||||
by_hero = build_indexes(by_pro, id_to_key)
|
by_hero = build_indexes(by_pro, id_to_key)
|
||||||
write_out(
|
write_out(
|
||||||
args.out,
|
args.out,
|
||||||
@@ -560,9 +841,15 @@ def main() -> None:
|
|||||||
limit_pros=len(picked),
|
limit_pros=len(picked),
|
||||||
lobby_types=lobby_types,
|
lobby_types=lobby_types,
|
||||||
player_source=player_source,
|
player_source=player_source,
|
||||||
|
refreshed_count=refreshed_count,
|
||||||
|
retained_count=max(0, retained_count),
|
||||||
|
refresh_limit=effective_refresh_limit,
|
||||||
|
rate_limited=rate_limited,
|
||||||
)
|
)
|
||||||
_log(
|
_log(
|
||||||
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)} → {args.out}"
|
f"done pros={len(by_pro)} refreshed={refreshed_count} "
|
||||||
|
f"retained={max(0, retained_count)} matches={total_matches} "
|
||||||
|
f"heroes={len(by_hero)} rate_limited={rate_limited} → {args.out}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ Approach (verified 2026-07):
|
|||||||
- Bilibili: public API ``api.live.bilibili.com/room/v1/Room/get_info`` with the
|
- Bilibili: public API ``api.live.bilibili.com/room/v1/Room/get_info`` with the
|
||||||
numeric room id taken from the ``live_url`` path; ``data.live_status == 1``
|
numeric room id taken from the ``live_url`` path; ``data.live_status == 1``
|
||||||
means live (0 offline, 2 replay — replay is treated as offline). No login.
|
means live (0 offline, 2 replay — replay is treated as offline). No login.
|
||||||
|
- Douyu: public ``www.douyu.com/betard/{room_id}``; ``room.show_status == 1``
|
||||||
|
means live, ``== 2`` offline; ``room.videoLoop == 1`` (carousel) counts as
|
||||||
|
offline.
|
||||||
- Douyin: one shared cookie session is warmed up (www.douyin.com +
|
- Douyin: one shared cookie session is warmed up (www.douyin.com +
|
||||||
live.douyin.com), then per room we GET ``live.douyin.com/{web_rid}`` with a
|
live.douyin.com), then per room we GET ``live.douyin.com/{web_rid}`` with a
|
||||||
browser UA (the numeric ``live_url`` path segment is the ``web_rid``). The
|
browser UA (the numeric ``live_url`` path segment is the ``web_rid``). The
|
||||||
@@ -22,11 +25,13 @@ Approach (verified 2026-07):
|
|||||||
``webcast/room/web/enter`` API was considered but returns empty bodies
|
``webcast/room/web/enter`` API was considered but returns empty bodies
|
||||||
without request signing, so the SSR page is the source of truth.)
|
without request signing, so the SSR page is the source of truth.)
|
||||||
|
|
||||||
Everything is soft-fail: network errors, empty or non-JSON responses keep the
|
Everything is soft-fail: network errors, empty or non-JSON responses clear
|
||||||
previous ``is_live`` value and never abort a refresh tier (exit code is
|
``is_live`` to False and drop ``live_probed_at`` (so consumers treat the
|
||||||
always 0). Only the two probe fields are touched; all other keys (including
|
badge as stale/unknown) and never abort a refresh tier (exit code is always
|
||||||
``live_url``) are preserved. Note the ``daily`` tier means the badge trails
|
0). Only the two probe fields are touched; all other keys (including
|
||||||
reality by up to a day — truly real-time would need a higher-frequency job.
|
``live_url``) are preserved. Production live badges are owned by the
|
||||||
|
visit-triggered ``/api/live-status`` edge probe; this daily write is only a
|
||||||
|
``data.json`` fallback until that API returns.
|
||||||
|
|
||||||
Preview only — do not merge into relations/heroes or recommend.
|
Preview only — do not merge into relations/heroes or recommend.
|
||||||
|
|
||||||
@@ -63,10 +68,12 @@ TIMEOUT = 20
|
|||||||
# Douyin rate-limits aggressively; keep ~1s spacing between its requests.
|
# Douyin rate-limits aggressively; keep ~1s spacing between its requests.
|
||||||
DOUYIN_SPACING = 1.0
|
DOUYIN_SPACING = 1.0
|
||||||
BILIBILI_SPACING = 0.5
|
BILIBILI_SPACING = 0.5
|
||||||
|
DOUYU_SPACING = 0.3
|
||||||
|
|
||||||
DOUYIN_HOME = "https://www.douyin.com/"
|
DOUYIN_HOME = "https://www.douyin.com/"
|
||||||
DOUYIN_LIVE_HOME = "https://live.douyin.com/"
|
DOUYIN_LIVE_HOME = "https://live.douyin.com/"
|
||||||
BILIBILI_INFO_URL = "https://api.live.bilibili.com/room/v1/Room/get_info?room_id={room_id}"
|
BILIBILI_INFO_URL = "https://api.live.bilibili.com/room/v1/Room/get_info?room_id={room_id}"
|
||||||
|
DOUYU_BETARD_URL = "https://www.douyu.com/betard/{room_id}"
|
||||||
|
|
||||||
# Escaped JSON inside the SSR pace chunks: \"roomStore\":{\"roomInfo\":{\"room\":{
|
# Escaped JSON inside the SSR pace chunks: \"roomStore\":{\"roomInfo\":{\"room\":{
|
||||||
DOUYIN_ROOMSTORE_RE = re.compile(
|
DOUYIN_ROOMSTORE_RE = re.compile(
|
||||||
@@ -150,8 +157,27 @@ def probe_bilibili(room_id: str) -> bool:
|
|||||||
return data.get("live_status") == 1
|
return data.get("live_status") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def probe_douyu(room_id: str) -> bool:
|
||||||
|
"""show_status: 1 live, 2 offline; videoLoop carousel counts as offline."""
|
||||||
|
payload = http_utils.http_json(
|
||||||
|
DOUYU_BETARD_URL.format(room_id=room_id), timeout=TIMEOUT
|
||||||
|
)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("douyu betard returned non-object")
|
||||||
|
room = payload.get("room")
|
||||||
|
if not isinstance(room, dict):
|
||||||
|
raise ValueError("douyu betard returned no room")
|
||||||
|
if int(room.get("videoLoop") or 0) == 1:
|
||||||
|
return False
|
||||||
|
status = room.get("show_status")
|
||||||
|
try:
|
||||||
|
return int(status) == 1
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
raise ValueError(f"douyu unexpected show_status {status!r}") from e
|
||||||
|
|
||||||
|
|
||||||
def room_ref_from_url(live_url: str) -> str | None:
|
def room_ref_from_url(live_url: str) -> str | None:
|
||||||
"""First path segment of the live room URL (douyin web_rid / bilibili room id)."""
|
"""First path segment of the live room URL (douyin/bilibili/douyu room id)."""
|
||||||
path = urllib.parse.urlparse(live_url.strip()).path.strip("/")
|
path = urllib.parse.urlparse(live_url.strip()).path.strip("/")
|
||||||
if not path:
|
if not path:
|
||||||
return None
|
return None
|
||||||
@@ -165,6 +191,8 @@ def live_platform_from_url(live_url: str, fallback: str = "") -> str:
|
|||||||
return "bilibili"
|
return "bilibili"
|
||||||
if "douyin.com" in host:
|
if "douyin.com" in host:
|
||||||
return "douyin"
|
return "douyin"
|
||||||
|
if "douyu.com" in host:
|
||||||
|
return "douyu"
|
||||||
return (fallback or "").strip().lower()
|
return (fallback or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
@@ -208,11 +236,18 @@ def probe_streamers(
|
|||||||
elif platform == "bilibili":
|
elif platform == "bilibili":
|
||||||
is_live = probe_bilibili(ref)
|
is_live = probe_bilibili(ref)
|
||||||
time.sleep(BILIBILI_SPACING)
|
time.sleep(BILIBILI_SPACING)
|
||||||
|
elif platform == "douyu":
|
||||||
|
is_live = probe_douyu(ref)
|
||||||
|
time.sleep(DOUYU_SPACING)
|
||||||
else:
|
else:
|
||||||
print(f"skip {sid}: platform={platform!r} unsupported", flush=True)
|
print(f"skip {sid}: platform={platform!r} unsupported", flush=True)
|
||||||
continue
|
continue
|
||||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||||
print(f" FAIL {sid}: {e} (keeping previous is_live)", flush=True)
|
# Align with /api/live-status and local serve_relations: unknown is
|
||||||
|
# not live, and must not preserve a stale positive badge.
|
||||||
|
row["is_live"] = False
|
||||||
|
row.pop("live_probed_at", None)
|
||||||
|
print(f" FAIL {sid}: {e} (is_live=false, stale)", flush=True)
|
||||||
fail += 1
|
fail += 1
|
||||||
continue
|
continue
|
||||||
row["is_live"] = is_live
|
row["is_live"] = is_live
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
"""Fetch Douyin profile fields into data/streamers.json.
|
"""Fetch Douyin / Douyu profile fields into data/streamers.json.
|
||||||
|
|
||||||
Manual seed fields (id / platform / live_url / profile_url / heroes / tagline)
|
Manual seed fields (id / platform / live_url / profile_url / heroes / tagline)
|
||||||
are preserved. Profile enrichment (nickname, signature, counts, avatar) is
|
are preserved. Profile enrichment (nickname, signature, counts, avatar) is
|
||||||
best-effort via Douyin HTML RENDER_DATA + text fallback; failures keep the
|
best-effort:
|
||||||
previous values.
|
|
||||||
|
- Douyin: HTML RENDER_DATA + text fallback
|
||||||
|
- Douyu: ``v.douyu.com/author/<hash>`` (or ``author-video/<hash>``) page
|
||||||
|
``window.$DATA`` (fans / following / plays / avatar). Room-only rows
|
||||||
|
resolve ``up_id`` from the live-room HTML then fetch the author page;
|
||||||
|
if that fails, fall back to ``betard`` (nickname / avatar / bio, no fans).
|
||||||
|
|
||||||
|
Failures keep the previous values.
|
||||||
|
|
||||||
Preview only — do not merge into relations/heroes or recommend.
|
Preview only — do not merge into relations/heroes or recommend.
|
||||||
Part of refresh_web ``daily`` (soft-fail: never aborts the tier).
|
Part of refresh_web ``daily`` (soft-fail: never aborts the tier).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python fetch_streamers.py
|
python fetch_streamers.py
|
||||||
python fetch_streamers.py --ids xiaowang
|
python fetch_streamers.py --ids xiaowang k9
|
||||||
python fetch_streamers.py --out data/streamers.json
|
python fetch_streamers.py --out data/streamers.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -32,8 +39,8 @@ import urllib.request
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from shared.http_utils import write_json_atomic
|
from shared.http_utils import http_json, write_json_atomic
|
||||||
from shared.paths import DATA, STREAMER_AVATARS
|
from shared.paths import DATA, ROOT, STREAMER_AVATARS
|
||||||
|
|
||||||
OUT = DATA / "streamers.json"
|
OUT = DATA / "streamers.json"
|
||||||
AVATAR_DIR = STREAMER_AVATARS
|
AVATAR_DIR = STREAMER_AVATARS
|
||||||
@@ -58,6 +65,17 @@ HTML_AVATAR_RE = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
SEC_UID_RE = re.compile(r"/user/(MS4wLjABAAAA[A-Za-z0-9_-]+)")
|
SEC_UID_RE = re.compile(r"/user/(MS4wLjABAAAA[A-Za-z0-9_-]+)")
|
||||||
|
DOUYU_AUTHOR_HASH_RE = re.compile(
|
||||||
|
r"(?:v\.)?douyu\.com/author(?:-video)?/([A-Za-z0-9]+)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
DOUYU_ROOM_RE = re.compile(
|
||||||
|
r"(?:www\.)?douyu\.com/(\d+)(?:/|$|\?)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
DOUYU_DATA_RE = re.compile(r"window\.\$DATA=(\{.*?\}),\$", re.DOTALL)
|
||||||
|
DOUYU_BARE_KEY_RE = re.compile(r"([{\s,])([A-Za-z_][A-Za-z0-9_]*)\s*:")
|
||||||
|
# Room HTML embeds up_id in plain JSON and/or JSON-escaped script strings.
|
||||||
|
DOUYU_UP_ID_RE = re.compile(r'\\?"up_id\\?"\s*:\s*\\?"([A-Za-z0-9]+)\\?"')
|
||||||
|
DOUYU_BETARD_URL = "https://www.douyu.com/betard/{room_id}"
|
||||||
# Fields fetch may overwrite; manual seed keys are never removed.
|
# Fields fetch may overwrite; manual seed keys are never removed.
|
||||||
PROFILE_KEYS = (
|
PROFILE_KEYS = (
|
||||||
"nickname",
|
"nickname",
|
||||||
@@ -104,7 +122,11 @@ def _opener() -> urllib.request.OpenerDirector:
|
|||||||
|
|
||||||
|
|
||||||
def _get(
|
def _get(
|
||||||
opener: urllib.request.OpenerDirector, url: str, *, timeout: int = 30
|
opener: urllib.request.OpenerDirector,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: int = 30,
|
||||||
|
referer: str = "https://www.douyin.com/",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Return (final_url, html)."""
|
"""Return (final_url, html)."""
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -113,7 +135,7 @@ def _get(
|
|||||||
"User-Agent": BROWSER_UA,
|
"User-Agent": BROWSER_UA,
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
"Referer": "https://www.douyin.com/",
|
"Referer": referer,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
with opener.open(req, timeout=timeout) as resp:
|
with opener.open(req, timeout=timeout) as resp:
|
||||||
@@ -306,7 +328,7 @@ def resolve_profile_url(
|
|||||||
return url, None
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
def download_avatar(url: str, dest: Path) -> bool:
|
def download_avatar(url: str, dest: Path, *, referer: str | None = None) -> bool:
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Prefer a larger CDN variant when the URL embeds a size token.
|
# Prefer a larger CDN variant when the URL embeds a size token.
|
||||||
candidates = [url]
|
candidates = [url]
|
||||||
@@ -314,12 +336,22 @@ def download_avatar(url: str, dest: Path) -> bool:
|
|||||||
candidates.insert(0, url.replace("/100x100/", "/720x720/"))
|
candidates.insert(0, url.replace("/100x100/", "/720x720/"))
|
||||||
if "300x300" in url:
|
if "300x300" in url:
|
||||||
candidates.insert(0, url.replace("300x300", "720x720"))
|
candidates.insert(0, url.replace("300x300", "720x720"))
|
||||||
|
if "_avatar_middle." in url:
|
||||||
|
candidates.insert(0, url.replace("_avatar_middle.", "_avatar_big."))
|
||||||
|
if "_middle.jpg" in url:
|
||||||
|
candidates.insert(0, url.replace("_middle.jpg", "_big.jpg"))
|
||||||
|
host = urllib.parse.urlparse(url).netloc.lower()
|
||||||
|
if referer is None:
|
||||||
|
if "douyu" in host:
|
||||||
|
referer = "https://www.douyu.com/"
|
||||||
|
else:
|
||||||
|
referer = "https://www.douyin.com/"
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
candidate,
|
candidate,
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": BROWSER_UA,
|
"User-Agent": BROWSER_UA,
|
||||||
"Referer": "https://www.douyin.com/",
|
"Referer": referer,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -336,6 +368,153 @@ def download_avatar(url: str, dest: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_douyu_dollar_data(blob: str) -> dict:
|
||||||
|
"""Parse Douyu ``window.$DATA={...}`` JS object (bare keys) into a dict."""
|
||||||
|
quoted = DOUYU_BARE_KEY_RE.sub(r'\1"\2":', blob.strip())
|
||||||
|
data = json.loads(quoted)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("douyu $DATA is not an object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_profile_from_douyu_data(data: dict) -> dict:
|
||||||
|
"""Map Douyu author ``$DATA`` fields onto streamer profile keys."""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
nick = _first_str(data.get("name"), data.get("nickname"))
|
||||||
|
if nick:
|
||||||
|
out["nickname"] = nick
|
||||||
|
room_id = _first_str(data.get("roomId"), data.get("room_id"))
|
||||||
|
if room_id:
|
||||||
|
out["unique_id"] = room_id
|
||||||
|
out["live_url"] = f"https://www.douyu.com/{room_id}"
|
||||||
|
up_id = _first_str(data.get("upId"), data.get("up_id"))
|
||||||
|
if up_id:
|
||||||
|
# Canonical homepage (author space); author-video is an alias.
|
||||||
|
out["profile_url"] = f"https://v.douyu.com/author/{up_id}"
|
||||||
|
# Author bio only — do not fall back to live room title (would wipe
|
||||||
|
# hand-seeded / betard signatures on every refresh).
|
||||||
|
contents = _first_str(data.get("contents"), data.get("ownerAuthContents"))
|
||||||
|
if contents:
|
||||||
|
out["signature"] = contents
|
||||||
|
out["following_count"] = _first_int(data.get("upFollowNum"), data.get("up_follow_num"))
|
||||||
|
out["follower_count"] = _first_int(data.get("subscribeNum"), data.get("subscribe_num"))
|
||||||
|
# Video play count — shown as「播放」on Douyu cards.
|
||||||
|
out["total_favorited"] = _first_int(data.get("playCount"), data.get("play_count"))
|
||||||
|
avatar = _first_str(data.get("avatar"))
|
||||||
|
if avatar:
|
||||||
|
out["avatar_url"] = avatar.replace(r"\/", "/")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_douyu_author_profile(
|
||||||
|
opener: urllib.request.OpenerDirector, profile_url: str
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch Douyu author / author-video page and parse ``window.$DATA``."""
|
||||||
|
m = DOUYU_AUTHOR_HASH_RE.search(profile_url)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"not a douyu author url: {profile_url!r}")
|
||||||
|
hash_id = m.group(1)
|
||||||
|
# Prefer /author/; fall back to /author-video/ if $DATA is missing.
|
||||||
|
errors: list[str] = []
|
||||||
|
for path in (f"author/{hash_id}", f"author-video/{hash_id}"):
|
||||||
|
page_url = f"https://v.douyu.com/{path}"
|
||||||
|
try:
|
||||||
|
_, html = _get(opener, page_url, referer="https://v.douyu.com/")
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
|
errors.append(f"{path}: {e}")
|
||||||
|
continue
|
||||||
|
data_m = DOUYU_DATA_RE.search(html)
|
||||||
|
if not data_m:
|
||||||
|
errors.append(f"{path}: no window.$DATA")
|
||||||
|
continue
|
||||||
|
profile = extract_profile_from_douyu_data(
|
||||||
|
parse_douyu_dollar_data(data_m.group(1))
|
||||||
|
)
|
||||||
|
if not any(
|
||||||
|
profile.get(k) is not None
|
||||||
|
for k in ("nickname", "follower_count", "avatar_url", "unique_id")
|
||||||
|
):
|
||||||
|
errors.append(f"{path}: parsed empty")
|
||||||
|
continue
|
||||||
|
return profile
|
||||||
|
raise ValueError("; ".join(errors) or "douyu author page failed")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_douyu_up_id_from_room_html(html: str) -> str | None:
|
||||||
|
"""Pull author ``up_id`` hash embedded in the live-room page HTML."""
|
||||||
|
m = DOUYU_UP_ID_RE.search(html or "")
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_douyu_author_url_from_room(
|
||||||
|
opener: urllib.request.OpenerDirector, room_id: str
|
||||||
|
) -> str:
|
||||||
|
"""Fetch ``www.douyu.com/<rid>`` and build the author homepage URL."""
|
||||||
|
page_url = f"https://www.douyu.com/{room_id}"
|
||||||
|
_, html = _get(opener, page_url, referer="https://www.douyu.com/")
|
||||||
|
up_id = extract_douyu_up_id_from_room_html(html)
|
||||||
|
if not up_id:
|
||||||
|
raise ValueError(f"douyu room {room_id}: no up_id in HTML")
|
||||||
|
return f"https://v.douyu.com/author/{up_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_douyu_room_profile(room_id: str) -> dict:
|
||||||
|
"""Fallback enrichment from live-room ``betard`` (no fan counts)."""
|
||||||
|
payload = http_json(DOUYU_BETARD_URL.format(room_id=room_id), timeout=20)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("douyu betard returned non-object")
|
||||||
|
room = payload.get("room")
|
||||||
|
if not isinstance(room, dict):
|
||||||
|
raise ValueError("douyu betard returned no room")
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"unique_id": str(room_id),
|
||||||
|
"live_url": f"https://www.douyu.com/{room_id}",
|
||||||
|
}
|
||||||
|
nick = _first_str(room.get("nickname"), room.get("owner_name"))
|
||||||
|
if nick:
|
||||||
|
out["nickname"] = nick
|
||||||
|
details = _first_str(room.get("show_details"), room.get("room_name"))
|
||||||
|
if details:
|
||||||
|
out["signature"] = details
|
||||||
|
avatar = room.get("avatar")
|
||||||
|
avatar_url = None
|
||||||
|
if isinstance(avatar, dict):
|
||||||
|
avatar_url = _first_str(avatar.get("big"), avatar.get("middle"), avatar.get("small"))
|
||||||
|
elif isinstance(avatar, str):
|
||||||
|
avatar_url = avatar
|
||||||
|
if not avatar_url:
|
||||||
|
avatar_url = _first_str(room.get("owner_avatar"), room.get("avatar_mid"))
|
||||||
|
if avatar_url:
|
||||||
|
out["avatar_url"] = avatar_url
|
||||||
|
if not out.get("nickname") and not out.get("avatar_url"):
|
||||||
|
raise ValueError("douyu betard parsed empty")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_douyu_profile(
|
||||||
|
opener: urllib.request.OpenerDirector, row: dict
|
||||||
|
) -> dict:
|
||||||
|
"""Enrich a Douyu streamer from author URL, room→up_id, else betard."""
|
||||||
|
profile_url = str(row.get("profile_url") or "").strip()
|
||||||
|
live_url = str(row.get("live_url") or "").strip()
|
||||||
|
if DOUYU_AUTHOR_HASH_RE.search(profile_url):
|
||||||
|
return fetch_douyu_author_profile(opener, profile_url)
|
||||||
|
room_id = None
|
||||||
|
for candidate in (profile_url, live_url):
|
||||||
|
m = DOUYU_ROOM_RE.search(candidate)
|
||||||
|
if m:
|
||||||
|
room_id = m.group(1)
|
||||||
|
break
|
||||||
|
if not room_id:
|
||||||
|
raise ValueError("douyu row needs author profile_url or room live_url")
|
||||||
|
try:
|
||||||
|
author_url = resolve_douyu_author_url_from_room(opener, room_id)
|
||||||
|
return fetch_douyu_author_profile(opener, author_url)
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||||
|
print(f" douyu room→author failed ({e}); betard fallback", flush=True)
|
||||||
|
return fetch_douyu_room_profile(room_id)
|
||||||
|
|
||||||
|
|
||||||
def fetch_douyin_profile(
|
def fetch_douyin_profile(
|
||||||
opener: urllib.request.OpenerDirector, profile_url: str
|
opener: urllib.request.OpenerDirector, profile_url: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -382,6 +561,15 @@ def merge_profile(row: dict, profile: dict, *, streamer_id: str) -> None:
|
|||||||
if val is None or val == "":
|
if val is None or val == "":
|
||||||
continue
|
continue
|
||||||
row[key] = val
|
row[key] = val
|
||||||
|
# Fill missing live/profile URLs from platform enrichment; never wipe seeds.
|
||||||
|
for key in ("live_url", "profile_url"):
|
||||||
|
val = profile.get(key)
|
||||||
|
if isinstance(val, str) and val and not str(row.get(key) or "").strip():
|
||||||
|
row[key] = val
|
||||||
|
# Prefer canonical Douyu author homepage when enrichment found one.
|
||||||
|
prof = profile.get("profile_url")
|
||||||
|
if isinstance(prof, str) and DOUYU_AUTHOR_HASH_RE.search(prof):
|
||||||
|
row["profile_url"] = prof
|
||||||
avatar_url = profile.get("avatar_url")
|
avatar_url = profile.get("avatar_url")
|
||||||
if isinstance(avatar_url, str) and avatar_url:
|
if isinstance(avatar_url, str) and avatar_url:
|
||||||
dest = AVATAR_DIR / f"{streamer_id}.jpg"
|
dest = AVATAR_DIR / f"{streamer_id}.jpg"
|
||||||
@@ -408,18 +596,25 @@ def enrich_streamers(
|
|||||||
if ids is not None and sid not in ids:
|
if ids is not None and sid not in ids:
|
||||||
continue
|
continue
|
||||||
platform = str(row.get("platform") or "").strip().lower()
|
platform = str(row.get("platform") or "").strip().lower()
|
||||||
if platform != "douyin":
|
if platform not in ("douyin", "douyu"):
|
||||||
print(f"skip {sid}: platform={platform!r} (only douyin supported)", flush=True)
|
print(
|
||||||
|
f"skip {sid}: platform={platform!r} "
|
||||||
|
f"(supported: douyin, douyu)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
skip += 1
|
skip += 1
|
||||||
continue
|
continue
|
||||||
|
print(f"fetching {sid} ({platform}) ...", flush=True)
|
||||||
|
try:
|
||||||
|
if platform == "douyin":
|
||||||
profile_url = str(row.get("profile_url") or "").strip()
|
profile_url = str(row.get("profile_url") or "").strip()
|
||||||
if not profile_url:
|
if not profile_url:
|
||||||
print(f"skip {sid}: missing profile_url", flush=True)
|
print(f"skip {sid}: missing profile_url", flush=True)
|
||||||
skip += 1
|
skip += 1
|
||||||
continue
|
continue
|
||||||
print(f"fetching {sid} ...", flush=True)
|
|
||||||
try:
|
|
||||||
profile = fetch_douyin_profile(opener, profile_url)
|
profile = fetch_douyin_profile(opener, profile_url)
|
||||||
|
else:
|
||||||
|
profile = fetch_douyu_profile(opener, row)
|
||||||
merge_profile(row, profile, streamer_id=sid)
|
merge_profile(row, profile, streamer_id=sid)
|
||||||
nick = row.get("nickname") or "?"
|
nick = row.get("nickname") or "?"
|
||||||
print(
|
print(
|
||||||
@@ -434,7 +629,7 @@ def enrich_streamers(
|
|||||||
time.sleep(0.8)
|
time.sleep(0.8)
|
||||||
if ok > 0:
|
if ok > 0:
|
||||||
payload["fetched_at"] = _now_iso()
|
payload["fetched_at"] = _now_iso()
|
||||||
payload["source"] = payload.get("source") or "manual+douyin"
|
payload["source"] = payload.get("source") or "manual+douyin+douyu"
|
||||||
meta = payload.get("platform_meta")
|
meta = payload.get("platform_meta")
|
||||||
if not isinstance(meta, dict):
|
if not isinstance(meta, dict):
|
||||||
meta = {}
|
meta = {}
|
||||||
@@ -442,13 +637,19 @@ def enrich_streamers(
|
|||||||
"douyin",
|
"douyin",
|
||||||
{"label_zh": "抖音", "icon": "ui-icon/platform_douyin.png"},
|
{"label_zh": "抖音", "icon": "ui-icon/platform_douyin.png"},
|
||||||
)
|
)
|
||||||
|
meta.setdefault(
|
||||||
|
"douyu",
|
||||||
|
{"label_zh": "斗鱼", "icon": "ui-icon/platform_douyu.png"},
|
||||||
|
)
|
||||||
payload["platform_meta"] = meta
|
payload["platform_meta"] = meta
|
||||||
_ = PROFILE_KEYS
|
_ = PROFILE_KEYS
|
||||||
return ok, skip, fail
|
return ok, skip, fail
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="Enrich streamers.json from Douyin profiles")
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Enrich streamers.json from Douyin / Douyu profiles"
|
||||||
|
)
|
||||||
ap.add_argument("--out", type=Path, default=OUT)
|
ap.add_argument("--out", type=Path, default=OUT)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--ids",
|
"--ids",
|
||||||
|
|||||||
@@ -20,4 +20,7 @@
|
|||||||
Cache-Control: public, max-age=60, must-revalidate
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
/style.css
|
/style.css
|
||||||
Cache-Control: public, max-age=300, must-revalidate
|
Cache-Control: public, max-age=60, must-revalidate
|
||||||
|
|
||||||
|
/fonts/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Cloudflare Pages: static files win; these cover History deep links without a file.
|
||||||
|
/heroes/:key/:tab /heroes/:key/index.html 200
|
||||||
|
/* /index.html 200
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||||
var SITE_VERSION = "0.5.109";
|
var SITE_VERSION = "0.6.57";
|
||||||
|
var SITE_ORIGIN = "";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
/* Player pages JSON (OSS players/); empty → STATIC_ASSET_BASE, then local /api/players. */
|
||||||
|
var PLAYERS_ASSET_BASE = "";
|
||||||
|
|
||||||
|
|||||||