v0.5.84: matches origin filter, mobile gate, refresh reliability.

Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 18:31:55 +08:00
co-authored by Cursor
parent 7681fdb069
commit b01552ee6e
50 changed files with 3406 additions and 487 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+548 -42
View File
@@ -1,49 +1,555 @@
{
"meta": {
"note_zh": "手工维护的明星选手 OpenDota account_idfetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。",
"source": "opendota/proPlayers",
"note_zh": "手工维护的明星选手 OpenDota account_idfetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。对照 Dotabuff / Liquipedia playerid。外号:查理斯→ChaliceSomnus 即 Maybe(路垚);Faith_bian 现常用 Bach。",
"source": "opendota/proPlayers + dotabuff.com/players",
"updated_at": "2026-07-29"
},
"players": [
{"account_id": 898754153, "name": "Ame", "team_tag": "XG", "region": "cn"},
{"account_id": 173978074, "name": "NothingToSay", "team_tag": "XG", "region": "cn"},
{"account_id": 94296097, "name": "xNova", "team_tag": "XG", "region": "cn"},
{"account_id": 129958758, "name": "Xxs", "team_tag": "XG", "region": "cn"},
{"account_id": 101695162, "name": "fy", "team_tag": "XG", "region": "cn"},
{"account_id": 157475523, "name": "XinQ", "team_tag": "VG", "region": "cn"},
{"account_id": 320252024, "name": "shiro", "team_tag": "VG", "region": "cn"},
{"account_id": 148215639, "name": "Monet", "team_tag": "", "region": "cn"},
{"account_id": 854671154, "name": "XM", "team_tag": "", "region": "cn"},
{"account_id": 292921272, "name": "Wisper", "team_tag": "LGD", "region": "cn"},
{"account_id": 321580662, "name": "Yatoro", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 302214028, "name": "Collapse", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 106305042, "name": "Larl", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 113331514, "name": "Miposhka", "team_tag": "TSpirit", "region": "eu"},
{"account_id": 100058342, "name": "skiter", "team_tag": "FLCN", "region": "eu"},
{"account_id": 183719386, "name": "AMMAR_THE_F", "team_tag": "FLCN", "region": "eu"},
{"account_id": 898455820, "name": "Malr1ne", "team_tag": "FLCN", "region": "eu"},
{"account_id": 10366616, "name": "Sneyking", "team_tag": "FLCN", "region": "eu"},
{"account_id": 201358612, "name": "Nisha", "team_tag": "Liquid", "region": "eu"},
{"account_id": 77490514, "name": "Boxi", "team_tag": "Liquid", "region": "eu"},
{"account_id": 16497807, "name": "tOfu", "team_tag": "Liquid", "region": "eu"},
{"account_id": 54580962, "name": "Insania", "team_tag": "Liquid", "region": "eu"},
{"account_id": 93618577, "name": "bzm", "team_tag": "1w", "region": "eu"},
{"account_id": 331855530, "name": "Pure", "team_tag": "1w", "region": "eu"},
{"account_id": 86698277, "name": "33", "team_tag": "1w", "region": "eu"},
{"account_id": 136829091, "name": "Whitemon", "team_tag": "1w", "region": "eu"},
{"account_id": 124801257, "name": "Nightfall", "team_tag": "Aurora", "region": "eu"},
{"account_id": 858106446, "name": "kiyotaka", "team_tag": "Aurora", "region": "eu"},
{"account_id": 480412663, "name": "gpk~", "team_tag": "BB", "region": "eu"},
{"account_id": 196878136, "name": "Kataomi`", "team_tag": "BB", "region": "eu"},
{"account_id": 1044002267, "name": "Satanic", "team_tag": "VSN", "region": "eu"},
{"account_id": 127617979, "name": "Crystallis", "team_tag": "MOUZ", "region": "eu"},
{"account_id": 431770905, "name": "TORONTOTOKYO", "team_tag": "OG", "region": "eu"},
{"account_id": 111620041, "name": "SumaiL-", "team_tag": "NGX", "region": "eu"},
{"account_id": 103735745, "name": "Saksa", "team_tag": "TY", "region": "eu"}
{
"account_id": 130416036,
"name": "Agressif",
"team_tag": "",
"region": "cn"
},
{
"account_id": 94738847,
"name": "Chalice",
"team_tag": "",
"region": "cn"
},
{
"account_id": 140613225,
"name": "Cty",
"team_tag": "",
"region": "cn"
},
{
"account_id": 143693439,
"name": "Dy",
"team_tag": "",
"region": "cn"
},
{
"account_id": 88585077,
"name": "Ferrari_430",
"team_tag": "",
"region": "cn"
},
{
"account_id": 148215639,
"name": "Monet",
"team_tag": "",
"region": "cn"
},
{
"account_id": 134276083,
"name": "old eLeVeN",
"team_tag": "",
"region": "cn"
},
{
"account_id": 137193239,
"name": "Paparazi灬",
"team_tag": "",
"region": "cn"
},
{
"account_id": 138543123,
"name": "Pyw",
"team_tag": "",
"region": "cn"
},
{
"account_id": 149486894,
"name": "Sccc",
"team_tag": "",
"region": "cn"
},
{
"account_id": 106863163,
"name": "Somnus/Maybe",
"team_tag": "",
"region": "cn"
},
{
"account_id": 108382060,
"name": "Sylar",
"team_tag": "",
"region": "cn"
},
{
"account_id": 98887913,
"name": "xiao8",
"team_tag": "",
"region": "cn"
},
{
"account_id": 854671154,
"name": "XM",
"team_tag": "",
"region": "cn"
},
{
"account_id": 107803494,
"name": "Ori",
"team_tag": "AR",
"region": "cn"
},
{
"account_id": 118134220,
"name": "Bach",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 320252024,
"name": "shiro",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 157475523,
"name": "XinQ",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 137129583,
"name": "Xm",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 111114687,
"name": "y`",
"team_tag": "VG",
"region": "cn"
},
{
"account_id": 898754153,
"name": "Ame",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 101695162,
"name": "fy",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 173978074,
"name": "NothingToSay",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 94296097,
"name": "xNova",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 129958758,
"name": "Xxs",
"team_tag": "XG",
"region": "cn"
},
{
"account_id": 207829314,
"name": "BoBoKa",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 202217968,
"name": "Emo",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 185908355,
"name": "Lou",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 139822354,
"name": "Setsu",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 147767183,
"name": "Zeal",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 158847773,
"name": "天命",
"team_tag": "YB",
"region": "cn"
},
{
"account_id": 86698277,
"name": "33",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 346412363,
"name": "Ari",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 93618577,
"name": "bzm",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 331855530,
"name": "Pure",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 136829091,
"name": "Whitemon",
"team_tag": "1w",
"region": "eu"
},
{
"account_id": 320219866,
"name": "kaori",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 858106446,
"name": "kiyotaka",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 256156323,
"name": "Mira",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 124801257,
"name": "Nightfall",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 126842529,
"name": "Ws`",
"team_tag": "Aurora",
"region": "eu"
},
{
"account_id": 480412663,
"name": "gpk~",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 196878136,
"name": "Kataomi`",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 172099728,
"name": "Kiritych~",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 165564598,
"name": "MieRo",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 317880638,
"name": "Save-",
"team_tag": "BB",
"region": "eu"
},
{
"account_id": 183719386,
"name": "AMMAR_THE_F",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 25907144,
"name": "Cr1t-",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 898455820,
"name": "Malr1ne",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 100058342,
"name": "skiter",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 10366616,
"name": "Sneyking",
"team_tag": "FLCN",
"region": "eu"
},
{
"account_id": 97590558,
"name": "Ace ♠",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 77490514,
"name": "Boxi",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 54580962,
"name": "Insania",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 152962063,
"name": "m1CKe",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 201358612,
"name": "Nisha",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 16497807,
"name": "tOfu",
"team_tag": "Liquid",
"region": "eu"
},
{
"account_id": 190826739,
"name": "BOOM",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 127617979,
"name": "Crystallis",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 116585378,
"name": "MidOne",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 108958769,
"name": "panto",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 9403474,
"name": "yamich",
"team_tag": "MOUZ",
"region": "eu"
},
{
"account_id": 101356886,
"name": "GH",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 140297552,
"name": "No!ob™",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 152168157,
"name": "OmaR",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 111620041,
"name": "SumaiL-",
"team_tag": "NGX",
"region": "eu"
},
{
"account_id": 355168766,
"name": "Natsumi",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 100594231,
"name": "skem",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 155494381,
"name": "TIMS",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 431770905,
"name": "TORONTOTOKYO",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 324277900,
"name": "Yopaj-",
"team_tag": "OG",
"region": "eu"
},
{
"account_id": 302214028,
"name": "Collapse",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 106305042,
"name": "Larl",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 113331514,
"name": "Miposhka",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 847565596,
"name": "rue",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 321580662,
"name": "Yatoro",
"team_tag": "TSpirit",
"region": "eu"
},
{
"account_id": 312436974,
"name": "CHIRA_JUNIOR",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 56351509,
"name": "DM",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 93817671,
"name": "Maladych",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 103735745,
"name": "Saksa",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 171262902,
"name": "医者watson`",
"team_tag": "TY",
"region": "eu"
},
{
"account_id": 140288368,
"name": "Tobi",
"team_tag": "Tundra",
"region": "eu"
},
{
"account_id": 94054712,
"name": "Topson",
"team_tag": "Tundra",
"region": "eu"
},
{
"account_id": 73401082,
"name": "Dukalis",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 106573901,
"name": "No[o]ne-",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 195108598,
"name": "Noticed",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 1044002267,
"name": "Satanic",
"team_tag": "VSN",
"region": "eu"
},
{
"account_id": 131303632,
"name": "4nalog01",
"team_tag": "Heroic",
"region": "sa"
},
{
"account_id": 81306398,
"name": "KingJungles",
"team_tag": "Heroic",
"region": "sa"
},
{
"account_id": 292921272,
"name": "Wisper",
"team_tag": "Heroic",
"region": "sa"
},
{
"account_id": 177203952,
"name": "Yuma",
"team_tag": "Heroic",
"region": "sa"
}
]
}
+224 -2
View File
@@ -19,7 +19,8 @@
"profile_url": "https://v.douyin.com/MM3U-EpqI2E/",
"tagline": "天怒绝活哥",
"heroes": [
"skywrath_mage"
"skywrath_mage",
"shredder"
],
"nickname": "DOTA2小王",
"unique_id": "chenshiweidota",
@@ -40,7 +41,8 @@
"live_url": "https://live.douyin.com/892762012034",
"profile_url": "https://v.douyin.com/FcUd6qHXYwc/",
"heroes": [
"juggernaut"
"juggernaut",
"phantom_assassin"
],
"nickname": "Dota2剑心犹在",
"unique_id": "33552682609",
@@ -255,6 +257,226 @@
"video_title": "Dota2沙王直播录像",
"is_live": true,
"live_probed_at": "2026-07-29T05:14:20.981087+00:00"
},
{
"id": "qitiandasheng",
"platform": "douyin",
"profile_url": "https://v.douyin.com/BclSpsH9RJ0/",
"tagline": "万场绝活大圣王",
"heroes": [
"monkey_king"
],
"nickname": "Dota2-yellow",
"unique_id": "381896350",
"signature": "万场绝活大圣王 关注小黄带给你顶级的大圣教学\n辉耀魔晶法师克星巫师之刃开创者\n接定位上车打号yellow6x",
"following_count": 276,
"follower_count": 31000,
"total_favorited": 53000,
"avatar": "streamer_avatars/qitiandasheng.jpg",
"video": "streamer_videos/qitiandasheng.mp4",
"video_aspect": "1080/1920",
"video_fit": "cover",
"video_title": "你从未见过的4v6 4圣剑一刷新超凡局逆天灭世翻盘! 求仙问卜不如自己做主!!这把我要赢!",
"profile_fetched_at": "2026-07-29T08:35:00+00:00"
},
{
"id": "luna",
"platform": "douyin",
"live_url": "https://live.douyin.com/25345747701",
"profile_url": "https://v.douyin.com/qTjo7mybfZA/",
"tagline": "国服第九露娜",
"heroes": [
"luna"
],
"nickname": "DOTA2冰凉皮蛋",
"unique_id": "dy19wol6d5wn",
"signature": "DOTA2万分一号位接上分定位赛陪玩复盘\n主播主玩145,话痨型教学主播,直播间一定能学到技术",
"following_count": 11,
"follower_count": 1025,
"total_favorited": 1496,
"avatar": "streamer_avatars/luna.jpg",
"video": "streamer_videos/luna.mp4",
"video_title": "冠绝局露娜被破三路逆天翻盘 前期三路劣,中单凯经济老六毫无节奏,死活不出bkb各种被抓,全场被压着打,最后破三路一波翻盘",
"profile_fetched_at": "2026-07-29T09:08:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:08:00+00:00"
},
{
"id": "wenyi",
"platform": "douyin",
"live_url": "https://live.douyin.com/273765057602",
"profile_url": "https://v.douyin.com/DOK1nsY-zio/",
"tagline": "瘟疫法师",
"heroes": [
"necrolyte"
],
"nickname": "Dota2教练麟仔",
"unique_id": "qyjjjjj",
"signature": "打号加我:ymshkm ,ok林仔外门弟子\n排名最高前400,抖音林文化传播员\n有点小帅 心态顶级欢迎弹幕随意互动\n刀龄14年 教你最好的上分姿势爬出鱼塘\n直播时间:上午10点开播或下午2点开播\n我的猪宝贝@鱼鱼困了 (臭臭版",
"following_count": 527,
"follower_count": 26000,
"total_favorited": 6391,
"avatar": "streamer_avatars/wenyi.jpg",
"profile_fetched_at": "2026-07-29T09:22:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:22:00+00:00"
},
{
"id": "tumao",
"platform": "douyin",
"live_url": "https://live.douyin.com/134896053323",
"profile_url": "https://v.douyin.com/jhBRzuYLsQo/",
"tagline": "绝活土猫",
"heroes": [
"earth_spirit"
],
"nickname": "Dota2-土猫King",
"unique_id": "dota2_zkm",
"signature": "当我拿出土猫那一刻 阁下该如何应对 激情dota\n优势那肯定是队友强 劣势由土猫保驾护航",
"following_count": 88,
"follower_count": 11000,
"total_favorited": 2060,
"avatar": "streamer_avatars/tumao.jpg",
"video": "streamer_videos/tumao.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "实名点赞我的老板 关键跳羊带我比赛",
"profile_fetched_at": "2026-07-29T09:25:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:25:00+00:00"
},
{
"id": "huskar",
"platform": "douyin",
"live_url": "https://live.douyin.com/146558623901",
"profile_url": "https://v.douyin.com/Vcwyd4ol0ys/",
"tagline": "国服第一神灵武士",
"heroes": [
"huskar"
],
"nickname": "DOTA2 阿黎(只会玩神灵)",
"unique_id": "2056381253",
"signature": "老干爹前职业选手 | 国服第一神灵武士\n残血即是斩杀线,血条清空才是游戏开始\n接pw/代练/教学:yoyo_ice09 私信秒回\n老干爹英魂之刃前职业| 国一神灵认证\nDota2单排9000+ | 胜率78%+\n坐标:岳阳市 | 直播时间:全天不定时",
"following_count": 235,
"follower_count": 1602,
"total_favorited": 362,
"avatar": "streamer_avatars/huskar.jpg",
"video": "streamer_videos/huskar.mp4",
"video_title": "杀戮日常,超凡三黑",
"profile_fetched_at": "2026-07-29T09:28:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:28:00+00:00"
},
{
"id": "fatiao",
"platform": "douyin",
"profile_url": "https://v.douyin.com/ZBmUklVKXm4/",
"tagline": "发条",
"heroes": [
"rattletrap",
"magnataur"
],
"nickname": "刘大独(DOTA2",
"unique_id": "Ycn5505",
"signature": "水瓶老玩家 无公司 不签约 不一样的玩法 不一样的思维",
"following_count": 863,
"follower_count": 115000,
"total_favorited": 1132000,
"avatar": "streamer_avatars/fatiao.jpg",
"video": "streamer_videos/fatiao.mp4",
"video_title": "DOTA2发条 这版本发条是真好玩",
"profile_fetched_at": "2026-07-29T09:40:00+00:00"
},
{
"id": "zhadan",
"platform": "douyin",
"profile_url": "https://v.douyin.com/JTBBqWZFhVo/",
"tagline": "本色风情 · 炸弹人",
"heroes": [
"techies",
"furion"
],
"nickname": "bino",
"unique_id": "69276358589",
"signature": "做好玩的dota2对局,轻松快乐最重要 xwf2783",
"following_count": 224,
"follower_count": 96000,
"total_favorited": 881000,
"avatar": "streamer_avatars/zhadan.jpg",
"video": "streamer_videos/zhadan.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "什么叫20分钟锁了对面5个人在家?",
"profile_fetched_at": "2026-07-29T09:42:00+00:00"
},
{
"id": "kaer",
"platform": "douyin",
"profile_url": "https://v.douyin.com/rSCOHKhoktg/",
"tagline": "绝活卡尔",
"heroes": [
"invoker"
],
"nickname": "Dota2卡尔卡滨kb",
"unique_id": "380362637",
"signature": "2013年刚出天梯时无脑一手卡尔到天梯前10",
"following_count": 220,
"follower_count": 38000,
"total_favorited": 43000,
"avatar": "streamer_avatars/kaer.jpg",
"video": "streamer_videos/kaer.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "万2均分携手雕哥游龙",
"profile_fetched_at": "2026-07-29T09:45:00+00:00"
},
{
"id": "lanmao",
"platform": "douyin",
"live_url": "https://live.bilibili.com/32554189",
"profile_url": "https://v.douyin.com/LKxUBIHCC2c/",
"tagline": "绝活蓝猫",
"heroes": [
"storm_spirit"
],
"nickname": "小铅笔HB",
"unique_id": "786543863",
"signature": "Muteki01(上车,教学,商务)",
"following_count": 644,
"follower_count": 43000,
"total_favorited": 470000,
"avatar": "streamer_avatars/lanmao.jpg",
"video": "streamer_videos/lanmao.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "你的一波失误换来的就是蓝猫时刻!",
"profile_fetched_at": "2026-07-29T09:48:00+00:00",
"is_live": false,
"live_probed_at": "2026-07-29T09:49:35.253522+00:00"
},
{
"id": "huonv",
"platform": "douyin",
"live_url": "https://live.douyin.com/930419992694",
"profile_url": "https://v.douyin.com/TleGRW0DhNo/",
"tagline": "火女",
"heroes": [
"lina"
],
"nickname": "新生Dota2",
"unique_id": "xsdota2",
"signature": "坚持认真做好玩的Dota2攻略。 上车/合作:xsdota2",
"following_count": 280,
"follower_count": 66000,
"total_favorited": 1105000,
"avatar": "streamer_avatars/huonv.jpg",
"video": "streamer_videos/huonv.mp4",
"video_aspect": "576/1024",
"video_fit": "cover",
"video_title": "2个镜头告诉你为什么火女必出A",
"profile_fetched_at": "2026-07-29T09:54:00+00:00",
"is_live": true,
"live_probed_at": "2026-07-29T09:54:00+00:00"
}
]
}
+85 -6
View File
@@ -34,7 +34,9 @@ import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
@@ -239,10 +241,16 @@ def wrangler_deploy(
) -> None:
"""Upload dist via `wrangler pages deploy` (direct upload, non-interactive).
The whole dist directory is uploaded, so dist/functions/ (Pages Functions,
e.g. /api/live-status) is included automatically — Pages Functions need
no extra wrangler config or flags for direct-upload projects.
Wrangler looks for ``functions/`` relative to the process cwd (not inside
an absolute asset-directory argument). Run from ``dist`` and deploy ``.``
so ``dist/functions/`` (e.g. /api/live-status) is compiled and uploaded.
"""
functions_dir = dist / "functions"
if not functions_dir.is_dir():
print(
f"warn: {functions_dir} missing — Pages Functions will not deploy "
"(re-run export_relations_site.py)"
)
env = os.environ.copy()
# wrangler reads Global API Key auth from these env vars. They are passed
# to the subprocess only; wrangler does not echo them.
@@ -256,15 +264,76 @@ def wrangler_deploy(
"wrangler@3",
"pages",
"deploy",
str(dist),
".",
"--project-name",
project,
"--commit-dirty",
"--branch",
"main",
]
print(f"uploading {dist} via wrangler ...")
subprocess.run(cmd, cwd=str(ROOT), env=env, check=True)
print(f"uploading {dist} via wrangler (cwd=dist, functions={functions_dir.is_dir()}) ...")
subprocess.run(cmd, cwd=str(dist), env=env, check=True)
def smoke_test_production(
base_url: str,
*,
expected_run_id: str = "",
attempts: int = 6,
delay_s: float = 5.0,
) -> None:
"""Verify the deployed payload and Pages Function after propagation."""
base = base_url.rstrip("/")
marker = expected_run_id or str(int(time.time()))
error = "unknown failure"
for attempt in range(1, attempts + 1):
try:
data_url = f"{base}/data.json?refresh={urllib.parse.quote(marker)}"
req = urllib.request.Request(data_url, headers={"User-Agent": "climperor-smoke"})
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
if not isinstance(payload, dict) or not payload.get("heroes"):
raise RuntimeError("data.json has no heroes")
actual_run_id = str((payload.get("meta") or {}).get("refresh_run_id") or "")
if expected_run_id and actual_run_id != expected_run_id:
raise RuntimeError(
f"stale deployment run_id={actual_run_id!r}, expected={expected_run_id!r}"
)
live_url = f"{base}/api/live-status?refresh={urllib.parse.quote(marker)}"
live_req = urllib.request.Request(
live_url, headers={"User-Agent": "climperor-smoke"}
)
with urllib.request.urlopen(live_req, timeout=30) as resp:
content_type = resp.headers.get_content_type()
live_cache = (resp.headers.get("X-Live-Cache") or "").lower()
live_payload = json.loads(resp.read().decode("utf-8"))
if content_type != "application/json" or not isinstance(live_payload, dict):
raise RuntimeError(
f"/api/live-status is not JSON (content-type={content_type})"
)
if live_cache in ("error", "stale-override"):
print(
f"warn: live-status Function is routed but probe state is {live_cache}"
)
print(
f"production smoke passed: {base} "
f"(heroes={len(payload['heroes'])}, run_id={actual_run_id or '-'}, "
f"live_cache={live_cache or '?'})"
)
return
except (
OSError,
ValueError,
RuntimeError,
json.JSONDecodeError,
urllib.error.URLError,
) as exc:
error = str(exc)
print(f"smoke attempt {attempt}/{attempts} failed: {error}")
if attempt < attempts:
time.sleep(delay_s)
raise SystemExit(f"deployment smoke failed after {attempts} attempts: {error}")
def ensure_cname(email: str, api_key: str, domain: str, project: str) -> None:
@@ -358,6 +427,11 @@ def main() -> None:
help="passed to export as --static-asset-base (default: same as ability-video-base "
"or STATIC_ASSET_BASE env)",
)
ap.add_argument(
"--skip-smoke",
action="store_true",
help="skip post-deploy data.json and /api/live-status verification",
)
args = ap.parse_args()
dist = Path(args.dist).resolve()
@@ -379,6 +453,11 @@ def main() -> None:
check_integrity(dist)
wrangler_deploy(dist, args.project_name, email=email, api_key=api_key, account_id=account_id)
bind_domain(email, api_key, account_id, args.project_name, args.domain)
if not args.skip_smoke:
smoke_test_production(
f"https://{args.project_name}.pages.dev",
expected_run_id=os.environ.get("REFRESH_RUN_ID") or "",
)
print()
print(f"deployment url : https://{args.project_name}.pages.dev")
+15 -7
View File
@@ -32,6 +32,7 @@ import json
import os
import shutil
from shared.http_utils import write_json_atomic
from shared.paths import (
ABILITY_ICONS,
ABILITY_VIDEOS,
@@ -50,7 +51,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.72"
SITE_VERSION = "0.5.84"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -187,11 +188,11 @@ def main() -> None:
shutil.rmtree(out)
out.mkdir(parents=True)
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
for name in ("index.html", "router.js", "app.js", "style.css", "mobile-gate.js", "_headers"):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
# Cloudflare Pages Functions (functions/api/live-status.js -> /api/live-status).
# Cloudflare Pages Functions (functions/api/*.js -> /api/*).
functions_src = WEB_DIR / "functions"
n_functions = 0
if functions_src.is_dir():
@@ -205,9 +206,13 @@ def main() -> None:
)
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
refresh_run_id = os.environ.get("REFRESH_RUN_ID") or ""
payload.setdefault("meta", {})["refresh_run_id"] = refresh_run_id
write_json_atomic(
out / "data.json",
payload,
indent=None,
separators=(",", ":"),
)
if static_base:
@@ -246,7 +251,10 @@ def main() -> None:
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print(
f"local Pages Function check: npx wrangler pages dev . "
f"--port 8789 (run from {out})"
)
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
+5 -18
View File
@@ -29,7 +29,7 @@ from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import download_icons, http_json
from shared.http_utils import download_icons, http_json, write_json_atomic
from shared.paths import ABILITY_ICONS, DATA
from loc_format import format_loc, strip_html
@@ -660,10 +660,7 @@ def main() -> None:
meta["tags_only"] = True
meta["mechanic_tag_order"] = list(QUERY_MECHANIC_ORDER)
payload = {"meta": meta, "by_hero": by_hero}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
print(
f"retag done: {len(by_hero)} heroes, {tagged} abilities with tags -> {args.out}",
flush=True,
@@ -723,10 +720,7 @@ def main() -> None:
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
illus = sum(1 for c in by_hero.values() if (c.get("summary") or {}).get("has_illusion"))
print(f"resummarized {len(by_hero)} heroes (illusion={illus}) -> {args.out}", flush=True)
return
@@ -822,10 +816,7 @@ def main() -> None:
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
time.sleep(args.delay)
tagged = retag_abilities(by_hero, ABILITY_TAG_OVERRIDES_MAP)
@@ -840,11 +831,7 @@ def main() -> None:
},
"by_hero": by_hero,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
print(f"done: {len(by_hero)} heroes -> {args.out}", flush=True)
if args.icons:
+3 -12
View File
@@ -24,7 +24,7 @@ import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import download_icons, http_json, load_itemlist_zh
from shared.http_utils import download_icons, http_json, load_itemlist_zh, write_json_atomic
from shared.paths import DATA, ITEM_ICONS
OPENDOTA = "https://api.opendota.com/api"
@@ -284,21 +284,12 @@ def main() -> None:
used_ids.update(int(row["id"]) for row in core if isinstance(row, dict) and "id" in row)
print(f" [{n}/{len(pending)}] {key}: {len(core)} core items", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(build_payload(by_hero, catalog, used_ids), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
write_json_atomic(args.out, build_payload(by_hero, catalog, used_ids))
time.sleep(args.delay)
used_ids = collect_used_ids(by_hero)
payload = build_payload(by_hero, catalog, used_ids)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
if not args.skip_icons:
keys = {catalog[iid]["key"] for iid in used_ids if iid in catalog}
+2 -5
View File
@@ -57,7 +57,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import download_icons, http_json
from shared.http_utils import download_icons, http_json, write_json_atomic
from shared.paths import DATA, ITEM_ICONS
OPENDOTA = "https://api.opendota.com/api"
@@ -989,10 +989,7 @@ def write_out(
"items": items_out,
"by_hero": by_hero,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
write_json_atomic(path, payload)
def needs_player_enrich(row: dict) -> bool:
+9 -7
View File
@@ -20,11 +20,10 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
OPENDOTA_HERO_STATS = "https://api.opendota.com/api/heroStats"
@@ -173,6 +172,8 @@ def main() -> None:
raw = http_json(OPENDOTA_HERO_STATS)
if not isinstance(raw, list):
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
if not raw:
raise SystemExit("heroStats returned an empty list; keeping previous cache")
window_days = detect_window_days(raw)
by_hero: dict[str, dict] = {}
@@ -191,11 +192,12 @@ def main() -> None:
by_hero[key] = row_to_hero_stats(row)
payload = build_payload(by_hero, window_days=window_days)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
if len(by_hero) < max(100, len(heroes) * 4 // 5):
raise SystemExit(
f"heroStats only contained {len(by_hero)}/{len(heroes)} known heroes; "
"keeping previous cache"
)
write_json_atomic(args.out, payload)
missing = [h["key"] for h in heroes if h["key"] not in by_hero]
print(
f"done: {len(by_hero)} heroes -> {args.out}"
+2 -5
View File
@@ -33,6 +33,7 @@ import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import write_json_atomic
from shared.paths import DATA
API = "https://api.opendota.com/api/explorer"
@@ -281,11 +282,7 @@ def main() -> None:
min_games=args.min_games,
min_cost=args.min_cost,
)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(output, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, output)
nonempty = sum(bool(v) for v in output["by_hero"].values())
print(
f"done: {len(rows)} rows, {nonempty} heroes -> {args.out}",
+8 -6
View File
@@ -23,7 +23,13 @@ import time
import urllib.error
from datetime import datetime, timezone
from shared.http_utils import download_icons, http_bytes, http_json, load_itemlist_zh
from shared.http_utils import (
download_icons,
http_bytes,
http_json,
load_itemlist_zh,
write_json_atomic,
)
from shared.paths import DATA, ITEM_CAT_ICONS, ITEM_ICONS
ITEMS_URL = (
@@ -317,11 +323,7 @@ def main() -> None:
"upgraded": {"sections": up_sections},
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
print(
f"wrote {args.out}: basic={payload['meta']['basic_count']} "
f"upgraded={payload['meta']['upgraded_count']} "
+3 -10
View File
@@ -26,7 +26,7 @@ import time
import urllib.error
from datetime import datetime, timezone
from shared.http_utils import download_icons, http_json, load_itemlist
from shared.http_utils import download_icons, http_json, load_itemlist, write_json_atomic
from shared.paths import DATA, ITEM_ICONS
from loc_format import HAS_PLACEHOLDER, format_loc
@@ -406,10 +406,7 @@ def main() -> None:
},
"items": items_out,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
payload = {
"meta": {
@@ -421,11 +418,7 @@ def main() -> None:
},
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
if not args.skip_icons:
keys = {row["key"] for row in items_out.values()}
+7 -7
View File
@@ -21,10 +21,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
OUT = DATA / "leaderboards.json"
@@ -67,6 +66,11 @@ def fetch_division(division: str) -> dict:
lb = raw.get("leaderboard")
if not isinstance(lb, list):
raise SystemExit(f"{division}: missing leaderboard array")
if len(lb) < TOP_N:
raise SystemExit(
f"{division}: leaderboard only returned {len(lb)} rows; "
"keeping previous cache"
)
top = [slim_entry(e) for e in lb[:TOP_N] if isinstance(e, dict)]
return {
"division": division,
@@ -102,11 +106,7 @@ def main() -> None:
regions[div] = fetch_division(div)
payload = build_payload(regions)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
parts = [
f"{div}={len((regions[div].get('top100') or []))}" for div in REGION_ORDER
]
+5 -9
View File
@@ -38,7 +38,7 @@ import time
import urllib.error
from datetime import datetime, timedelta, timezone
from shared.http_utils import http_bytes, http_json
from shared.http_utils import http_bytes, http_json, write_json_atomic
from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
@@ -430,13 +430,10 @@ def main() -> None:
patches = fetch_patch_list(since_ts)
print(f" {len(patches)} patches in window", flush=True)
if not patches:
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps({"meta": {}, "patches": [], "lookup": {}, "details": {}}, ensure_ascii=False, indent=2),
encoding="utf-8",
raise SystemExit(
"patch list returned no versions in the requested window; "
"keeping previous cache"
)
print("no patches in window; wrote empty file", flush=True)
return
cached = {} if args.force else load_existing_details()
details: dict[str, dict] = {}
@@ -485,8 +482,7 @@ def main() -> None:
"lookup": lookup,
"details": details,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
write_json_atomic(OUT, payload)
print(
f"saved {len(patches)} patches, {len(details)} details -> {OUT}",
flush=True,
+27 -23
View File
@@ -1,16 +1,17 @@
"""Fetch recent league/tournament matches for OpenDota registered pros.
"""Fetch recent league/tournament (+ optional ranked) matches for OpenDota pros.
Default source: web/data/pro_player_watchlist.json (hand-curated star IDs).
Falls back to /proPlayers auto-pick only with --all-pros.
Per player: /players/{id}/matches (lobby practice + tournament), then
/matches/{id} for final items + skill builds.
Per player: /players/{id}/matches per lobby_type (practice/tournament, and
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.
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
Usage:
python web/fetch_pro_matches.py
python web/fetch_pro_matches.py --limit 12
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 --all-pros --limit-pros 20 --with-team
"""
@@ -29,7 +30,7 @@ import urllib.error
from datetime import datetime, timedelta, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
from fetch_hero_matches import (
@@ -220,11 +221,15 @@ def player_match_metas(
*,
lobby_types: tuple[int, ...] = LOBBY_LEAGUE,
) -> list[dict]:
"""Recent match list rows for a pro (deduped, newest first)."""
per_lt = max(limit, limit // max(1, len(lobby_types)) + 2)
"""Recent match list rows for a pro (deduped, newest first).
Fetches up to ``limit`` newest matches **per lobby_type**, then merges.
With league + ranked pubs this can return up to ``limit * len(lobby_types)``
rows important so recent league games do not crowd out ranked pubs.
"""
by_id: dict[int, dict] = {}
for lt in lobby_types:
url = f"{OPENDOTA}/players/{account_id}/matches?limit={per_lt}&lobby_type={lt}"
url = f"{OPENDOTA}/players/{account_id}/matches?limit={limit}&lobby_type={lt}"
try:
raw = http_json(url)
except (
@@ -258,11 +263,10 @@ def player_match_metas(
if st_new >= st_old:
by_id[mid] = row
ranked = sorted(
return sorted(
by_id.values(),
key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)),
)
return ranked[:limit]
def fetch_player_matches(
@@ -285,6 +289,12 @@ def fetch_player_matches(
continue
if mid <= 0 or hid <= 0:
continue
lt_raw = meta.get("lobby_type")
try:
lt_i = int(lt_raw) if lt_raw is not None else None
except (TypeError, ValueError):
lt_i = None
row_origin = "public" if lt_i == 7 else "pro"
detail = fetch_match(mid)
if delay > 0:
time.sleep(delay)
@@ -293,7 +303,7 @@ def fetch_player_matches(
slim = extract_player_row(
detail,
hid,
origin="pro",
origin=row_origin,
id_map=id_map,
item_catalog=catalog,
list_meta=meta,
@@ -309,11 +319,7 @@ def fetch_player_matches(
continue
slim["hero_id"] = hid
slim["hero_key"] = id_to_key.get(hid)
lt = meta.get("lobby_type")
try:
slim["lobby_type"] = int(lt) if lt is not None else None
except (TypeError, ValueError):
slim["lobby_type"] = None
slim["lobby_type"] = lt_i
rows.append(slim)
return rows
@@ -390,16 +396,17 @@ def write_out(
"attribution": "https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"player_source": player_source,
"limit_per_pro": limit,
"limit_per_lobby": limit,
"limit_pros": limit_pros,
"lobby_types": list(lobby_types),
"pro_count": len(by_pro),
"match_count": match_count,
"hero_count": len(by_hero),
"note_zh": (
"OpenDota 明星/职业选手近期联赛/锦标赛对局;"
"OpenDota 明星选手近期联赛/锦标赛对局(可选含天梯 lobby_type=7"
"默认名单见 web/data/pro_player_watchlist.json"
"lobby_type 1=训练/practice、2=tournament"
"每种 lobby 各保留最近 limit 场,避免联赛挤掉天梯"
"lobby_type 1=训练/practice、2=tournament、7=ranked"
"含终局出装、加点与联赛名(若有)。"
),
},
@@ -408,10 +415,7 @@ def write_out(
"by_pro": by_pro,
"by_hero": by_hero,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
write_json_atomic(path, payload)
def main() -> None:
+110 -24
View File
@@ -29,13 +29,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import write_json_atomic
from shared.paths import DATA, ROOT
API = "https://api.stratz.com/graphql"
@@ -165,28 +165,6 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def write_json_atomic(path: Path, payload: dict) -> None:
"""Write JSON via temp file then replace, so partial writes never corrupt cache."""
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=str(path.parent),
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp_path, path)
except Exception:
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
raise
def fetch_weeks_for_bracket(
token: str, hero_ids: list[int], bracket: str, take: int
) -> list[dict]:
@@ -511,6 +489,47 @@ def build_meta(
}
def preserve_failed_meta_brackets(
fresh: dict,
previous: dict,
*,
failed_weeks: set[str],
failed_positions: set[str],
) -> dict:
"""Keep previous bracket cells when STRATZ returns no usable rows."""
if not failed_weeks and not failed_positions:
fresh["stale"] = False
fresh["stale_brackets"] = []
return fresh
previous_by_hero = previous.get("by_hero") or {}
fresh_by_hero = fresh.get("by_hero") or {}
for key, cell in fresh_by_hero.items():
old = previous_by_hero.get(key) or {}
for bracket in failed_weeks:
cell["weeks"][bracket] = list((old.get("weeks") or {}).get(bracket) or [])
old_latest = (old.get("latest") or {}).get(bracket)
if old_latest:
cell["latest"][bracket] = dict(old_latest)
for bracket in failed_positions:
cell["positions"][bracket] = dict(
(old.get("positions") or {}).get(bracket) or {}
)
for bracket in failed_weeks:
old_total = (previous.get("totals") or {}).get(bracket)
old_board = (previous.get("meta_board") or {}).get(bracket)
if old_total:
fresh["totals"][bracket] = dict(old_total)
if old_board:
fresh["meta_board"][bracket] = list(old_board)
fresh["stale"] = True
fresh["stale_brackets"] = sorted(failed_weeks | failed_positions)
fresh["last_attempt_at"] = _now_iso()
return fresh
def refresh_matchup_tops(
token: str,
id_to_key: dict[int, str],
@@ -526,6 +545,14 @@ def refresh_matchup_tops(
On per-hero failure, keep the previous cell and mark ``stale=True``.
"""
started_at = _now_iso()
previous_file: dict | None = None
if out_path.is_file():
try:
loaded = json.loads(out_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
previous_file = loaded
except (OSError, json.JSONDecodeError):
previous_file = None
prev = load_previous_matchups(out_path)
hero_ids = sorted(id_to_key.keys())
@@ -539,7 +566,8 @@ def refresh_matchup_tops(
)
else:
pending = list(hero_ids)
by_hero_mu = {}
# Keep the complete previous set until each cell is successfully replaced.
by_hero_mu = dict(prev)
print(
f"full matchup refresh: {len(pending)} heroes "
f"(prev cached={len(prev)} as failure fallback)",
@@ -547,6 +575,7 @@ def refresh_matchup_tops(
)
ok = failed = stale_kept = 0
missing_fallback: list[str] = []
for n, hid in enumerate(pending, start=1):
key = id_to_key[hid]
try:
@@ -588,6 +617,7 @@ def refresh_matchup_tops(
)
else:
print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True)
missing_fallback.append(key)
time.sleep(delay * 2)
continue
@@ -641,6 +671,15 @@ def refresh_matchup_tops(
f"stale_kept={stale_kept}{out_path}",
flush=True,
)
if missing_fallback:
if previous_file is not None:
write_json_atomic(out_path, previous_file)
else:
out_path.unlink(missing_ok=True)
raise RuntimeError(
"STRATZ matchup refresh had failures without cached fallback: "
+ ", ".join(missing_fallback[:10])
)
return payload
@@ -712,9 +751,56 @@ def main() -> None:
)
time.sleep(args.delay)
failed_weeks = {b for b, rows in weeks_by_bracket.items() if not rows}
failed_positions = {
b for b, rows in position_rows_by_bracket.items() if not rows
}
previous_meta: dict = {}
if args.out_meta.is_file():
try:
loaded = json.loads(args.out_meta.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
previous_meta = loaded
except (OSError, json.JSONDecodeError):
previous_meta = {}
previous_by_hero = previous_meta.get("by_hero") or {}
missing_week_fallback = [
bracket
for bracket in failed_weeks
if not any(
(cell.get("weeks") or {}).get(bracket)
for cell in previous_by_hero.values()
if isinstance(cell, dict)
)
]
missing_position_fallback = [
bracket
for bracket in failed_positions
if not any(
(cell.get("positions") or {}).get(bracket)
for cell in previous_by_hero.values()
if isinstance(cell, dict)
)
]
if missing_week_fallback or missing_position_fallback:
raise RuntimeError(
"STRATZ meta failed without cached fallback: weeks="
f"{missing_week_fallback}, positions={missing_position_fallback}"
)
meta = build_meta(
weeks_by_bracket, position_rows_by_bracket, id_to_key, args.weeks
)
meta = preserve_failed_meta_brackets(
meta,
previous_meta,
failed_weeks=failed_weeks,
failed_positions=failed_positions,
)
if len(failed_weeks) == len(BRACKET_ORDER):
# Preserve the timestamp of the last successful data snapshot.
meta["fetched_at"] = previous_meta.get("fetched_at")
write_json_atomic(args.out_meta, meta)
print(f"wrote {args.out_meta}", flush=True)
+16 -5
View File
@@ -5,6 +5,9 @@ For every streamer row with ``live_url`` this script writes:
- ``is_live`` True only when the platform confirms the room is live
- ``live_probed_at`` UTC ISO timestamp of the successful probe
Probe backend follows the ``live_url`` host (may differ from profile
``platform``, e.g. Douyin profile + Bilibili room).
Approach (verified 2026-07):
- Bilibili: public API ``api.live.bilibili.com/room/v1/Room/get_info`` with the
@@ -155,6 +158,16 @@ def room_ref_from_url(live_url: str) -> str | None:
return path.split("/")[0] or None
def live_platform_from_url(live_url: str, fallback: str = "") -> str:
"""Probe backend follows the live room host (may differ from profile platform)."""
host = urllib.parse.urlparse(live_url.strip()).netloc.lower()
if "bilibili.com" in host:
return "bilibili"
if "douyin.com" in host:
return "douyin"
return (fallback or "").strip().lower()
def probe_streamers(
payload: dict, *, ids: set[str] | None = None
) -> tuple[int, int, int]:
@@ -172,7 +185,8 @@ def probe_streamers(
live_url = str(row.get("live_url") or "").strip()
if not live_url:
continue
platform = str(row.get("platform") or "").strip().lower()
fallback = str(row.get("platform") or "").strip().lower()
platform = live_platform_from_url(live_url, fallback)
ref = room_ref_from_url(live_url)
if not ref:
print(f"skip {sid}: cannot parse room ref from {live_url!r}", flush=True)
@@ -244,10 +258,7 @@ def main() -> int:
if args.dry_run:
print(f"dry-run: would write {args.out} ({summary})", flush=True)
return 0
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
http_utils.write_json_atomic(args.out, payload)
print(f"wrote {args.out} {summary}", flush=True)
# Soft-fail for CI/refresh_web: always exit 0 after writing.
return 0
+2 -5
View File
@@ -32,6 +32,7 @@ import urllib.request
from datetime import datetime, timezone
from typing import Any
from shared.http_utils import write_json_atomic
from shared.paths import DATA, STREAMER_AVATARS
OUT = DATA / "streamers.json"
@@ -94,11 +95,7 @@ def _load(path: Path) -> dict:
def _save(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(path, payload)
def _opener() -> urllib.request.OpenerDirector:
+3
View File
@@ -16,5 +16,8 @@
/router.js
Cache-Control: public, max-age=60, must-revalidate
/mobile-gate.js
Cache-Control: public, max-age=60, must-revalidate
/style.css
Cache-Control: public, max-age=300, must-revalidate
+422 -119
View File
@@ -88,6 +88,10 @@ const state = {
rankingRegion: "china",
/** Top-level matches page: OpenDota account_id string (null = all stars) */
matchesPlayerId: null,
/** Top-level matches page: all | pro | china */
matchesOrigin: "all",
/** Top-level matches page: 1-based page index */
matchesPage: 1,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
@@ -390,7 +394,7 @@ const BRACKET_RANK_ICON = {
/** Recent matches tab: newest N ladder+league games. */
const MATCHES_DISPLAY_LIMIT = 10;
const PRO_MATCHES_PAGE_LIMIT = 60;
const PRO_MATCHES_PAGE_SIZE = 20;
function heroStatsPack() {
return (
@@ -1056,6 +1060,139 @@ function heroMatchesFor(heroKey) {
return out;
}
/** Pad 09 → "00"…"09" for HH:mm. */
function pad2(n) {
return String(n).padStart(2, "0");
}
/**
* Parse unix sec/ms, ISO string, or Date epoch ms. Date-only YYYY-MM-DD is
* treated as Asia/Shanghai midnight (avoids UTC-day shift for CN dates).
*/
function parseTimeMs(input) {
if (input == null || input === "") return NaN;
if (input instanceof Date) {
const t = input.getTime();
return Number.isFinite(t) ? t : NaN;
}
if (typeof input === "number") {
if (!Number.isFinite(input) || input <= 0) return NaN;
return input > 1e12 ? input : input * 1000;
}
const s = String(input).trim();
if (!s) return NaN;
if (/^\d+(\.\d+)?$/.test(s)) {
const n = Number(s);
if (!Number.isFinite(n) || n <= 0) return NaN;
return n > 1e12 ? n : n * 1000;
}
const dayOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (dayOnly) {
const t = new Date(
`${dayOnly[1]}-${dayOnly[2]}-${dayOnly[3]}T00:00:00+08:00`
).getTime();
return Number.isFinite(t) ? t : NaN;
}
const t = Date.parse(s);
return Number.isFinite(t) ? t : NaN;
}
/** Calendar + clock parts in Asia/Shanghai (site audience). */
function shanghaiParts(ms) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date(ms));
const get = (type) => {
const hit = parts.find((p) => p.type === type);
return hit ? hit.value : "";
};
let hour = get("hour");
// Some engines emit "24" for midnight under hour12:false.
if (hour === "24") hour = "00";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour: Number(hour),
minute: Number(get("minute")),
};
}
/**
* Absolute wall time for CN UI.
* Same year: "6/13 19:00"; other year: "2024/6/13 19:00".
* dateOnly omits the clock.
*/
function formatAbsoluteTime(input, { dateOnly = false } = {}) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return "";
const p = shanghaiParts(ms);
const now = shanghaiParts(Date.now());
const date =
p.year === now.year
? `${p.month}/${p.day}`
: `${p.year}/${p.month}/${p.day}`;
if (dateOnly) return date;
return `${date} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
/** Full absolute for tooltips: "2026/7/29 16:48". */
function formatAbsoluteTimeFull(input) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return "";
const p = shanghaiParts(ms);
return `${p.year}/${p.month}/${p.day} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
/**
* Hybrid friendly time (CN feed convention):
* 刚刚 N分钟前 N小时前 昨天[ HH:mm] N天前 N周前 M/D[ HH:mm].
* Returns { text, title } so callers can set hover absolute.
*/
function formatFriendlyTime(input, { dateOnly = false, now = Date.now() } = {}) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return { text: "", title: "" };
const title = formatAbsoluteTimeFull(ms);
const abs = () => formatAbsoluteTime(ms, { dateOnly });
const diff = now - ms;
// Clock skew / future → absolute.
if (diff < -60_000) return { text: abs(), title };
const sec = Math.floor(Math.max(0, diff) / 1000);
if (sec < 45) return { text: "刚刚", title };
if (sec < 3600) {
return { text: `${Math.max(1, Math.floor(sec / 60))}分钟前`, title };
}
if (sec < 86400) {
return { text: `${Math.max(1, Math.floor(sec / 3600))}小时前`, title };
}
const p = shanghaiParts(ms);
const n = shanghaiParts(now);
const dayStart = (parts) => Date.UTC(parts.year, parts.month - 1, parts.day);
const dayDiff = Math.round((dayStart(n) - dayStart(p)) / 86400000);
if (dayDiff === 1) {
const text = dateOnly
? "昨天"
: `昨天 ${pad2(p.hour)}:${pad2(p.minute)}`;
return { text, title };
}
if (dayDiff >= 2 && dayDiff < 7) {
return { text: `${dayDiff}天前`, title };
}
if (dayDiff >= 7 && dayDiff < 45) {
return { text: `${Math.max(1, Math.floor(dayDiff / 7))}周前`, title };
}
return { text: abs(), title };
}
function formatMatchDuration(sec) {
const n = Number(sec) || 0;
if (n <= 0) return "—";
@@ -1064,21 +1201,6 @@ function formatMatchDuration(sec) {
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatMatchTime(ts) {
const n = Number(ts);
if (!Number.isFinite(n) || n <= 0) return "";
try {
return new Date(n * 1000).toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
const RANK_TIER_MEDALS = [
null,
"herald",
@@ -1297,19 +1419,13 @@ function buildMatchCard(heroKey, row, opts = {}) {
const origin = document.createElement("span");
origin.className = "match-origin";
if (row.origin === "public") {
const cluster = Number(row.cluster);
const region = Number(row.region);
const chinaRegions = new Set([12, 13, 17, 18, 20, 25]);
const chinaClusters = new Set([
221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417,
]);
const isChina =
(Number.isFinite(region) && chinaRegions.has(region)) ||
(Number.isFinite(cluster) && chinaClusters.has(cluster));
origin.textContent = isChina ? "国服" : "天梯";
if (isChina) origin.classList.add("match-origin-china");
} else if (row.origin === "pro") {
const kind = matchOriginKind(row);
if (kind === "china") {
origin.textContent = "国服";
origin.classList.add("match-origin-china");
} else if (kind === "ladder") {
origin.textContent = "天梯";
} else if (kind === "pro") {
origin.textContent = "职业";
origin.classList.add("match-origin-pro");
} else {
@@ -1325,11 +1441,12 @@ function buildMatchCard(heroKey, row, opts = {}) {
head.appendChild(league);
}
const when = formatMatchTime(row.start_time);
if (when) {
const whenInfo = formatFriendlyTime(row.start_time);
if (whenInfo.text) {
const timeEl = document.createElement("span");
timeEl.className = "match-time";
timeEl.textContent = when;
timeEl.textContent = whenInfo.text;
if (whenInfo.title) timeEl.title = whenInfo.title;
head.appendChild(timeEl);
}
@@ -1846,9 +1963,10 @@ function buildStatsPanel(heroKey) {
const foot = document.createElement("div");
foot.className = "detail-muted detail-stats-note";
const pack = heroStatsPack();
const fetched = pack.fetched_at
? `拉取于 ${String(pack.fetched_at).slice(0, 10)}`
: "";
const fetchedInfo = pack.fetched_at
? formatFriendlyTime(pack.fetched_at)
: { text: "", title: "" };
const fetched = fetchedInfo.text ? `拉取于 ${fetchedInfo.text}` : "";
foot.textContent = [
`OpenDota ${heroStatsWindowLabel()}`,
BRACKET_LABELS[bracket] || bracket,
@@ -1856,6 +1974,7 @@ function buildStatsPanel(heroKey) {
]
.filter(Boolean)
.join(" · ");
if (fetchedInfo.title) foot.title = fetchedInfo.title;
block.appendChild(foot);
}
return block;
@@ -1866,12 +1985,6 @@ function formatMatchupWr(wr) {
return `${(Number(wr) * 100).toFixed(1)}%`;
}
function formatMatchupFetched(iso) {
if (!iso) return "";
const s = String(iso);
return s.length >= 10 ? s.slice(0, 10) : s;
}
function matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
@@ -2026,11 +2139,14 @@ function buildMatchupsPanel(heroKey) {
notes.appendChild(line);
}
const fetched = formatMatchupFetched(cell.fetched_at || pack.fetched_at);
if (fetched) {
const fetchedInfo = formatFriendlyTime(
cell.fetched_at || pack.fetched_at
);
if (fetchedInfo.text) {
const time = document.createElement("p");
time.className = "matchup-note-time";
time.textContent = `更新于 ${fetched}`;
time.textContent = `更新于 ${fetchedInfo.text}`;
if (fetchedInfo.title) time.title = fetchedInfo.title;
notes.appendChild(time);
}
@@ -3160,7 +3276,11 @@ function buildPatchChangesPanel(heroKey) {
const hasNotes = (hentry.hero_notes || []).length > 0;
const hasAbilities = (hentry.abilities || []).length > 0;
if (!hasNotes && !hasAbilities) continue;
let html = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(p.date || "")}</span></div>`;
const patchDate =
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
p.date ||
"";
let html = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(patchDate)}</span></div>`;
html += renderHeroNotes(hentry.hero_notes);
for (const ab of (hentry.abilities || [])) {
html += patchAbilityBlockHtml(ab, lookup);
@@ -3206,7 +3326,11 @@ function renderPatches() {
.map((p) => {
const major = p.website ? " ★" : "";
const sel = p.version === state.selectedPatch ? " selected" : "";
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major}${escapeHtml(p.date || "")}</option>`;
const patchDate =
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
p.date ||
"";
return `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major}${escapeHtml(patchDate)}</option>`;
})
.join("");
select.onchange = () => {
@@ -3390,22 +3514,6 @@ function ensureDefaultRankingRegion() {
}
}
function formatLeaderboardPosted(ts) {
if (ts == null || ts === "") return "";
const n = Number(ts);
if (!Number.isFinite(n) || n <= 0) return "";
// Valve posts unix seconds; guard ms accidentally.
const ms = n > 1e12 ? n : n * 1000;
try {
return new Date(ms).toLocaleString("zh-CN", {
hour12: false,
timeZone: "Asia/Shanghai",
});
} catch {
return "";
}
}
function renderRankings() {
if (!state.data) return;
const regionsEl = $("#rankings-regions");
@@ -3468,9 +3576,11 @@ function renderRankings() {
}
const cur = regions[state.rankingRegion] || {};
const posted = formatLeaderboardPosted(cur.time_posted);
const postedInfo = formatFriendlyTime(cur.time_posted);
if (sub) {
sub.textContent = posted ? `更新于 ${posted}` : "";
sub.textContent = postedInfo.text ? `更新于 ${postedInfo.text}` : "";
if (postedInfo.title) sub.title = postedInfo.title;
else sub.removeAttribute("title");
}
const rows = Array.isArray(cur.top100) ? cur.top100 : [];
@@ -3508,7 +3618,47 @@ function proMatchesData() {
);
}
function proPlayerOptions() {
/** OpenDota China region ids / cluster ids used to label 国服 pubs. */
const MATCH_CHINA_REGIONS = new Set([12, 13, 17, 18, 20, 25]);
const MATCH_CHINA_CLUSTERS = new Set([
221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417,
]);
const MATCH_ORIGIN_FILTERS = [
{ id: "all", label: "全部" },
{ id: "pro", label: "职业" },
{ id: "china", label: "国服" },
];
function isRankedPubMatch(row) {
return row?.origin === "public" || Number(row?.lobby_type) === 7;
}
function isChinaPubMatch(row) {
if (!isRankedPubMatch(row)) return false;
const cluster = Number(row.cluster);
const region = Number(row.region);
return (
(Number.isFinite(region) && MATCH_CHINA_REGIONS.has(region)) ||
(Number.isFinite(cluster) && MATCH_CHINA_CLUSTERS.has(cluster))
);
}
/** Classify a match row for badges / filters: pro | league | china | ladder. */
function matchOriginKind(row) {
if (isRankedPubMatch(row)) return isChinaPubMatch(row) ? "china" : "ladder";
if (row?.origin === "pro") return "pro";
return "league";
}
function matchPassesOriginFilter(row, origin) {
const want = origin || "all";
if (want === "all") return true;
if (want === "pro") return !isRankedPubMatch(row);
if (want === "china") return isChinaPubMatch(row);
return true;
}
function proPlayerOptions(originFilter) {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const prosMeta = pack.pros || {};
@@ -3516,19 +3666,22 @@ function proPlayerOptions() {
for (const [sid, cell] of Object.entries(byPro)) {
if (!cell || typeof cell !== "object") continue;
const matches = Array.isArray(cell.matches) ? cell.matches : [];
if (!matches.length) continue;
const filtered = matches.filter((row) =>
matchPassesOriginFilter(row, originFilter)
);
if (!filtered.length) continue;
const meta = prosMeta[sid] || {};
const name =
cell.name ||
meta.name ||
(matches[0] && matchPlayerDisplayName(matches[0])) ||
(filtered[0] && matchPlayerDisplayName(filtered[0])) ||
sid;
const team = cell.team_tag || meta.team_tag || "";
rows.push({
account_id: String(cell.account_id || sid),
name,
team_tag: team,
match_count: matches.length,
match_count: filtered.length,
});
}
rows.sort(
@@ -3539,7 +3692,7 @@ function proPlayerOptions() {
return rows;
}
function proMatchRows(playerId) {
function proMatchRows(playerId, originFilter) {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const out = [];
@@ -3551,6 +3704,7 @@ function proMatchRows(playerId) {
const matches = Array.isArray(cell.matches) ? cell.matches : [];
for (const row of matches) {
if (!row || typeof row !== "object") continue;
if (!matchPassesOriginFilter(row, originFilter)) continue;
const mid = Number(row.match_id);
if (!mid || seen.has(mid)) continue;
seen.add(mid);
@@ -3576,12 +3730,48 @@ function proMatchRows(playerId) {
function renderMatchesPage() {
const body = $("#matches-body");
const originsEl = $("#matches-origins");
const playersEl = $("#matches-players");
const sub = $("#matches-sub");
if (!body) return;
const options = proPlayerOptions();
const hasData = options.length > 0;
const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin)
? state.matchesOrigin
: "all";
if (origin !== state.matchesOrigin) state.matchesOrigin = origin;
const packHasAny = Object.values(proMatchesData().by_pro || {}).some(
(cell) => Array.isArray(cell?.matches) && cell.matches.length
);
const options = proPlayerOptions(origin);
const hasData = packHasAny;
if (originsEl) {
if (!hasData) {
originsEl.innerHTML = "";
} else {
originsEl.innerHTML = MATCH_ORIGIN_FILTERS.map((o) => {
const active = origin === o.id ? " active" : "";
return `<button type="button" class="rankings-region-btn${active}" data-origin="${escapeHtml(
o.id
)}" role="tab" aria-selected="${origin === o.id}">${escapeHtml(
o.label
)}</button>`;
}).join("");
originsEl.querySelectorAll("[data-origin]").forEach((btn) => {
btn.addEventListener("click", () => {
const next = btn.getAttribute("data-origin") || "all";
if (!MATCH_ORIGIN_FILTERS.some((o) => o.id === next)) return;
state.matchesOrigin = next;
state.matchesPage = 1;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
});
});
}
}
if (playersEl) {
if (!hasData) {
@@ -3610,6 +3800,7 @@ function renderMatchesPage() {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-player") || "";
state.matchesPlayerId = id || null;
state.matchesPage = 1;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
@@ -3633,18 +3824,100 @@ function renderMatchesPage() {
state.matchesPlayerId = null;
}
const rows = proMatchRows(state.matchesPlayerId).slice(
0,
PRO_MATCHES_PAGE_LIMIT
);
if (sub) sub.textContent = "";
const allRows = proMatchRows(state.matchesPlayerId, origin);
const total = allRows.length;
const pageSize = PRO_MATCHES_PAGE_SIZE;
const pageCount = Math.max(1, Math.ceil(total / pageSize) || 1);
let page = Number(state.matchesPage) || 1;
if (page < 1) page = 1;
if (page > pageCount) page = pageCount;
if (page !== state.matchesPage) {
state.matchesPage = page;
syncStateToUrl({ replace: true });
}
const start = (page - 1) * pageSize;
const rows = allRows.slice(start, start + pageSize);
if (sub) {
sub.textContent = total
? `${total} 场 · 第 ${page}/${pageCount}`
: "";
}
if (!rows.length) {
body.innerHTML = '<div class="rankings-empty">该选手暂无近期比赛</div>';
body.innerHTML = state.matchesPlayerId
? '<div class="rankings-empty">该选手暂无符合筛选的近期比赛</div>'
: '<div class="rankings-empty">暂无符合筛选的近期比赛</div>';
return;
}
body.replaceChildren(buildMatchList(null, rows, { showHero: true }));
const wrap = document.createElement("div");
wrap.className = "matches-page-wrap";
wrap.appendChild(buildMatchList(null, rows, { showHero: true }));
if (pageCount > 1) {
wrap.appendChild(
buildMatchesPager(page, pageCount, (next) => {
state.matchesPage = next;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
})
);
}
body.replaceChildren(wrap);
}
function buildMatchesPager(page, pageCount, onGo) {
const nav = document.createElement("nav");
nav.className = "matches-pager";
nav.setAttribute("aria-label", "比赛分页");
const mkBtn = (label, target, disabled, current) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "matches-pager-btn" + (current ? " active" : "");
btn.textContent = label;
btn.disabled = !!disabled;
if (current) btn.setAttribute("aria-current", "page");
if (!disabled && !current) {
btn.addEventListener("click", () => onGo(target));
}
return btn;
};
nav.appendChild(mkBtn("上一页", page - 1, page <= 1, false));
const windowSize = 5;
let from = Math.max(1, page - Math.floor(windowSize / 2));
let to = Math.min(pageCount, from + windowSize - 1);
from = Math.max(1, to - windowSize + 1);
if (from > 1) {
nav.appendChild(mkBtn("1", 1, false, page === 1));
if (from > 2) {
const ell = document.createElement("span");
ell.className = "matches-pager-ell";
ell.textContent = "…";
nav.appendChild(ell);
}
}
for (let i = from; i <= to; i++) {
nav.appendChild(mkBtn(String(i), i, false, i === page));
}
if (to < pageCount) {
if (to < pageCount - 1) {
const ell = document.createElement("span");
ell.className = "matches-pager-ell";
ell.textContent = "…";
nav.appendChild(ell);
}
nav.appendChild(
mkBtn(String(pageCount), pageCount, false, page === pageCount)
);
}
nav.appendChild(mkBtn("下一页", page + 1, page >= pageCount, false));
return nav;
}
function streamersData() {
@@ -4059,45 +4332,33 @@ function formatStreamerCount(n) {
return `${text}亿`;
}
function formatStreamerFetched(iso) {
if (!iso || typeof iso !== "string") return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Account id + social counts for the middle column (between avatar and follow). */
function buildStreamerMetaLine(row) {
const platform = String(row.platform || "").toLowerCase();
const uniqueId = row.unique_id || "";
const parts = [];
if (uniqueId) {
parts.push(
platform === "douyin"
? `抖音号:${uniqueId}`
: platform === "bilibili"
? `UID${uniqueId}`
: String(uniqueId),
);
parts.push({
label: platform === "douyin" ? "抖音号" : platform === "bilibili" ? "UID" : "账号",
value: String(uniqueId),
account: true,
});
}
if (platform === "bilibili") {
if (Number.isFinite(Number(row.following_count))) {
parts.push(`${formatStreamerCount(row.following_count)}关注`);
parts.push({ label: "关注", value: formatStreamerCount(row.following_count) });
}
} else if (Number.isFinite(Number(row.total_favorited))) {
parts.push(`${formatStreamerCount(row.total_favorited)}获赞`);
parts.push({ label: "获赞", value: formatStreamerCount(row.total_favorited) });
}
if (Number.isFinite(Number(row.follower_count))) {
parts.push(`${formatStreamerCount(row.follower_count)}粉丝`);
parts.push({ label: "粉丝", value: formatStreamerCount(row.follower_count) });
}
if (!parts.length) return null;
// Douyin user-search card: item spans + 1px sep with ~8px side margin (not raw "")
const meta = document.createElement("div");
meta.className = "streamer-meta";
parts.forEach((text, i) => {
parts.forEach((part, i) => {
if (i > 0) {
const sep = document.createElement("span");
sep.className = "streamer-meta-sep";
@@ -4105,8 +4366,20 @@ function buildStreamerMetaLine(row) {
meta.appendChild(sep);
}
const item = document.createElement("span");
item.className = "streamer-meta-item";
item.textContent = text;
item.className = part.account
? "streamer-meta-item is-account"
: "streamer-meta-item is-stat";
const label = document.createElement("span");
label.className = "streamer-meta-label";
label.textContent = part.label;
const value = document.createElement("span");
value.className = "streamer-meta-value";
value.textContent = part.value;
if (part.account) {
item.append(label, document.createTextNode(""), value);
} else {
item.append(value, label);
}
meta.appendChild(item);
});
return meta;
@@ -4234,10 +4507,16 @@ function buildStreamerCard(row) {
}
head.appendChild(avatarWrap);
// Middle column: nickname + account/stats only (signature is a full-width row below).
const info = document.createElement("div");
info.className = "streamer-card-info";
const nameEl = document.createElement("h3");
nameEl.className = "streamer-name";
nameEl.textContent = nick;
head.appendChild(nameEl);
info.appendChild(nameEl);
const metaEl = buildStreamerMetaLine(row);
if (metaEl) info.appendChild(metaEl);
head.appendChild(info);
const actions = document.createElement("div");
actions.className = "streamer-actions";
@@ -4253,10 +4532,7 @@ function buildStreamerCard(row) {
if (actions.childNodes.length) head.appendChild(actions);
card.appendChild(head);
// Meta + signature span full card width below the avatar row (Douyin-style)
const metaEl = buildStreamerMetaLine(row);
if (metaEl) card.appendChild(metaEl);
// Signature on its own row — long bios must not squeeze the avatar/follow row.
if (signature) {
const sig = document.createElement("p");
sig.className = "streamer-signature";
@@ -4402,14 +4678,16 @@ function renderStreamers() {
if (!body) return;
const data = streamersData();
const rows = streamerList();
const fetched = formatStreamerFetched(data.fetched_at);
const fetchedInfo = formatFriendlyTime(data.fetched_at);
body.innerHTML = "";
body.appendChild(
buildStreamerCards(rows, { emptyText: "暂无主播数据" })
);
if (foot) {
foot.textContent = fetched ? `更新于 ${fetched}` : "";
foot.setAttribute("aria-hidden", fetched ? "false" : "true");
foot.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : "";
if (fetchedInfo.title) foot.title = fetchedInfo.title;
else foot.removeAttribute("title");
foot.setAttribute("aria-hidden", fetchedInfo.text ? "false" : "true");
}
}
@@ -4449,9 +4727,9 @@ function syncStreamerCardLive(card, row) {
/**
* Visit-triggered live refresh: the Pages Function coalesces concurrent
* visitors through a 5-minute edge cache. Local dev has no /api/live-status
* (serve_relations.py returns an empty stub), so failures keep the data.json
* is_live fallback. Fires at most once per page load.
* visitors through a 5-minute edge cache. Local `serve_relations.py` returns
* an empty stub (no probing), so that path keeps the data.json is_live
* fallback. Fires at most once per page load.
*/
let liveStatusFetched = false;
@@ -4459,10 +4737,17 @@ function refreshStreamerLiveStatus() {
if (liveStatusFetched) return;
liveStatusFetched = true;
fetch("/api/live-status")
.then((res) => (res.ok ? res.json() : null))
.then(async (res) => {
if (!res.ok) return null;
const ctype = (res.headers.get("content-type") || "").toLowerCase();
if (!ctype.includes("application/json")) return null;
return res.json();
})
.then((payload) => {
const probed = payload && payload.streamers;
if (!probed || typeof probed !== "object") return;
// Empty map (local stub / total probe error) → keep data.json fallback.
if (!Object.keys(probed).length) return;
const rows =
(state.data && state.data.streamers && state.data.streamers.streamers) || [];
let changed = false;
@@ -4693,10 +4978,14 @@ function renderTrends() {
});
}
const fetched = pack.fetched_at
? `更新于 ${String(pack.fetched_at).slice(0, 10)}`
: "";
if (sub) sub.textContent = fetched;
const fetchedInfo = pack.fetched_at
? formatFriendlyTime(pack.fetched_at)
: { text: "", title: "" };
if (sub) {
sub.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : "";
if (fetchedInfo.title) sub.title = fetchedInfo.title;
else sub.removeAttribute("title");
}
const rows = buildTrendsBoardRows(state.trendsBracket);
if (!rows.length) {
@@ -5204,13 +5493,25 @@ function applyPatch(patch) {
state.rankingRegion = lb.default_region || order[0] || "china";
}
}
// Star-player filter (matches page).
// Star-player filter + origin + page (matches page).
if (state.page === "matches") {
if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) {
state.matchesPlayerId = String(patch.matchesPlayerId);
} else {
state.matchesPlayerId = null;
}
if (
patch.matchesOrigin === "pro" ||
patch.matchesOrigin === "china" ||
patch.matchesOrigin === "all"
) {
state.matchesOrigin = patch.matchesOrigin;
} else {
state.matchesOrigin = "all";
}
const mp = Number(patch.matchesPage);
state.matchesPage =
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
@@ -5281,6 +5582,8 @@ function bindPageChrome() {
}
async function main() {
// mobile-gate.js sets this before paint; skip desktop data boot on phones/tablets.
if (typeof window !== "undefined" && window.__CLIMPEROR_MOBILE__) return;
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (talentPopoverEl) {
+1 -1
View File
@@ -1,4 +1,4 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.72";
var SITE_VERSION = "0.5.84";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
+117 -31
View File
@@ -4,7 +4,8 @@
* Visit-triggered live-status probing with request coalescing via the edge
* Cache API (no KV, no wrangler config). The first visitor after the 5-minute
* freshness window triggers a re-probe of every streamer with `live_url`;
* concurrent visitors within the window share the cached JSON.
* concurrent visitors within the window share the cached JSON. Concurrent cold
* starts in the same isolate also share one in-flight probe promise.
*
* Probe logic is a JS port of fetch_streamer_live.py:
* - Bilibili: api.live.bilibili.com Room/get_info; data.live_status === 1 is
@@ -16,10 +17,10 @@
*
* Soft-fail everywhere: douyin blocks from datacenter IPs are expected. When a
* single probe fails, the streamer carries over the last known is_live from
* the previous (stale) cache entry with `stale: true`; when every probe fails
* the stale cache entry is served wholesale (X-Live-Cache: stale-override),
* or an empty payload when nothing was ever cached (X-Live-Cache: error).
* The handler never throws a 500 for probe failures.
* data.json / the previous (stale) cache entry with `stale: true`; when every
* probe fails the stale cache entry is served wholesale (X-Live-Cache:
* stale-override), or an empty payload when nothing was ever cached
* (X-Live-Cache: error). The handler never throws a 500 for probe failures.
*
* Named exports double as the local test surface; the Pages runtime only
* routes onRequest* handlers.
@@ -51,6 +52,9 @@ const DOUYIN_ROOMSTORE_RE = /\\"roomStore\\":\s*\{\\"roomInfo\\":\s*\{\\"room\\"
const DOUYIN_STATUS_RE = /\\"status\\":\s*(\d)/;
const DOUYIN_WEBRID_RE = /\\"web_rid\\":\s*\\"(\d+)\\"/;
/** Isolate-local coalescing for concurrent cold starts (same Worker isolate). */
let inFlightProbe = null;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -172,16 +176,45 @@ export function roomRefFromUrl(liveUrl) {
return seg || null;
}
/** Probe backend follows the live room host (may differ from profile platform). */
export function livePlatformFromUrl(liveUrl, fallback = "") {
try {
const host = new URL(String(liveUrl).trim()).hostname.toLowerCase();
if (host.includes("bilibili.com")) return "bilibili";
if (host.includes("douyin.com")) return "douyin";
} catch {
/* keep fallback */
}
return String(fallback || "")
.trim()
.toLowerCase();
}
/**
* Extract the streamer row list from a data.json payload.
* Production nests under `streamers.streamers`; also accept a bare array.
*/
export function streamerRowsFromPayload(payload) {
if (!payload || typeof payload !== "object") return [];
if (Array.isArray(payload.streamers)) return payload.streamers;
const nested = payload.streamers;
if (nested && typeof nested === "object" && Array.isArray(nested.streamers)) {
return nested.streamers;
}
return [];
}
/** Extract probe targets (id/platform/room ref) from a data.json payload. */
export function targetsFromPayload(payload) {
const rows = payload && Array.isArray(payload.streamers) ? payload.streamers : [];
const rows = streamerRowsFromPayload(payload);
const targets = [];
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
const liveUrl = String(row.live_url || "").trim();
if (!sid || !liveUrl) continue;
const platform = String(row.platform || "").trim().toLowerCase();
const fallback = String(row.platform || "").trim().toLowerCase();
const platform = livePlatformFromUrl(liveUrl, fallback);
const ref = roomRefFromUrl(liveUrl);
if (!ref) continue;
targets.push({ id: sid, platform, ref });
@@ -189,6 +222,18 @@ export function targetsFromPayload(payload) {
return targets;
}
/** Seed carry-over map from data.json is_live (daily snapshot). */
export function seedLiveFromPayload(payload) {
const out = {};
for (const row of streamerRowsFromPayload(payload)) {
if (!row || typeof row !== "object") continue;
const sid = String(row.id || "").trim();
if (!sid || typeof row.is_live !== "boolean") continue;
out[sid] = { is_live: row.is_live };
}
return out;
}
async function probeAll(targets) {
const jar = targets.some((t) => t.platform === "douyin")
? await warmDouyinCookies()
@@ -247,34 +292,49 @@ function emptyPayload() {
return { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers: {} };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
async function putCache(body) {
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
await caches.default.put(CACHE_KEY, cached);
}
/**
* Load targets + build a fresh probe payload (or return null when there is
* nothing to probe / total failure with no seed).
* Returns { body, cacheState } where cacheState is miss | stale-override | error.
*/
async function probeFresh(requestUrl, cachedData) {
let dataPayload = null;
let targets = [];
try {
const dataUrl = new URL("/data.json", request.url);
const dataUrl = new URL("/data.json", requestUrl);
const res = await fetchWithTimeout(dataUrl.toString(), {
headers: { Accept: "application/json" },
});
if (res.ok) targets = targetsFromPayload(await res.json());
if (res.ok) {
dataPayload = await res.json();
targets = targetsFromPayload(dataPayload);
}
} catch {
// data.json unreachable: fall through to stale/empty below
}
if (!targets.length) {
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
return { body: emptyPayload(), cacheState: "error" };
}
const staleStreamers =
const seeded = seedLiveFromPayload(dataPayload);
const cachedStreamers =
cachedData && cachedData.streamers && typeof cachedData.streamers === "object"
? cachedData.streamers
: {};
// Prefer previous edge cache over the daily snapshot when both exist.
const staleStreamers = { ...seeded, ...cachedStreamers };
const probed = await probeAll(targets);
const streamers = {};
@@ -296,21 +356,47 @@ async function handle(context) {
if (freshCount === 0) {
// Total probe failure (e.g. douyin blocking this colo): serve the stale
// snapshot if one exists, otherwise an explicitly empty payload.
if (cachedData) return jsonResponse(cachedData, "stale-override");
return jsonResponse(emptyPayload(), "error");
if (cachedData) return { body: cachedData, cacheState: "stale-override" };
if (Object.keys(seeded).length) {
const body = {
probed_at: new Date().toISOString(),
ttl: FRESH_TTL_S,
streamers: Object.fromEntries(
Object.entries(seeded).map(([id, cell]) => [
id,
{ is_live: cell.is_live, stale: true },
])
),
};
return { body, cacheState: "stale-override" };
}
return { body: emptyPayload(), cacheState: "error" };
}
const body = { probed_at: new Date().toISOString(), ttl: FRESH_TTL_S, streamers };
const res = jsonResponse(body, "miss");
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
// Cache write must not block the response.
context.waitUntil(caches.default.put(CACHE_KEY, cached));
return res;
return { body, cacheState: "miss" };
}
async function handle(context) {
const { request } = context;
const cachedData = await readCachedPayload();
if (cachedData && isFresh(cachedData)) {
return jsonResponse(cachedData, "hit");
}
// Coalesce concurrent cold starts in this isolate onto one probe run.
if (!inFlightProbe) {
inFlightProbe = probeFresh(request.url, cachedData).finally(() => {
inFlightProbe = null;
});
}
const { body, cacheState } = await inFlightProbe;
if (cacheState === "miss") {
context.waitUntil(putCache(body));
}
return jsonResponse(body, cacheState);
}
export async function onRequestGet(context) {
@@ -0,0 +1,70 @@
/**
* Pages Function: GET|POST /api/mobile-demand
*
* Best-effort edge counter for "please add mobile support" demand.
* Stored via the Cache API (no KV / wrangler binding), same pattern as
* live-status. Counts may undercount under concurrent colo races and can
* reset if the edge entry is evicted; good enough as a demand signal.
*
* GET -> { count }
* POST -> increment once, return { count, voted: true }
*/
const CACHE_KEY = "https://mobile-demand.internal/v1";
// Long store TTL so eviction is rare; freshness is not time-gated.
const CACHE_STORE_MAX_AGE_S = 365 * 24 * 60 * 60;
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
async function readCount() {
try {
const cached = await caches.default.match(CACHE_KEY);
if (!cached) return 0;
const data = await cached.json();
const n = Number(data && data.count);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
} catch {
return 0;
}
}
async function writeCount(count) {
const body = {
count,
updated_at: new Date().toISOString(),
};
const cached = new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `max-age=${CACHE_STORE_MAX_AGE_S}`,
},
});
await caches.default.put(CACHE_KEY, cached);
}
export async function onRequestGet() {
try {
const count = await readCount();
return jsonResponse({ count });
} catch {
return jsonResponse({ count: 0 });
}
}
export async function onRequestPost() {
try {
const next = (await readCount()) + 1;
await writeCount(next);
return jsonResponse({ count: next, voted: true });
} catch {
return jsonResponse({ error: "increment_failed" }, 503);
}
}
+22 -5
View File
@@ -5,9 +5,25 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.72" />
<link rel="stylesheet" href="/style.css?v=0.5.84" />
<script src="/mobile-gate.js?v=0.5.84"></script>
</head>
<body>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
<div class="mobile-gate-card">
<div class="mobile-gate-brand" aria-hidden="true">
<img class="mobile-gate-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="" />
<span class="mobile-gate-brand-title">上分帝</span>
</div>
<h1 id="mobile-gate-title" class="mobile-gate-title">暂不支持移动端</h1>
<p class="mobile-gate-desc">请从 PC 端浏览器访问本站。</p>
<button type="button" id="mobile-demand-btn" class="mobile-demand-btn">催更移动端</button>
<p id="mobile-demand-count" class="mobile-demand-count" hidden>
已有 <span id="mobile-demand-count-n">0</span> 人希望开通
</p>
</div>
</div>
<header class="topbar">
<div class="brand" aria-label="Dota 2 上分帝">
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
@@ -72,7 +88,8 @@
<div class="rankings-cluster">
<div class="rankings-center-wrap matches-center-wrap">
<div class="rankings-body matches-body" id="matches-body"></div>
<aside class="rankings-aside" aria-label="选手筛选">
<aside class="rankings-aside" aria-label="比赛筛选">
<div class="matches-origins" id="matches-origins" role="tablist" aria-label="职业或国服"></div>
<div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div>
<p class="rankings-sub" id="matches-sub"></p>
</aside>
@@ -149,8 +166,8 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.72"></script>
<script src="/router.js?v=0.5.72"></script>
<script src="/app.js?v=0.5.72"></script>
<script src="/config.js?v=0.5.84"></script>
<script src="/router.js?v=0.5.84"></script>
<script src="/app.js?v=0.5.84"></script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
/**
* Early mobile client gate (loaded from <head>).
* Sets html.mobile-client before paint; wires the demand button after DOM ready.
* Exposes window.__CLIMPEROR_MOBILE__ for app.js to skip the desktop boot path.
*/
(function () {
function isMobileClient() {
var ua = navigator.userAgent || "";
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua)) {
return true;
}
// iPadOS 13+ reports as MacIntel with touch.
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) {
return true;
}
return false;
}
var mobile = isMobileClient();
window.__CLIMPEROR_MOBILE__ = mobile;
if (!mobile) return;
document.documentElement.classList.add("mobile-client");
var STORAGE_KEY = "climperor_mobile_demand_voted";
function $(id) {
return document.getElementById(id);
}
function markVoted(btn) {
try {
localStorage.setItem(STORAGE_KEY, "1");
} catch (_) {
/* ignore quota / private mode */
}
if (btn) {
btn.disabled = true;
btn.textContent = "已记录催更";
}
}
function alreadyVoted() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (_) {
return false;
}
}
function setCount(n) {
var el = $("mobile-demand-count-n");
if (el) el.textContent = String(n);
var wrap = $("mobile-demand-count");
if (wrap) wrap.hidden = false;
}
async function refreshCount() {
try {
var res = await fetch("/api/mobile-demand");
if (!res.ok) return;
var data = await res.json();
var n = Number(data && data.count);
if (Number.isFinite(n) && n >= 0) setCount(Math.floor(n));
} catch (_) {
/* soft-fail: keep count hidden */
}
}
document.addEventListener("DOMContentLoaded", function () {
var logo = document.querySelector(".mobile-gate-logo");
if (logo && typeof STATIC_ASSET_BASE === "string") {
var base = STATIC_ASSET_BASE.trim().replace(/\/+$/, "");
if (base) logo.src = base + "/ui-icon/dota2_logo_wordmark.png";
}
var btn = $("mobile-demand-btn");
var voted = alreadyVoted();
if (voted) markVoted(btn);
refreshCount();
if (!btn || voted) return;
btn.addEventListener("click", async function () {
btn.disabled = true;
try {
var res = await fetch("/api/mobile-demand", { method: "POST" });
if (!res.ok) {
btn.disabled = false;
return;
}
var data = await res.json();
var n = Number(data && data.count);
if (Number.isFinite(n) && n >= 0) setCount(Math.floor(n));
markVoted(btn);
} catch (_) {
btn.disabled = false;
}
});
});
})();
+27 -1
View File
@@ -10,7 +10,8 @@
* - rankings: Immortal leaderboard region
* #/rankings[/region] (legacy #/rankings/meta[/bracket] players / china)
* - matches: star-player recent matches (pro_matches)
* #/matches[/account_id]
* #/matches[/account_id][?origin=pro|china][&page=N]
* (default origin all omitted; page=1 omitted)
* - streamers: curated Douyin streamer directory
* #/streamers
* - trends: medal bracket for the 8-week win/pick board
@@ -50,6 +51,8 @@ const DEFAULT_TRENDS_SORT = "wr_end";
const VALID_TRENDS_SORTS = ["wr_end", "pr_end"];
const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" };
const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" };
const DEFAULT_MATCHES_ORIGIN = "all";
const VALID_MATCHES_ORIGINS = ["all", "pro", "china"];
const DEFAULT_MECHANIC_EFFECT = "basic_dispel";
const VALID_MECHANIC_EFFECTS = [
"basic_dispel",
@@ -95,6 +98,8 @@ function parseHash(hash) {
patchVersion: null,
rankingRegion: null,
matchesPlayerId: null,
matchesOrigin: null,
matchesPage: null,
trendsBracket: null,
trendsSort: null,
mechanicEffect: null,
@@ -152,6 +157,17 @@ function parseHash(hash) {
out.trendsSort = TRENDS_SORT_FROM_URL[sortParam];
}
}
if (page === "matches") {
const originParam = params.get("origin");
if (originParam && VALID_MATCHES_ORIGINS.includes(originParam)) {
out.matchesOrigin = originParam;
}
const pageParam = params.get("page");
if (pageParam && /^\d+$/.test(pageParam)) {
const n = parseInt(pageParam, 10);
if (n >= 1) out.matchesPage = n;
}
}
return out;
}
@@ -229,6 +245,16 @@ function serializeHash(state) {
if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString();
if (s) hash += "?" + s;
} else if (state.page === "matches") {
const qs = new URLSearchParams();
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
if (VALID_MATCHES_ORIGINS.includes(origin) && origin !== DEFAULT_MATCHES_ORIGIN) {
qs.set("origin", origin);
}
const pageNum = Number(state.matchesPage) || 1;
if (pageNum > 1) qs.set("page", String(pageNum));
const s = qs.toString();
if (s) hash += "?" + s;
}
return hash;
}
+241 -31
View File
@@ -1873,13 +1873,18 @@ a.match-player-name:hover {
color: rgba(180, 195, 215, 0.8);
}
.match-items,
.match-skills,
.match-backpack {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.match-skills {
display: flex;
flex-wrap: nowrap;
gap: 5px;
align-items: center;
}
.match-inv {
display: flex;
flex-direction: column;
@@ -2603,22 +2608,90 @@ body:has(#items-view:not(.hidden)) {
}
.matches-board .matches-body {
min-height: 0;
width: 100%;
min-width: 0;
}
.matches-center-wrap {
.matches-board .rankings-center-wrap.matches-center-wrap {
align-items: stretch;
/* Fixed column width so cards align; ~lv30 skill row + a little slack. */
width: min(1680px, 100%, calc(100vw - 280px));
}
.matches-board .match-list,
.matches-board .match-card {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.matches-board .match-skills {
max-width: 100%;
}
@media (max-width: 1460px) {
.match-skills {
flex-wrap: wrap;
}
}
.matches-board .rankings-aside {
align-items: stretch;
}
.matches-origins,
.matches-players {
display: flex;
flex-direction: column;
gap: 6px;
max-height: min(70vh, 560px);
overflow: auto;
scrollbar-width: thin;
padding-right: 2px;
width: 100%;
}
.matches-board .rankings-aside .rankings-region-btn {
width: 100%;
box-sizing: border-box;
}
.matches-board .match-list {
gap: 12px;
}
.matches-page-wrap {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.matches-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px;
padding: 4px 0 8px;
}
.matches-pager-btn {
min-width: 36px;
height: 34px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(140, 170, 210, 0.28);
background: rgba(0, 0, 0, 0.28);
color: rgba(220, 230, 245, 0.9);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.matches-pager-btn:hover:not(:disabled):not(.active) {
border-color: rgba(180, 200, 230, 0.55);
color: #fff;
}
.matches-pager-btn.active {
border-color: rgba(232, 200, 120, 0.75);
background: rgba(180, 130, 40, 0.28);
color: rgba(255, 220, 160, 0.98);
cursor: default;
}
.matches-pager-btn:disabled {
opacity: 0.4;
cursor: default;
}
.matches-pager-ell {
color: rgba(160, 175, 195, 0.7);
padding: 0 2px;
user-select: none;
}
.rankings-empty {
padding: 48px 0;
text-align: center;
@@ -2696,6 +2769,9 @@ body:has(#items-view:not(.hidden)) {
gap: 18px;
width: min(640px, 94%);
}
.matches-board .rankings-center-wrap.matches-center-wrap {
width: min(1680px, 94%);
}
.rankings-aside {
position: static;
width: 100%;
@@ -3232,7 +3308,7 @@ body:has(#items-view:not(.hidden)) {
background: rgba(16, 24, 36, 0.92);
box-sizing: border-box;
}
/* Row 1 only: avatar | nickname | actions — meta/signature live below as siblings */
/* Row 1: avatar | info (name/meta/signature) | follow */
.streamer-card-head {
display: flex;
align-items: center;
@@ -3285,11 +3361,9 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
font-weight: 700;
color: var(--muted);
}
/* Douyin search live: outer ring expands+fades; avatar shrinks (1s ease). */
.streamer-avatar-wrap.is-live {
padding-bottom: 10px;
margin-bottom: -10px;
}
/* Douyin live avatar: ring pulse + badge overlaps ring (no gap).
Measured on live.douyin.com: badge bottom:-6px on 54px wrap, ~10px overlap,
gradient pill, no border/shadow. */
.streamer-avatar-wrap.is-live .streamer-avatar {
border-color: rgb(69, 71, 79);
}
@@ -3303,7 +3377,7 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
width: 99px;
height: 99px;
border-radius: 50%;
border: 1px solid rgb(254, 26, 104);
border: 1px solid rgb(254, 44, 85);
box-sizing: border-box;
pointer-events: none;
z-index: 0;
@@ -3335,23 +3409,37 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
.streamer-live-badge {
position: absolute;
left: 50%;
bottom: 0;
/* Douyin: bottom:-6px → badge overlaps ring/avatar bottom, no air gap. */
bottom: -6px;
transform: translateX(-50%);
z-index: 2;
padding: 1px 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 38px;
height: 16px;
padding: 0 6px;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
font-weight: 600;
line-height: 1;
letter-spacing: 0.02em;
color: #fff;
background: rgb(254, 26, 104);
background: linear-gradient(131.17deg, rgb(255, 23, 100) 0%, rgb(237, 52, 149) 94.15%);
border: 0;
border-radius: 4px;
box-shadow: none;
white-space: nowrap;
pointer-events: none;
}
.streamer-name {
.streamer-card-info {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
}
.streamer-name {
margin: 0;
font-size: 20px;
font-weight: 700;
@@ -3362,38 +3450,52 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
white-space: nowrap;
}
.streamer-meta {
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
margin: 0;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.6px;
line-height: 18px;
letter-spacing: 0.2px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Douyin search card: 1×10px divider, 8px side margin */
.streamer-meta-sep {
display: inline-block;
flex: 0 0 auto;
width: 1px;
height: 10px;
margin: 0 8px;
vertical-align: baseline;
background: rgba(255, 255, 255, 0.16);
height: 11px;
margin: 0 10px;
background: rgba(255, 255, 255, 0.13);
}
.streamer-meta-item {
display: inline;
flex: 0 0 auto;
}
.streamer-meta-value {
color: #dce5f2;
font-weight: 600;
}
.streamer-meta-item.is-account .streamer-meta-value {
color: #aab8cd;
font-weight: 500;
}
.streamer-meta-item.is-stat .streamer-meta-label {
margin-left: 3px;
}
.streamer-signature {
margin: 0;
font-size: 13px;
line-height: 20px;
letter-spacing: 0.4px;
color: var(--muted);
letter-spacing: 0.3px;
color: #8e9bb0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.streamer-actions {
flex: 0 0 auto;
@@ -3586,9 +3688,21 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
.streamer-avatar-fallback {
font-size: 24px;
}
.streamer-live-badge {
bottom: -5px;
min-width: 34px;
height: 15px;
font-size: 9px;
}
.streamer-name {
font-size: 18px;
}
.streamer-meta {
font-size: 12px;
}
.streamer-meta-sep {
margin: 0 6px;
}
.streamer-actions {
min-width: 76px;
gap: 8px;
@@ -3636,3 +3750,99 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
}
}
/* —— Mobile gate (phones / tablets; set by mobile-gate.js) —— */
#mobile-gate {
display: none;
}
html.mobile-client,
html.mobile-client body {
height: 100%;
max-height: none;
overflow: auto;
}
html.mobile-client body > :not(#mobile-gate) {
display: none !important;
}
html.mobile-client #mobile-gate {
display: flex;
flex: 1 1 auto;
min-height: 100dvh;
align-items: center;
justify-content: center;
padding: 32px 20px;
}
.mobile-gate-card {
width: min(360px, 100%);
text-align: center;
}
.mobile-gate-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 28px;
}
.mobile-gate-logo {
height: 28px;
width: auto;
display: block;
}
.mobile-gate-brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--text);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.mobile-gate-title {
margin: 0 0 10px;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.mobile-gate-desc {
margin: 0 0 28px;
font-size: 15px;
line-height: 1.55;
color: var(--muted);
}
.mobile-demand-btn {
appearance: none;
border: 1px solid rgba(94, 200, 255, 0.45);
background: rgba(94, 200, 255, 0.12);
color: var(--sel);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
padding: 12px 22px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.mobile-demand-btn:hover:not(:disabled) {
background: rgba(94, 200, 255, 0.2);
border-color: rgba(94, 200, 255, 0.7);
}
.mobile-demand-btn:focus-visible {
outline: 2px solid var(--sel);
outline-offset: 2px;
}
.mobile-demand-btn:disabled {
cursor: default;
opacity: 0.75;
border-color: rgba(140, 170, 210, 0.28);
background: rgba(140, 170, 210, 0.1);
color: var(--muted);
}
.mobile-demand-count {
margin: 16px 0 0;
font-size: 13px;
color: var(--muted);
}
.mobile-demand-count span {
color: var(--text);
font-weight: 600;
}
+2 -6
View File
@@ -18,7 +18,7 @@ from datetime import datetime, timezone
from shared.grid import hero_table
from shared.hero_tags import ILLUSION_KEYS
from shared.http_utils import http_json
from shared.http_utils import http_json, write_json_atomic
from shared.paths import DATA
ITEMS_META = DATA / "items_meta.json"
@@ -439,11 +439,7 @@ def main() -> None:
"items": items_out,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
write_json_atomic(args.out, payload)
nonempty = sum(1 for v in by_hero.values() if v)
jugg = [x["item"] for x in by_hero.get("juggernaut") or []]
print(f"done: {nonempty}/{len(by_hero)} heroes have feared items -> {args.out}", flush=True)
+317 -32
View File
@@ -20,12 +20,14 @@ Usage:
from __future__ import annotations
import argparse
import io
import json
import os
import re
import sys
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
@@ -155,6 +157,33 @@ def http_json_soft(
return e.code, raw
def http_response_soft(
url: str,
*,
headers: dict | None = None,
) -> tuple[int, bytes, str, dict[str, str]]:
request_headers = {"User-Agent": "climperor-monitor", "Accept": "*/*"}
request_headers.update(headers or {})
req = urllib.request.Request(url, headers=request_headers)
try:
with urllib.request.urlopen(req, timeout=60) as response:
return (
response.status,
response.read(),
response.headers.get_content_type(),
{k.lower(): v for k, v in response.headers.items()},
)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
if isinstance(exc, urllib.error.HTTPError):
return (
exc.code,
exc.read(),
exc.headers.get_content_type(),
{k.lower(): v for k, v in exc.headers.items()},
)
return 0, str(exc).encode("utf-8", errors="replace"), "", {}
def gql(query: str, variables: dict) -> dict:
data = http_json(
"https://api.cloudflare.com/client/v4/graphql",
@@ -234,6 +263,18 @@ class DigestExtras:
workflows: list[WorkflowDayStatus] = field(default_factory=list)
deploys: list[PagesDeploy] = field(default_factory=list)
gitea_note: str = ""
refresh_summaries: dict[str, dict] = field(default_factory=dict)
production_health: "ProductionHealth | None" = None
@dataclass
class ProductionHealth:
site_version: str = "?"
ages_hours: dict[str, float | None] = field(default_factory=dict)
stale: list[str] = field(default_factory=list)
live_api_ok: bool = False
refresh_run_id: str = ""
note: str = ""
def zone_id() -> str:
@@ -437,6 +478,213 @@ def fetch_workflow_statuses(day: date) -> tuple[list[WorkflowDayStatus], str]:
return out, ""
def fetch_refresh_summaries(workflows: list[WorkflowDayStatus]) -> tuple[dict[str, dict], str]:
"""Download summary.json artifacts for the latest visible refresh runs."""
token = gitea_token()
if not token:
return {}, "无 Gitea token,未读取刷新摘要"
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
code, payload = http_json_soft(
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/artifacts?limit=100",
headers=headers,
)
if code != 200 or not isinstance(payload, dict):
return {}, f"刷新摘要 API HTTP {code}"
artifacts = payload.get("artifacts") or []
by_run: dict[int, dict] = {}
for artifact in artifacts:
if not isinstance(artifact, dict) or artifact.get("expired"):
continue
run = artifact.get("workflow_run") or {}
try:
run_id = int(run.get("id") or artifact.get("workflow_run_id") or 0)
except (TypeError, ValueError):
continue
if run_id:
by_run[run_id] = artifact
summaries: dict[str, dict] = {}
for workflow in workflows:
latest = None
for run in workflow.runs:
try:
run_id = int(run.get("id") or 0)
except (TypeError, ValueError):
continue
if run.get("status") == "completed" and run_id in by_run:
latest = run
break
if not latest:
continue
artifact = by_run[int(latest["id"])]
artifact_id = artifact.get("id")
if not artifact_id:
continue
status, raw, _, _ = http_response_soft(
f"{gitea_base()}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/actions/artifacts/"
f"{artifact_id}/zip",
headers=headers,
)
if status != 200:
continue
try:
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
member = next(
name for name in archive.namelist() if name.endswith("summary.json")
)
summary = json.loads(archive.read(member).decode("utf-8"))
if isinstance(summary, dict):
summaries[workflow.file] = summary
except (ValueError, KeyError, StopIteration, zipfile.BadZipFile, json.JSONDecodeError):
continue
return summaries, ""
def _nested_timestamp(payload: dict, *path: str) -> datetime | None:
value: Any = payload
for key in path:
if not isinstance(value, dict):
return None
value = value.get(key)
return parse_gitea_time(value)
def _summary_proves_refresh(summary: dict, label: str, step: str) -> bool:
if not summary.get("ok"):
return False
step_ok = any(
isinstance(row, dict)
and row.get("step") == step
and row.get("ok")
for row in (summary.get("steps") or [])
)
if not step_ok:
return False
health = summary.get("health") or {}
if label == "主播资料":
return not (
int(health.get("streamer_profile_missing") or 0)
or int(health.get("streamer_live_probe_missing") or 0)
)
if label == "STRATZ Meta":
return not health.get("stratz_meta_stale")
if label == "STRATZ 对位":
return int(health.get("stratz_matchup_stale") or 0) == 0
return True
def fetch_production_health(
now: datetime | None = None,
refresh_summaries: dict[str, dict] | None = None,
) -> ProductionHealth:
"""Probe production payload freshness and the live-status Function."""
current = now or datetime.now(timezone.utc)
health = ProductionHealth()
status, config_raw, _, _ = http_response_soft(f"{SITE_URL}/config.js")
if status == 200:
match = re.search(
rb'SITE_VERSION\s*=\s*"([^"]+)"',
config_raw,
)
if match:
health.site_version = match.group(1).decode("utf-8", errors="replace")
status, data_raw, _, _ = http_response_soft(f"{SITE_URL}/data.json")
if status != 200:
health.note = f"生产 data.json HTTP {status}"
health.stale.append("data.json")
return health
try:
payload = json.loads(data_raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
health.note = "生产 data.json 无法解析"
health.stale.append("data.json")
return health
health.refresh_run_id = str((payload.get("meta") or {}).get("refresh_run_id") or "")
summaries = refresh_summaries or {}
checks = {
"英雄统计": (
("hero_stats", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_hero_stats.py",
),
"排行榜": (
("leaderboards", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_leaderboards.py",
),
"近期比赛": (
("hero_matches", "meta", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_hero_matches.py",
),
"明星比赛": (
("pro_matches", "meta", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_pro_matches.py",
),
"主播资料": (
("streamers", "fetched_at"),
36.0,
"web-daily.yml",
"fetch_streamers.py",
),
"STRATZ Meta": (
("stratz_hero_meta", "fetched_at"),
216.0,
"web-weekly.yml",
"fetch_stratz_meta.py",
),
"STRATZ 对位": (
("stratz_matchup_tops", "fetched_at"),
216.0,
"web-weekly.yml",
"fetch_stratz_meta.py",
),
"英雄装备": (
("hero_items", "meta", "fetched_at"),
216.0,
"web-weekly.yml",
"fetch_hero_items.py",
),
}
for label, (path, max_age, workflow_file, step) in checks.items():
timestamp = _nested_timestamp(payload, *path)
age = None
if timestamp is not None:
age = max(0.0, (current - timestamp.astimezone(timezone.utc)).total_seconds() / 3600)
health.ages_hours[label] = age
latest_refresh_ok = _summary_proves_refresh(
summaries.get(workflow_file) or {},
label,
step,
)
if (age is None or age > max_age) and not latest_refresh_ok:
health.stale.append(label)
live_status, live_raw, live_type, live_headers = http_response_soft(
f"{SITE_URL}/api/live-status"
)
try:
live_payload = json.loads(live_raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
live_payload = None
health.live_api_ok = (
live_status == 200
and live_type == "application/json"
and isinstance(live_payload, dict)
and live_headers.get("x-live-cache") not in ("error", "stale-override")
)
if not health.live_api_ok:
health.stale.append("直播状态 API")
return health
def cf_account_id() -> str:
d = http_json(
"https://api.cloudflare.com/client/v4/accounts",
@@ -495,7 +743,7 @@ def fmt_bytes(n: int) -> str:
def _fmt_workflow_line(w: WorkflowDayStatus) -> str:
if w.note and not w.runs:
return f"- **{w.label}**`{w.file}`{w.note}"
return f"- **{w.label}**{w.note}"
parts: list[str] = []
if w.ok_count:
parts.append(f"成功 {w.ok_count}")
@@ -528,49 +776,91 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
estimate = st.human_home_vis
rum = st.rum_human
# Human estimate = known-browser UA hits on `/` (SPA hash routes are not paths).
traffic_md = (
f"**真实访问(估)**:约 **{estimate}** 次首页打开\n"
f"(已知浏览器 UA 打开 `/`;SPA Hash 路由不计路径;不含爬虫)\n\n"
f"**RUM 页面浏览**{rum}"
+ (f" + {st.rum_other} 其他" if st.rum_other else "")
+ "\n"
f"**边缘总量**{st.all_req} 请求 / {st.all_vis} visits / {fmt_bytes(st.bytes)}\n"
f"**非人类首页命中**{st.other_home_vis} visits"
f"**非人类首页**{st.other_home_vis} visits\n"
f"**地区 Top**{countries}\n"
f"**状态码**{status_line}"
)
wf_lines = [_fmt_workflow_line(w) for w in extras.workflows]
if extras.gitea_note:
wf_lines.append(f"- _{extras.gitea_note}_")
for workflow_file, summary in extras.refresh_summaries.items():
label = next(
(label for file, label in REFRESH_WORKFLOWS if file == workflow_file),
workflow_file,
)
result = "已部署" if summary.get("deployed") else "无需部署"
if not summary.get("ok"):
result = "刷新失败"
stale_count = int(
(summary.get("health") or {}).get("stratz_matchup_stale") or 0
)
live_missing = int(
(summary.get("health") or {}).get("streamer_live_probe_missing") or 0
)
wf_lines.append(
f"- **{label}摘要**{result}"
+ (f" · 对位陈旧 {stale_count}" if stale_count else "")
+ (f" · 直播探测缺失 {live_missing}" if live_missing else "")
)
ok_deploys = [p for p in extras.deploys if p.status == "success"]
fail_deploys = [p for p in extras.deploys if p.status != "success"]
if extras.deploys:
deploy_md = (
f"**Pages 生产部署**:成功 **{len(ok_deploys)}**"
deploy_lines = [
f"**Pages**:成功 **{len(ok_deploys)}**"
+ (f" / 异常 {len(fail_deploys)}" if fail_deploys else "")
+ "\n"
)
]
for p in extras.deploys[:5]:
mark = "[ok]" if p.status == "success" else "[!]"
deploy_md += (
f"- {mark} {p.created_on.strftime('%H:%M')} "
f"`{p.commit or '?'}` {p.status}\n"
mark = "ok" if p.status == "success" else "!"
deploy_lines.append(
f"- [{mark}] {p.created_on.strftime('%H:%M')} "
f"`{p.commit or '?'}` {p.status}"
)
deploy_md = "\n".join(deploy_lines)
else:
deploy_md = "**Pages 生产部署**:当日无新部署(数据未变则 refresh 会跳过 deploy\n"
deploy_md = "**Pages**:当日无新部署"
health = extras.production_health
production_lines: list[str] = []
if health:
newest = [
f"{label} {age:.1f}h"
for label, age in health.ages_hours.items()
if age is not None
]
production_lines.append(
f"**生产探针**{'正常' if not health.stale else '异常'}"
f" · live API {'正常' if health.live_api_ok else '失败'}"
)
if newest:
production_lines.append("- " + " / ".join(newest))
if health.stale:
production_lines.append("- **超时/异常**" + "".join(health.stale))
if health.refresh_run_id:
production_lines.append(f"- run `{health.refresh_run_id}`")
refresh_md = (
f"**站点版本**`v{extras.site_version}`\n\n"
f"**自动刷新(Gitea Actions**\n"
f"**版本**`v{extras.site_version}`\n"
f"**Actions**\n"
+ "\n".join(wf_lines)
+ "\n\n"
+ deploy_md.strip()
+ "\n"
+ deploy_md
+ ("\n" + "\n".join(production_lines) if production_lines else "")
)
any_refresh_ok = any(w.ok_count > 0 for w in extras.workflows) or bool(ok_deploys)
any_refresh_fail = any(w.fail_count > 0 for w in extras.workflows) or bool(
fail_deploys
)
any_refresh_fail = any_refresh_fail or bool(health and health.stale)
if any_refresh_fail:
header_color = "red"
@@ -586,18 +876,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
},
{
"tag": "div",
"text": {"tag": "lark_md", "content": "**自动更新**\n" + refresh_md},
},
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": (
f"**首页地区 Top**{countries}\n"
f"**状态码**{status_line}\n"
f"_模块:英雄 / 排行 / 主播 / 走势 / 物品 / 版本 / 机制_"
),
},
"text": {"tag": "lark_md", "content": "**刷新与部署**\n" + refresh_md},
},
{
"tag": "note",
@@ -605,8 +884,7 @@ def build_card(st: DayStats, extras: DigestExtras) -> dict:
{
"tag": "plain_text",
"content": (
f"{HOST} · Free 档无 BotScore · "
f"refresh=web-daily/weekly/patch · {now}"
f"{HOST} · 估数=已知浏览器 UA 打开 / · {now}"
),
}
],
@@ -674,15 +952,22 @@ def main(argv: list[str] | None = None) -> int:
args = ap.parse_args(argv)
day = parse_day(args.day)
refresh_day = datetime.now(CST).date()
zid = zone_id()
st = fetch_day(zid, day)
workflows, gitea_note = fetch_workflow_statuses(day)
workflows, gitea_note = fetch_workflow_statuses(refresh_day)
refresh_summaries, summary_note = fetch_refresh_summaries(workflows)
if summary_note:
gitea_note = "".join(x for x in (gitea_note, summary_note) if x)
deploys = fetch_pages_deploys(day)
production_health = fetch_production_health(refresh_summaries=refresh_summaries)
extras = DigestExtras(
site_version=read_site_version(),
site_version=production_health.site_version,
workflows=workflows,
deploys=deploys,
gitea_note=gitea_note,
refresh_summaries=refresh_summaries,
production_health=production_health,
)
payload = build_card(st, extras)
+197
View File
@@ -0,0 +1,197 @@
"""Restore/save durable Web refresh state around ephemeral Actions checkouts."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from shared.http_utils import write_json_atomic
from shared.paths import DATA
ROOT = Path(__file__).resolve().parent
CACHE_DIR = ROOT / ".refresh-cache"
STATE_DIR = Path(
os.environ.get("CLIMPEROR_REFRESH_STATE")
or (Path.home() / ".cache" / "climperor-web-refresh")
)
CACHE_FILES = (
"patches.json",
"hero_stats.json",
"hero_matches.json",
"pro_matches.json",
"stratz_hero_meta.json",
"stratz_matchup_tops.json",
"item_counter_stats.json",
"leaderboards.json",
"hero_items.json",
"items_meta.json",
"hero_item_fears.json",
"hero_abilities.json",
"item_shop.json",
)
STREAMER_RUNTIME_KEYS = (
"nickname",
"unique_id",
"signature",
"following_count",
"follower_count",
"total_favorited",
"avatar",
"profile_fetched_at",
"is_live",
"live_probed_at",
)
def _copy(src: Path, dst: Path) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_name(f".{dst.name}.restore.tmp")
shutil.copy2(src, tmp)
tmp.replace(dst)
def _payload_time(path: Path) -> datetime | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
raw = payload.get("fetched_at") or (payload.get("meta") or {}).get("fetched_at")
if not raw:
return None
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
def _cache_is_at_least_as_new(cached: Path, checkout: Path) -> bool:
if not checkout.is_file():
return True
cached_at = _payload_time(cached)
checkout_at = _payload_time(checkout)
if cached_at is not None and checkout_at is not None:
return cached_at >= checkout_at
# Ignored runtime files have no checkout copy; for tracked generated files,
# prefer Git when freshness cannot be established.
return cached_at is not None
def restore_streamers(cached_path: Path, checkout_path: Path) -> None:
"""Merge runtime fields without replacing newly committed manual rows."""
if not cached_path.is_file() or not checkout_path.is_file():
return
if not _cache_is_at_least_as_new(cached_path, checkout_path):
return
try:
cached = json.loads(cached_path.read_text(encoding="utf-8"))
checkout = json.loads(checkout_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return
cached_by_id = {
str(row.get("id")): row
for row in (cached.get("streamers") or [])
if isinstance(row, dict) and row.get("id")
}
for row in checkout.get("streamers") or []:
if not isinstance(row, dict):
continue
old = cached_by_id.get(str(row.get("id")))
if not old:
continue
for key in STREAMER_RUNTIME_KEYS:
if key in old:
row[key] = old[key]
if cached.get("fetched_at"):
checkout["fetched_at"] = cached["fetched_at"]
write_json_atomic(checkout_path, checkout)
def restore_patches(cached_path: Path, checkout_path: Path) -> None:
"""Merge fetched patch details without reverting newer committed entries."""
if not cached_path.is_file():
return
try:
cached = json.loads(cached_path.read_text(encoding="utf-8"))
checkout = (
json.loads(checkout_path.read_text(encoding="utf-8"))
if checkout_path.is_file()
else {}
)
except (OSError, json.JSONDecodeError):
return
patches_by_version: dict[str, dict] = {}
for source in (cached, checkout):
for row in source.get("patches") or []:
if isinstance(row, dict) and row.get("version"):
patches_by_version[str(row["version"])] = row
patches = sorted(
patches_by_version.values(),
key=lambda row: int(row.get("timestamp") or 0),
reverse=True,
)
details = dict(cached.get("details") or {})
details.update(checkout.get("details") or {})
lookup: dict[str, dict] = {}
lookup_groups = set(cached.get("lookup") or {}) | set(
checkout.get("lookup") or {}
)
for group in lookup_groups:
merged = dict((cached.get("lookup") or {}).get(group) or {})
merged.update((checkout.get("lookup") or {}).get(group) or {})
lookup[group] = merged
meta = dict(cached.get("meta") or {})
meta.update(checkout.get("meta") or {})
meta["count"] = len(patches)
meta["details_count"] = len(details)
write_json_atomic(
checkout_path,
{"meta": meta, "patches": patches, "lookup": lookup, "details": details},
)
def restore() -> None:
source_dir = (
STATE_DIR
if STATE_DIR.is_dir() and any(STATE_DIR.glob("*.json"))
else CACHE_DIR
)
restored = 0
for name in CACHE_FILES:
if name == "patches.json":
continue
src = source_dir / name
if src.is_file() and _cache_is_at_least_as_new(src, DATA / name):
_copy(src, DATA / name)
restored += 1
restore_patches(source_dir / "patches.json", DATA / "patches.json")
restore_streamers(source_dir / "streamers.json", DATA / "streamers.json")
print(f"refresh cache restored: {restored} generated files from {source_dir}")
def save() -> None:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)
saved = 0
for name in (*CACHE_FILES, "streamers.json"):
src = DATA / name
if src.is_file():
_copy(src, CACHE_DIR / name)
_copy(src, STATE_DIR / name)
saved += 1
print(f"refresh cache saved: {saved} files to {STATE_DIR}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("restore", "save"))
args = parser.parse_args()
restore() if args.action == "restore" else save()
if __name__ == "__main__":
main()
+285 -50
View File
@@ -21,11 +21,17 @@ from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT.parent))
from shared.http_utils import write_json_atomic
# Paths whose content change should trigger deploy / OSS.
DATA_WATCH = [
@@ -42,6 +48,7 @@ DATA_WATCH = [
ROOT / "data" / "patches.json",
ROOT / "data" / "hero_abilities.json",
ROOT / "data" / "item_shop.json",
ROOT / "data" / "item_counter_stats.json",
]
ASSET_DIRS = [
ROOT / "assets" / "item_icons",
@@ -51,6 +58,19 @@ ASSET_DIRS = [
ROOT / "assets" / "streamer_avatars",
ROOT / "assets" / "streamer_videos",
]
SUMMARY_PATH = ROOT / ".refresh" / "summary.json"
VOLATILE_JSON_KEYS = frozenset(
{
"fetched_at",
"generated_at",
"last_attempt_at",
"live_probed_at",
"profile_fetched_at",
"updated_at",
}
)
RUN_RESULTS: list[dict] = []
PATCH_RESULT: dict = {}
def _file_digest(path: Path) -> str | None:
@@ -63,6 +83,35 @@ def _file_digest(path: Path) -> str | None:
return h.hexdigest()
def _without_volatile_fields(value):
if isinstance(value, dict):
return {
key: _without_volatile_fields(child)
for key, child in value.items()
if key not in VOLATILE_JSON_KEYS
}
if isinstance(value, list):
return [_without_volatile_fields(child) for child in value]
return value
def _semantic_file_digest(path: Path) -> str | None:
"""Hash JSON business content while ignoring refresh-only timestamps."""
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return _file_digest(path)
canonical = json.dumps(
_without_volatile_fields(payload),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
def _dir_digest(path: Path) -> str | None:
if not path.is_dir():
return None
@@ -82,7 +131,7 @@ def _dir_digest(path: Path) -> str | None:
def snapshot() -> dict[str, str | None]:
out: dict[str, str | None] = {}
for p in DATA_WATCH:
out[str(p.relative_to(ROOT))] = _file_digest(p)
out[str(p.relative_to(ROOT))] = _semantic_file_digest(p)
for d in ASSET_DIRS:
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d)
return out
@@ -106,9 +155,20 @@ def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None])
def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool:
cmd = [sys.executable, str(ROOT / script), *args]
print(f"+ {' '.join(cmd)}", flush=True)
started = time.monotonic()
if dry_run:
RUN_RESULTS.append({"step": script, "ok": True, "dry_run": True, "duration_s": 0.0})
return True
proc = subprocess.run(cmd, cwd=str(ROOT), check=False)
result = {
"step": script,
"args": list(args),
"ok": proc.returncode == 0,
"soft_fail": soft_fail,
"returncode": proc.returncode,
"duration_s": round(time.monotonic() - started, 3),
}
RUN_RESULTS.append(result)
if proc.returncode == 0:
return True
if soft_fail:
@@ -124,6 +184,7 @@ def patch_check() -> dict:
"""Always hits the network (read-only list); safe under --dry-run."""
cmd = [sys.executable, str(ROOT / "fetch_patches.py"), "--check"]
print(f"+ {' '.join(cmd)}", flush=True)
started = time.monotonic()
proc = subprocess.run(
cmd,
cwd=str(ROOT),
@@ -136,6 +197,17 @@ def patch_check() -> dict:
if not line:
raise RuntimeError("fetch_patches.py --check produced empty stdout")
result = json.loads(line)
PATCH_RESULT.clear()
PATCH_RESULT.update(result)
RUN_RESULTS.append(
{
"step": "fetch_patches.py --check",
"ok": True,
"duration_s": round(time.monotonic() - started, 3),
"has_new": bool(result.get("has_new")),
"new_versions": list(result.get("new_versions") or []),
}
)
print(json.dumps(result, ensure_ascii=False), flush=True)
return result
@@ -160,7 +232,7 @@ def run_daily(*, dry_run: bool = False) -> bool:
run_script("fetch_hero_stats.py", dry_run=dry_run)
run_script("fetch_leaderboards.py", dry_run=dry_run)
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
run_script("fetch_pro_matches.py", dry_run=dry_run)
run_script("fetch_pro_matches.py", "--include-pubs", dry_run=dry_run)
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
run_script("fetch_streamers.py", dry_run=dry_run)
# Soft-fail live probe (exits 0; probe failures keep previous is_live).
@@ -180,7 +252,7 @@ def run_daily(*, dry_run: bool = False) -> bool:
def run_weekly(*, dry_run: bool = False) -> None:
# Default full matchup refresh (not --resume-matchups); stale cells kept on failure.
run_script("fetch_stratz_meta.py", dry_run=dry_run)
run_script("fetch_hero_items.py", dry_run=dry_run)
run_script("fetch_hero_items.py", "--force", dry_run=dry_run)
run_script("fetch_items_meta.py", dry_run=dry_run)
run_script("fetch_item_counter_stats.py", "--soft-fail", dry_run=dry_run)
run_script("item_fears.py", dry_run=dry_run)
@@ -200,6 +272,130 @@ def run_patch_tier(*, dry_run: bool = False) -> bool:
return True
def _stale_metrics() -> dict:
metrics = {
"stratz_meta_stale": False,
"stratz_matchup_stale": 0,
"streamer_profile_missing": 0,
"streamer_live_probe_missing": 0,
}
meta_path = ROOT / "data" / "stratz_hero_meta.json"
matchup_path = ROOT / "data" / "stratz_matchup_tops.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
metrics["stratz_meta_stale"] = bool(meta.get("stale"))
metrics["stratz_meta_stale_brackets"] = list(meta.get("stale_brackets") or [])
except (OSError, json.JSONDecodeError):
metrics["stratz_meta_missing"] = True
try:
matchup = json.loads(matchup_path.read_text(encoding="utf-8"))
metrics["stratz_matchup_stale"] = sum(
1
for cell in (matchup.get("by_hero") or {}).values()
if isinstance(cell, dict) and cell.get("stale")
)
except (OSError, json.JSONDecodeError):
metrics["stratz_matchup_missing"] = True
try:
streamers = json.loads(
(ROOT / "data" / "streamers.json").read_text(encoding="utf-8")
)
rows = streamers.get("streamers") or []
metrics["streamer_profile_missing"] = sum(
1
for row in rows
if isinstance(row, dict)
and row.get("profile_url")
and not row.get("profile_fetched_at")
)
metrics["streamer_live_probe_missing"] = sum(
1
for row in rows
if isinstance(row, dict)
and row.get("live_url")
and not row.get("live_probed_at")
)
except (OSError, json.JSONDecodeError):
metrics["streamers_missing"] = True
metrics["item_counter_missing"] = not (
ROOT / "data" / "item_counter_stats.json"
).is_file()
return metrics
def _write_summary(summary: dict) -> None:
summary["steps"] = list(RUN_RESULTS)
summary["patch_check"] = dict(PATCH_RESULT)
summary["health"] = _stale_metrics()
summary["completed_at"] = datetime.now(timezone.utc).isoformat()
write_json_atomic(SUMMARY_PATH, summary)
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
def validate_publish_options(
*,
assets_changed: bool,
skip_oss: bool,
skip_deploy: bool,
) -> None:
if assets_changed and skip_oss and not skip_deploy:
raise RuntimeError(
"assets changed but --skip-oss would publish missing OSS assets; "
"also set --skip-deploy for a local-only refresh"
)
def acquire_refresh_lock(tier: str):
"""Serialize refresh/deploy work on a self-hosted runner.
Patch checks are deferred instead of waiting behind a full refresh; the
six-hour schedule will retry them without exporting a stale restored cache.
"""
lock_path = Path(
os.environ.get("CLIMPEROR_REFRESH_LOCK")
or (Path.home() / ".climperor-web-refresh.lock")
)
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+b")
handle.seek(0)
if handle.tell() == 0 and lock_path.stat().st_size == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
try:
if os.name == "nt":
import msvcrt
mode = msvcrt.LK_NBLCK if tier == "patch" else msvcrt.LK_LOCK
msvcrt.locking(handle.fileno(), mode, 1)
else:
import fcntl
flags = fcntl.LOCK_EX | (fcntl.LOCK_NB if tier == "patch" else 0)
fcntl.flock(handle.fileno(), flags)
except (BlockingIOError, OSError):
handle.close()
if tier == "patch":
return None
raise
return handle
def release_refresh_lock(handle) -> None:
try:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
finally:
handle.close()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
@@ -218,57 +414,96 @@ def main() -> None:
)
args = ap.parse_args()
before = snapshot() if not args.dry_run else {}
did_work = False
if args.tier in ("weekly", "all"):
run_weekly(dry_run=args.dry_run)
did_work = True
if args.tier in ("daily", "all"):
if run_daily(dry_run=args.dry_run):
did_work = True
else:
did_work = True # daily fetch scripts still ran
if args.tier == "patch":
if run_patch_tier(dry_run=args.dry_run):
did_work = True
if args.dry_run:
print("dry-run complete (no deploy/oss)", flush=True)
return
after = snapshot()
data_changed, assets_changed = diff_snapshots(before, after)
print(
f"changes: data={data_changed} assets={assets_changed} "
f"force_deploy={args.force_deploy} did_work={did_work}",
flush=True,
)
if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload")
elif assets_changed and args.skip_oss:
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed
deployed = False
if should_deploy and not args.skip_deploy:
run_script("deploy_relations.py")
deployed = True
elif should_deploy and args.skip_deploy:
print("deploy needed but --skip-deploy set; skipping", flush=True)
else:
print("no data/asset changes; skip deploy", flush=True)
RUN_RESULTS.clear()
PATCH_RESULT.clear()
started = time.monotonic()
summary = {
"ok": True,
"ok": False,
"tier": args.tier,
"data_changed": data_changed,
"assets_changed": assets_changed,
"deployed": deployed,
"run_id": os.environ.get("REFRESH_RUN_ID") or "",
"started_at": datetime.now(timezone.utc).isoformat(),
"data_changed": False,
"assets_changed": False,
"changed_paths": [],
"deployed": False,
"force_deploy": args.force_deploy,
}
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
lock_handle = acquire_refresh_lock(args.tier)
if lock_handle is None:
summary.update(
{
"ok": True,
"skipped": True,
"skip_reason": "full refresh already running; next patch schedule will retry",
"duration_s": round(time.monotonic() - started, 3),
}
)
_write_summary(summary)
return
try:
cache_enabled = os.environ.get("REFRESH_CACHE_ENABLED") == "1"
if cache_enabled:
run_script("refresh_cache.py", "restore")
before = snapshot() if not args.dry_run else {}
if args.tier in ("weekly", "all"):
run_weekly(dry_run=args.dry_run)
if args.tier in ("daily", "all"):
run_daily(dry_run=args.dry_run)
if args.tier == "patch":
run_patch_tier(dry_run=args.dry_run)
if args.dry_run:
summary["ok"] = True
summary["dry_run"] = True
print("dry-run complete (no deploy/oss)", flush=True)
return
after = snapshot()
data_changed, assets_changed = diff_snapshots(before, after)
changed_paths = sorted(
key for key in set(before) | set(after) if before.get(key) != after.get(key)
)
summary.update(
{
"data_changed": data_changed,
"assets_changed": assets_changed,
"changed_paths": changed_paths,
}
)
print(
f"changes: data={data_changed} assets={assets_changed} "
f"force_deploy={args.force_deploy}",
flush=True,
)
validate_publish_options(
assets_changed=assets_changed,
skip_oss=args.skip_oss,
skip_deploy=args.skip_deploy,
)
if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload")
elif assets_changed:
print("assets changed but deploy is disabled; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed
if should_deploy and not args.skip_deploy:
run_script("deploy_relations.py")
summary["deployed"] = True
elif should_deploy:
print("deploy needed but --skip-deploy set; skipping", flush=True)
else:
print("no business data/asset changes; skip deploy", flush=True)
if cache_enabled:
run_script("refresh_cache.py", "save")
summary["ok"] = True
except BaseException as exc:
summary["error"] = f"{type(exc).__name__}: {exc}"
raise
finally:
summary["duration_s"] = round(time.monotonic() - started, 3)
_write_summary(summary)
release_refresh_lock(lock_handle)
if __name__ == "__main__":
+39
View File
@@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import mimetypes
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
@@ -50,6 +51,32 @@ from shared.relations import DEFAULT_RELATIONS, load_relations
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
_MOBILE_DEMAND_LOCK = threading.Lock()
def _read_mobile_demand_count() -> int:
try:
if not MOBILE_DEMAND_PATH.is_file():
return 0
data = json.loads(MOBILE_DEMAND_PATH.read_text(encoding="utf-8"))
n = int(data.get("count", 0))
return n if n >= 0 else 0
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return 0
def _inc_mobile_demand_count() -> int:
with _MOBILE_DEMAND_LOCK:
n = _read_mobile_demand_count() + 1
MOBILE_DEMAND_PATH.parent.mkdir(parents=True, exist_ok=True)
MOBILE_DEMAND_PATH.write_text(
json.dumps({"count": n}, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return n
GRID_ORDER_PATH = DATA / "hero_grid_order.json"
HERO_ITEMS_PATH = DATA / "hero_items.json"
HERO_STATS_PATH = DATA / "hero_stats.json"
@@ -73,6 +100,8 @@ def _first_existing(*candidates: Path) -> Path | None:
if p.is_file():
return p
return None
ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4}
ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"}
ABILITY_ICON_URL = (
@@ -877,6 +906,9 @@ class Handler(BaseHTTPRequestHandler):
# production only); the empty map keeps the UI on data.json is_live.
self._json(200, {"probed_at": None, "ttl": 300, "streamers": {}})
return
if path == "/api/mobile-demand":
self._json(200, {"count": _read_mobile_demand_count()})
return
if path.startswith("/attr/"):
key = path[len("/attr/") :]
if key not in ("str.png", "agi.png", "int.png", "all.png"):
@@ -1057,6 +1089,13 @@ class Handler(BaseHTTPRequestHandler):
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype)
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/mobile-demand":
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
return
self.send_error(404)
def main() -> None:
ap = argparse.ArgumentParser(description="Preview qualitative hero relations (read-only)")
+261
View File
@@ -0,0 +1,261 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "web"))
from web import notify_site_traffic, refresh_web
from web.fetch_stratz_meta import preserve_failed_meta_brackets
from web.refresh_cache import restore_patches, restore_streamers
from shared.http_utils import write_json_atomic
class RefreshDigestTests(unittest.TestCase):
def test_atomic_json_replaces_complete_document(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "data.json"
write_json_atomic(path, {"value": [1, 2, 3]})
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), {"value": [1, 2, 3]})
self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), [])
def test_semantic_digest_ignores_refresh_timestamps(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "data.json"
path.write_text(
json.dumps({"fetched_at": "old", "by_hero": {"axe": {"pick": 1}}}),
encoding="utf-8",
)
before = refresh_web._semantic_file_digest(path)
path.write_text(
json.dumps({"fetched_at": "new", "by_hero": {"axe": {"pick": 1}}}),
encoding="utf-8",
)
self.assertEqual(before, refresh_web._semantic_file_digest(path))
path.write_text(
json.dumps({"fetched_at": "new", "by_hero": {"axe": {"pick": 2}}}),
encoding="utf-8",
)
self.assertNotEqual(before, refresh_web._semantic_file_digest(path))
def test_assets_cannot_deploy_when_oss_is_skipped(self) -> None:
with self.assertRaises(RuntimeError):
refresh_web.validate_publish_options(
assets_changed=True,
skip_oss=True,
skip_deploy=False,
)
refresh_web.validate_publish_options(
assets_changed=True,
skip_oss=True,
skip_deploy=True,
)
def test_streamer_cache_preserves_manual_rows_and_restores_runtime_fields(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
cached = Path(tmp) / "cached.json"
checkout = Path(tmp) / "checkout.json"
write_json_atomic(
cached,
{
"fetched_at": "2026-07-29T10:00:00+00:00",
"streamers": [
{
"id": "known",
"nickname": "cached-name",
"follower_count": 99,
"live_url": "must-not-restore",
},
{"id": "deleted", "nickname": "deleted"},
],
},
)
write_json_atomic(
checkout,
{
"fetched_at": "2026-07-29T09:00:00+00:00",
"streamers": [
{
"id": "known",
"nickname": "git-name",
"live_url": "new-manual-url",
},
{"id": "new", "nickname": "new-manual-row"},
],
},
)
restore_streamers(cached, checkout)
payload = json.loads(checkout.read_text(encoding="utf-8"))
rows = {row["id"]: row for row in payload["streamers"]}
self.assertEqual(rows["known"]["nickname"], "cached-name")
self.assertEqual(rows["known"]["follower_count"], 99)
self.assertEqual(rows["known"]["live_url"], "new-manual-url")
self.assertIn("new", rows)
self.assertNotIn("deleted", rows)
def test_patch_cache_merges_runtime_and_committed_versions(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
cached = Path(tmp) / "cached.json"
checkout = Path(tmp) / "checkout.json"
write_json_atomic(
cached,
{
"meta": {"fetched_at": "cached"},
"patches": [{"version": "7.40", "timestamp": 200}],
"lookup": {"heroes": {"1": {"name": "cached"}}},
"details": {"7.40": {"general_notes": ["cached"]}},
},
)
write_json_atomic(
checkout,
{
"meta": {"source": "git"},
"patches": [{"version": "7.41", "timestamp": 300}],
"lookup": {"heroes": {"2": {"name": "committed"}}},
"details": {"7.41": {"general_notes": ["committed"]}},
},
)
restore_patches(cached, checkout)
payload = json.loads(checkout.read_text(encoding="utf-8"))
self.assertEqual(
[row["version"] for row in payload["patches"]],
["7.41", "7.40"],
)
self.assertEqual(set(payload["details"]), {"7.40", "7.41"})
self.assertEqual(set(payload["lookup"]["heroes"]), {"1", "2"})
class StratzFallbackTests(unittest.TestCase):
def test_failed_bracket_keeps_previous_data_and_marks_stale(self) -> None:
fresh = {
"totals": {"immortal": {"pick": 0}},
"meta_board": {"immortal": []},
"by_hero": {
"axe": {
"weeks": {"immortal": []},
"latest": {},
"positions": {"immortal": {}},
}
},
}
previous = {
"totals": {"immortal": {"pick": 100}},
"meta_board": {"immortal": [{"key": "axe"}]},
"by_hero": {
"axe": {
"weeks": {"immortal": [{"week": 1, "pick": 100, "win": 50}]},
"latest": {"immortal": {"pick": 100, "win": 50}},
"positions": {"immortal": {"POSITION_3": {"pick": 40, "win": 20}}},
}
},
}
result = preserve_failed_meta_brackets(
fresh,
previous,
failed_weeks={"immortal"},
failed_positions={"immortal"},
)
self.assertTrue(result["stale"])
self.assertEqual(result["totals"]["immortal"]["pick"], 100)
self.assertEqual(
result["by_hero"]["axe"]["positions"]["immortal"]["POSITION_3"]["pick"],
40,
)
class ProductionHealthTests(unittest.TestCase):
def test_stale_payload_and_invalid_live_api_are_reported(self) -> None:
now = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
old = (now - timedelta(hours=48)).isoformat()
payload = {
"heroes": [{"key": "axe"}],
"meta": {"refresh_run_id": "123"},
"hero_stats": {"fetched_at": old},
"leaderboards": {"fetched_at": old},
"hero_matches": {"meta": {"fetched_at": old}},
"pro_matches": {"meta": {"fetched_at": old}},
"streamers": {"fetched_at": old},
"stratz_hero_meta": {"fetched_at": old},
"stratz_matchup_tops": {"fetched_at": old},
"hero_items": {"meta": {"fetched_at": old}},
}
responses = [
(200, b'var SITE_VERSION = "1.2.3";', "application/javascript", {}),
(200, json.dumps(payload).encode(), "application/json", {}),
(200, b"<html>fallback</html>", "text/html", {}),
]
with patch.object(
notify_site_traffic,
"http_response_soft",
side_effect=responses,
):
health = notify_site_traffic.fetch_production_health(now=now)
self.assertEqual(health.site_version, "1.2.3")
self.assertEqual(health.refresh_run_id, "123")
self.assertIn("英雄统计", health.stale)
self.assertIn("直播状态 API", health.stale)
self.assertFalse(health.live_api_ok)
def test_successful_refresh_summary_prevents_false_freshness_alarm(self) -> None:
now = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
old_daily = (now - timedelta(hours=48)).isoformat()
recent_weekly = (now - timedelta(days=2)).isoformat()
payload = {
"heroes": [{"key": "axe"}],
"meta": {"refresh_run_id": "456"},
"hero_stats": {"fetched_at": old_daily},
"leaderboards": {"fetched_at": old_daily},
"hero_matches": {"meta": {"fetched_at": old_daily}},
"pro_matches": {"meta": {"fetched_at": old_daily}},
"streamers": {"fetched_at": old_daily},
"stratz_hero_meta": {"fetched_at": recent_weekly},
"stratz_matchup_tops": {"fetched_at": recent_weekly},
"hero_items": {"meta": {"fetched_at": recent_weekly}},
}
responses = [
(200, b'var SITE_VERSION = "1.2.3";', "application/javascript", {}),
(200, json.dumps(payload).encode(), "application/json", {}),
(
200,
b'{"ok":true,"streamers":{}}',
"application/json",
{"x-live-cache": "hit"},
),
]
with patch.object(
notify_site_traffic,
"http_response_soft",
side_effect=responses,
):
health = notify_site_traffic.fetch_production_health(
now=now,
refresh_summaries={
"web-daily.yml": {
"ok": True,
"steps": [
{"step": "fetch_hero_stats.py", "ok": True},
{"step": "fetch_leaderboards.py", "ok": True},
{"step": "fetch_hero_matches.py", "ok": True},
{"step": "fetch_pro_matches.py", "ok": True},
{"step": "fetch_streamers.py", "ok": True},
],
"health": {
"streamer_profile_missing": 0,
"streamer_live_probe_missing": 0,
},
}
},
)
self.assertEqual(health.stale, [])
self.assertTrue(health.live_api_ok)
if __name__ == "__main__":
unittest.main()