dsh-music-player 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ DeepSeek Harness 音乐/小说播放插件。
6
6
 
7
7
  写代码写累了、想摸鱼又不想切窗口?这个插件就是你的**摸鱼神器**——直接在 DeepSeek Harness 的网页里塞进一个 **DSH音乐播放器**:扫一下你电脑上的音乐目录(默认 `~/Music`)就能在浏览器里听歌,带播放条和可拖拽的播放面板,还能自己建歌单。
8
8
 
9
- 光听歌还不够,它还能**听书**:把本地 `.txt`/`.epub` 小说丢给 AI 朗读,想听哪章点哪章、声音随便挑。现在还能**听新闻**:让 agent 用联网搜索收集当天头条(热点/国内/国际/科技/财经/体育/娱乐,或 AI 等任意自定义主题),整理筛选成口播稿用 AI 声音播报——支持每日多班次定时、静默收集、文字版阅读。最绝的是它注册了 `music_play` 等模型工具——你连鼠标都不用动,在对话框里跟 agent 说句「播放周杰伦的歌」,音乐分分钟响起来,摸鱼摸出新境界。
9
+ 光听歌还不够,它还能**听书**:把本地 `.txt`/`.epub` 小说丢给 AI 朗读,想听哪章点哪章、声音随便挑。现在还能**听新闻**:让 agent 用联网搜索收集当天头条(热点/国内/国际/科技/财经/体育/娱乐,或 AI 等任意自定义主题),整理筛选成口播稿用 AI 声音播报——支持每日多定时任务定时、静默收集、文字版阅读。还能**听网络电台**:中文主流台(央广/凤凰/CCTV 伴音等,含 HLS 流)与全球电台一键开播。最绝的是它注册了 `music_play` 等模型工具——你连鼠标都不用动,在对话框里跟 agent 说句「播放周杰伦的歌」,音乐分分钟响起来,摸鱼摸出新境界。
10
10
 
11
11
  ## 特性
12
12
 
@@ -18,12 +18,14 @@ DeepSeek Harness 音乐/小说播放插件。
18
18
  - 播放列表面板可自由拖动,右下角可拖拽调整大小,位置与尺寸跨刷新记忆
19
19
  - AI 讲书:本地 `.txt` / `.epub` 小说 AI 语音朗读,自动识别**书名/前言/章节/尾声**结构,播放条带**章节目录**跳转(打开即定位到当前正在播放的章节)、章节切换,可选 4 种中文 AI 声音(默认白桦)
20
20
  - `music_play` 模型工具:agent 可按关键词播放本地音乐,也可按小说名启动 AI 讲书
21
- - **每日新闻播报**:agent 用 web search 收集当天头条(可分类别/自定义主题),整理筛选后 AI 语音播报;播放面板「新闻播报」页签随时回看、挑条目播放、看文字版;支持每日多班次定时与静默收集(详见下文「每日新闻播报」)
21
+ - **每日新闻播报**:agent 用 web search 收集当天头条(可分类别/自定义主题),整理筛选后 AI 语音播报;播放面板「新闻播报」页签随时回看、挑条目播放、看文字版;支持每日多定时任务定时与静默收集(详见下文「每日新闻播报」)
22
22
  - 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
23
23
  - **真实音质识别**:扫描时自动识别每首歌的音质档位,播放条显示「格式 · 音质档」(如 `FLAC · 无损` / `MP3 · 高音质` / `MP3 · 标准`),与在线音乐的音质标签一致
24
24
  - **自建歌单**:可新建多个歌单,从本地文件(支持多选、可跨目录)添加歌曲;播放条爱心按钮一键收藏到默认歌单「我最喜欢」;歌单作为播放来源时,顺序/乱序循环只在该歌单内进行
25
25
  - **在线 QQ 音乐**:面板内置「QQ音乐」页签——微信/QQ 扫码登录(解锁 VIP/高音质)、我的歌单/推荐歌单/分类歌单/排行榜/新歌/搜索浏览、卡片式歌单展示、一键收藏到「我喜欢」
26
26
  - **在线酷狗音乐**:面板内置「酷狗音乐」页签——酷狗 App 扫码登录(解锁高音质)、推荐/分类歌单/排行榜(TOP500 等)/统一搜索/我的歌单,逐字歌词内嵌翻译;详见下文「在线酷狗音乐」
27
+ - **在线网易云音乐**:面板内置「网易云」页签——扫码登录后浏览/搜索/播放(免费歌 320k 高音质直链、VIP 歌试听片段)、我的歌单/推荐/分类歌单/排行榜、YRC 逐字歌词与 LRC+逐句翻译;详见下文「在线网易云音乐」
28
+ - **网络电台**:面板内置「网络电台」页签——radio-browser 全球开放电台目录(数万台、无登录),按台名/国家/标签搜索,中文电台/热门电台按主题分组浏览、收藏与最近播放;**支持 HLS(m3u8) 电台**(央广/凤凰/CRI/CCTV 伴音等中文主流台),Host 端纯 Node 把 HLS 转成浏览器直接可播的音频流,无新依赖;`music_play` 传 `source=radio` 可按台名直接开播;详见下文「网络电台」
27
29
 
28
30
  ## 截图
29
31
 
@@ -112,10 +114,10 @@ dsh plugin --profile <profile> add github:kendu76/dsh-music-player
112
114
  - 我的歌单:登录后展示当前账号的歌单(卡片式),本人创建的歌单卡片右上角可一键删除(二次确认;「我喜欢」不可删除)。
113
115
  - 推荐歌单:热门推荐 12 条,底部「加载更多」可续载。
114
116
  - 分类歌单:60+ 分类(默认折叠显示 8 个,可展开),每个分类的歌单支持「加载更多」。
115
- - 排行榜:巅峰榜/地区榜/特色榜等分组,点榜单看歌曲(带榜单封面卡片),榜单详情底部「加载更多」可分页续载全部歌曲。
117
+ - 排行榜:巅峰榜/地区榜/特色榜等分组,点榜单看歌曲(带榜单封面卡片),榜单歌曲**一次全量加载**(如热歌榜 299 首,无需「加载更多」)。
116
118
  - 新歌:新歌速递(最新/内地/港台/欧美/韩国等)。
117
119
  - 搜索:搜歌曲与歌单,带搜索历史(Host 持久化,最近 10 条)。
118
- - **播放**:点击任意歌曲即可播放(同一播放条 + 频谱);VIP 标识显示在歌名后,行尾显示歌手名。进入歌单/播放列表时自动定位到正在播放的那一首,且正在播放的条目以高亮选中态显示。
120
+ - **播放**:点击任意歌曲即可播放(同一播放条 + 频谱);歌单详情/排行榜详情/新歌速递头部有「**▶ 播放全部**」按钮,一键把整列表加入播放队列、从第一首开始顺序播放;VIP 标识显示在歌名后,行尾显示歌手名。进入歌单/播放列表时自动定位到正在播放的那一首,且正在播放的条目以高亮选中态显示。
119
121
  - **收藏**:播放条爱心按钮把当前在线曲目收藏到 QQ 音乐「我喜欢」,已收藏歌曲爱心实时点亮。
120
122
  - **续播**:在线播放进度(当前曲目 + 队列)刷新后自动恢复,点 ▶ 续播。
121
123
  - 在线曲目不占本地曲库的 500 首上限,与本地/讲书完全隔离。
@@ -124,7 +126,7 @@ dsh plugin --profile <profile> add github:kendu76/dsh-music-player
124
126
 
125
127
  播放面板侧栏切到「**酷狗音乐**」页签。**需先用酷狗 App 扫码登录**才能试听(未登录可浏览榜单/歌单/搜索,但取链播放需要登录态)。登录后支持:
126
128
 
127
- - **浏览**:推荐歌单(600+ 热门精选)/ 分类歌单 / 排行榜(TOP500、国潮音乐榜等 57 个)/ 统一搜索(歌曲 + 歌单)/ 我的歌单(可在歌单详情里移除歌曲)。
129
+ - **浏览**:推荐歌单(600+ 热门精选)/ 分类歌单 / 排行榜(TOP500、国潮音乐榜等 57 个,榜单歌曲一次全量加载,如 TOP500=500 首)/ 统一搜索(歌曲 + 歌单)/ 我的歌单(可在歌单详情里移除歌曲)。歌单详情与排行榜详情头部同样有「▶ 播放全部」,整列表入队、从第一首顺序播放。
128
130
  - **逐字歌词**:KRC 逐字行窗口(与 QQ 的 QRC 同级精度),内嵌翻译自动并入歌词显示。
129
131
  - **高音质**:按「无损 → Hi-Res → 320k → 128k」梯队请求,tracker 按账号权限授予真实档位,播放条显示实际品质标签。
130
132
  - **续播**:与 QQ 同构——播放进度+队列独立持久化,刷新后自动恢复;单曲取链失败自动跳下一首。
@@ -134,6 +136,35 @@ dsh plugin --profile <profile> add github:kendu76/dsh-music-player
134
136
  > 账号风控风险由使用者自行承担。技术细节与端点调研见
135
137
  > [docs/kugou-integration-research.md](docs/kugou-integration-research.md)。
136
138
 
139
+ ## 在线网易云音乐
140
+
141
+ 播放面板侧栏切到「**网易云**」页签。**需先用网易云音乐 App 扫码登录**(与 QQ/酷狗一致,未登录只显示登录入口),登录后支持:
142
+
143
+ - **播放**:免费歌曲直接拿 320k 高音质直链播放(播放条显示「网易云 · 高音质」);VIP/数字专辑歌曲仅能播 **45 秒试听片段**(显示「试听」)。
144
+ - **浏览**:推荐歌单 / 分类歌单(65 个分类)/ 官方排行榜(飙升榜、新歌榜等 63 个榜单)/ 统一搜索(歌曲 + 歌单,多分型)。歌单详情(含超大歌单,如 200 首的热歌榜自动批量补齐)与榜单详情头部均有「▶ 播放全部」。
145
+ - **歌词**:YRC 逐字行窗口(与 QQ QRC / 酷狗 KRC 同级精度,命中时生效);普通歌曲回退整行 LRC,外语歌带逐句翻译(tlyric)与罗马音。
146
+ - **我的歌单**:登录后可查看「我喜欢的音乐」与自建/收藏歌单;**公开歌单详情页可一键「☆ 收藏 / ★ 已收藏」**(收藏进「我的歌单」,取消收藏同按钮)。
147
+ - **续播**:播放进度 + 队列独立持久化,刷新后自动恢复;单曲版权受限/取链失败自动跳下一首。
148
+ - 登录态保存在 Host 端(`~/.dsh/music-player-netease-cookie.json`,0600),刷新/重启不丢。
149
+ - 另有「匿名取链」能力保留在后端:agent 用 `music_play` 工具传 `source=netease` 时,未登录也能播免费曲(供对话场景直接点播)。
150
+
151
+ > **使用声明**:与在线 QQ 音乐相同——非官方接口、仅供个人学习试听、严禁商业用途;
152
+ > 账号风控风险由使用者自行承担。技术细节与端点调研见
153
+ > [docs/netease-integration-research.md](docs/netease-integration-research.md)。
154
+
155
+ ## 网络电台
156
+
157
+ 播放面板切到「**网络电台**」页签即可在线听全球电台(radio-browser.info 开放目录,**无需登录、无 key**)。子页签:**我的电台(收藏)/ 最近播放 / 中文电台 / 热门电台 / 搜索**。
158
+
159
+ - **数据源**:radio-browser.info 社区目录(~4 万电台,CC 开放数据),多镜像自动故障转移;按台名/国家/标签/主题搜索或浏览。
160
+ - **中文电台 / 热门电台**:按主题分组浏览(中文=全部/新闻/音乐/交通/财经/文艺/故事/体育;热门=全部/音乐/新闻/古典/摇滚/爵士/谈话),「加载更多」分页拉取,会话内缓存切回不重拉。
161
+ - **HLS(m3u8) 支持**:央广/凤凰/CRI/CCTV 伴音等中文主流台多为 HLS 流(实测 CN 目录约四成为 HLS)。Host 端纯 Node(`lib/hls.js`)实时解析 m3u8(支持 master 嵌套、防盗链 token 与 scheme-relative URL)→ 逐分片归一化为 AAC(ADTS) 连续流喂给浏览器——兼容 **MPEG-TS 分片**(央广/凤凰/CCTV)与**裸 ADTS 分片**(蜻蜓/喜马拉雅系 .aac,如「华语金曲500首」)两种容器,**无需 hls.js/ffmpeg 任何新依赖**,浏览器 `<audio>` 直接可播(LIVE 直播态/断流自动重连与纯流台一致)。HLS 台在列表显示绿色「HLS」徽章、播放条标「电台 · HLS」。
162
+ - **收藏**:点行尾 ♥ 收藏到本地收藏夹(`~/.dsh/music-player-radio.json`),「我的电台」随时回听。
163
+ - **命令**:`music_play` 工具传 `source=radio` + 台名/国家,agent 可直接搜台开播(如「播放网络电台 中国之声」)。
164
+ - 直播流不 seek、无进度条(LIVE 态);个别台站可能失效或编码特殊(fMP4/加密等非 AAC)时会明确提示换台。
165
+
166
+ > **合规**:目录为 CC 开放数据,播放的是各电台公开直播流;仅供个人收听,不录制/不二次分发,遵守台站 ToS。设计与实测记录见 [docs/internet-radio-design.md](docs/internet-radio-design.md)。
167
+
137
168
  ## AI 讲书
138
169
 
139
170
  把本地 `.txt` / `.epub` 小说交给 AI 朗读。**AI 语音目前仅支持xiaomi提供方(限时免费),请在设置中配置好再使用此功能。**
@@ -164,9 +195,11 @@ dsh plugin --profile <profile> add github:kendu76/dsh-music-player
164
195
  ### 使用
165
196
 
166
197
  1. **对话即时播报**(收集完立即播放):对 agent 说「**播报今天的新闻**」。它会按类别搜索当天头条(默认热点/国内/国际/科技/财经/体育/娱乐,可任意指定,如「播一下 AI 相关的新闻」)、跨源去重、每条写口播摘要并标注来源,然后自动开播;说「收集一下今天的新闻,先别播」则只生成不出声。
167
- 2. **面板回看**:播放面板「新闻播报」页签——期次按时间倒序排列(**每班次独立保留最近 7 期**),未播放的标「待播」;点进详情可**播整期 / 播某类 / 点某一条新闻从该条播**;「文字版」按钮可全文阅读(不方便听语音时)。播放条上与讲书一致:📖 类别目录跳转、上/下一类、逐句字幕、AI 声音切换、`N%` 已读进度。
168
- 3. **每日定时(Host 自维护)**:在「新闻播报」页签的「⏰ 每日定时」里可视化配置**多班次**(如 08:00 / 12:30 / 18:00),每个班次可独立指定收集范围,并勾选「收集后立即播放」(不勾选 = **静默收集**,只更新简报不出声)。**保存即生效**——定时器由插件在 DSH 主机进程内自维护(无需 agent 创建/同步 DSH 定时任务、无需手动开会话),到点自动执行。也可对 agent 说「每天早上 9 点播报新闻」让其引导配置。可在「⏰ 每日定时」里给执行会话选一个**新闻会话模型**(不选则跟随当前活跃会话)。
169
- 4. **手动补收**:定时班次行有「▶ 立即执行」——一键自动跑一轮(不等时刻);同一班次 10 分钟内重复收集会被自动跳过。
198
+ 2. **面板回看**:播放面板「新闻播报」页签——期次按时间倒序排列(**当日期次全保留,跨天自动清理**),未播放的标「待播」;点进详情可**播整期 / 播某类 / 点某一条新闻从该条播**;「文字版」按钮可全文阅读(不方便听语音时)。播放条上与讲书一致:📖 类别目录跳转、上/下一类、逐句字幕、AI 声音切换、`N%` 已读进度。
199
+ 3. **每日定时(Host 自维护)**:在「新闻播报」页签的「⏰ 每日定时」里可视化配置**多定时任务**(如 08:00 / 12:30 / 18:00),每个定时任务可独立指定收集范围与**新闻条数**(1-20,默认 8;选了多个类别时各类别尽量平均分配),并勾选「收集后立即播放」(不勾选 = **静默收集**,只更新简报不出声);也可勾选「**仅工作日执行**」——按工作日历判断:周一至周五**扣除法定节假日**放假不跑、**周末调休补班视为工作日照常**。节假日数据**按需自动联网获取**(仅在到点判断「仅工作日」定时任务、且日历缺失/过期时才查询 timor.tech,失败自动回退内置表/周一至周五),**无需任何手工维护**。**保存即生效**——定时器由插件在 DSH 主机进程内自维护(无需 agent 创建/同步 DSH 定时任务、无需手动开会话),到点自动执行。也可对 agent 说「每天早上 9 点播报新闻」让其引导配置。可在「⏰ 每日定时」里给执行会话选一个**新闻会话模型**(不选则跟随当前活跃会话)。
200
+ 4. **手动补收**:定时任务行有「▶ 立即执行」——一键自动跑一轮(不等时刻);同一定时任务 10 分钟内重复收集会被自动跳过。
201
+ 5. **RSS 信源池(可选加强,默认开启,Host 后台自动使用)**:内置 **10 个核心源**(中新网时政/国际/财经/体育/文化/即时/要闻 + IT之家 + 量子位 + 少数派,全部实测今日新鲜、开箱即用、零配置),池条目带可靠发布时间作为第一信源——**每次新闻收集执行前自动懒拉取最新数据**(无后台定时器、无需手动刷新),agent 先从池中筛选(按定时任务范围预筛注入指令),`web_search` 只作补盲(热点榜单、自定义主题、池外突发);某源连续失败自动停用 24h。**无 UI、无需任何配置**——面板不展示信源池,Host 直接在后台使用(详见 RFC `docs/news-rss-pool-rfc.md`)。**关掉信源池也不影响功能**——收集完全退回 web_search,能力不降级。
202
+ 6. **工具层确定性去重**:`news_broadcast` 提交时对标题归一化 + 相似度比对,自动剔除与**本期次内**或**当日已有期次**重复的条目(notice 透明报告);同一事件多个来源时,更权威源(official > major > secondary > kol)可**升级替换**当日旧期次里的旧条目。**同类任务当天多次执行不会因去重短收**:收集指令会注入【已报条目】清单(今天已播过哪些),且**仅当当天该定时任务已执行过**时才要求每类按目标条数 1.5~2 倍提交候选缓冲去重(首次执行零冗余、不浪费 token);工具层先去重(剔除重复候选)、再按定时任务条数收敛到目标——收集 8 条就播 8 条(详见 RFC §7.5)。
170
203
 
171
204
  ### 一次执行 = 一个会话
172
205
 
@@ -174,7 +207,7 @@ dsh plugin --profile <profile> add github:kendu76/dsh-music-player
174
207
  - 执行会话自动归入侧边栏 **「新闻收集」分组**(专属工作区目录 `~/.dsh/news`),不再散落在「未分组」里;分组名可在工作区菜单里随意重命名。
175
208
  - **每天凌晨 3 点自动清理「今天之前」的新闻**:删除前一天及更早的全部期次与失败记录(不再保留多天新闻),并联动销毁/归档对应的执行会话;**插件每次启动时也会立即检查一次**,存在非今天的新闻就直接清理(清理幂等,手动触发 `POST /dsh-music/news/purge-stale` 走同一入口)。
176
209
  - 每期次/失败记录都会记录对应的**执行会话 id**;**删除某期新闻时,会连同删除它对应的执行会话**(结果与会话一一对应、可清理)。
177
- - 定时器在 DSH 主机进程内自维护(Node setInterval 读已保存的班次偏好),**完全脱离会话存活**——会话销毁不影响每天到点触发;宿主重启后按持久化偏好自动重建定时器。
210
+ - 定时器在 DSH 主机进程内自维护(Node setInterval 读已保存的定时任务偏好),**完全脱离会话存活**——会话销毁不影响每天到点触发;宿主重启后按持久化偏好自动重建定时器。
178
211
 
179
212
  ### 数据与边界
180
213
 
@@ -248,13 +281,13 @@ AI 语音目前仅支持 xiaomi 提供方(限时免费)。请先在 DSH 模
248
281
  由 agent 在会话里用 `web_search` 联网搜索(内置 DeepSeek 搜索提供方),按类别多查询、跨源去重、只保留可确认时效的条目,每条必标来源(新华社、微博热搜等)。收集在 DSH 主机进程完成——浏览器没开不影响,只是不能出声;搜索服务故障/断网时该期次不生成(宁缺毋假),面板会显示失败原因并支持一键补收。
249
282
 
250
283
  **新闻定时任务设置了却不触发?**
251
- 定时器由插件在 DSH 主机进程内自维护(读面板已保存的班次偏好,每 30s 检查一次,到点触发)——**请确认 dsh-desktop / dsh 服务主机进程保持运行**;浏览器没开不影响收集(只影响出声)。每次触发会新建一个执行会话去收集并绑定结果(归入侧边栏「新闻收集」分组)。可在「新闻播报」页签的「⏰ 每日定时」里查看/修改班次;保存即生效。
284
+ 定时器由插件在 DSH 主机进程内自维护(读面板已保存的定时任务偏好,每 30s 检查一次,到点触发)——**请确认 dsh-desktop / dsh 服务主机进程保持运行**;浏览器没开不影响收集(只影响出声)。每次触发会新建一个执行会话去收集并绑定结果(归入侧边栏「新闻收集」分组)。可在「新闻播报」页签的「⏰ 每日定时」里查看/修改定时任务;保存即生效。
252
285
 
253
286
  **收集时看到 `web_fetch` 报 `WEB_BLOCKED_URL`(non-public IP)?**
254
287
  说明本机开着 TUN 代理的 **fake-ip DNS 模式**(Clash/mihomo、sing-box 等):所有域名都会被解析成 `198.18.x.x` 这类代理内部地址,DSH 的 `web_fetch` 防 SSRF 保护会拒绝连接。收集流程会正常尝试抓取原文,但遇到这种报错会自动跳过、降级为只用 `web_search` 摘要继续整理,**新闻照常生成**。想让 `web_fetch` 恢复可用:把代理的 DNS 改为真实 IP 解析即可(Clash/mihomo 设 `enhanced-mode: redir-host`;sing-box 删除 DNS 配置里的 `fakeip`)。
255
288
 
256
289
  **新闻期次为什么少了 / 旧的去哪了?**
257
- 新闻**只保留当天**:每天凌晨 3 点自动删除前一天及更早的全部期次与失败记录(插件启动时也会立即检查一次),并归档对应的执行会话;当天之内每个班次独立保留最近 7 期,超出自动裁剪;也可以在面板里手动删除。「未听」的期次会带「待播」徽标,方便快速找到漏掉的。
290
+ 新闻**只保留当天**:每天凌晨 3 点自动删除前一天及更早的全部期次与失败记录(插件启动时也会立即检查一次),并归档对应的执行会话;当日期次全保留,也可以在面板里手动删除。「未听」的期次会带「待播」徽标,方便快速找到漏掉的。
258
291
 
259
292
  ## License
260
293
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 中国法定节假日 / 调休日历(离线内置,无需网络)。
3
+ *
4
+ * 数据来源:国务院办公厅历年《关于部分节假日安排的通知》:
5
+ * 2025:国办发明电〔2024〕12 号(2024-11-12 发布)
6
+ * 2026:国办发明电〔2025〕7 号(2025-11-04 发布)
7
+ * 每年年底国务院公布次年安排后,在本文件 CN_HOLIDAYS 追加一年的键值对即可,
8
+ * 逻辑无需改动;不在日历内的年份/日期自动回退「周一至周五 = 工作日」。
9
+ *
10
+ * 语义:
11
+ * 'holiday' = 法定节假日放假(即使落在工作日也不上班)
12
+ * 'workday' = 周末调休补班(即使落在周末也要上班)
13
+ *
14
+ * 本模块为纯逻辑(不依赖 fs / 网络 / DSH),可独立单测;lib/news-core.js 的
15
+ * shiftFiresAt 借它判断「仅工作日」定时任务是否到点触发。
16
+ */
17
+
18
+ /** 各年份节假日/调休表:'YYYY' → { 'MM-DD': 'holiday'|'workday' }。 */
19
+ export const CN_HOLIDAYS = {
20
+ 2025: {
21
+ // 元旦:1月1日(周三)放假 1 天,不调休
22
+ '01-01': 'holiday',
23
+ // 春节:1月28日(除夕)至2月4日放假调休 8 天;1月26日(周日)、2月8日(周六)上班
24
+ '01-28': 'holiday', '01-29': 'holiday', '01-30': 'holiday', '01-31': 'holiday',
25
+ '02-01': 'holiday', '02-02': 'holiday', '02-03': 'holiday', '02-04': 'holiday',
26
+ '01-26': 'workday', '02-08': 'workday',
27
+ // 清明节:4月4日至6日放假 3 天
28
+ '04-04': 'holiday', '04-05': 'holiday', '04-06': 'holiday',
29
+ // 劳动节:5月1日至5日放假调休 5 天;4月27日(周日)上班
30
+ '05-01': 'holiday', '05-02': 'holiday', '05-03': 'holiday', '05-04': 'holiday', '05-05': 'holiday',
31
+ '04-27': 'workday',
32
+ // 端午节:5月31日至6月2日放假 3 天
33
+ '05-31': 'holiday', '06-01': 'holiday', '06-02': 'holiday',
34
+ // 国庆节、中秋节:10月1日至8日放假调休 8 天;9月28日(周日)、10月11日(周六)上班
35
+ '10-01': 'holiday', '10-02': 'holiday', '10-03': 'holiday', '10-04': 'holiday',
36
+ '10-05': 'holiday', '10-06': 'holiday', '10-07': 'holiday', '10-08': 'holiday',
37
+ '09-28': 'workday', '10-11': 'workday',
38
+ },
39
+ 2026: {
40
+ // 元旦:1月1日至3日放假调休 3 天;1月4日(周日)上班
41
+ '01-01': 'holiday', '01-02': 'holiday', '01-03': 'holiday',
42
+ '01-04': 'workday',
43
+ // 春节:2月15日至23日放假调休 9 天;2月14日(周六)、2月28日(周六)上班
44
+ '02-15': 'holiday', '02-16': 'holiday', '02-17': 'holiday', '02-18': 'holiday',
45
+ '02-19': 'holiday', '02-20': 'holiday', '02-21': 'holiday', '02-22': 'holiday', '02-23': 'holiday',
46
+ '02-14': 'workday', '02-28': 'workday',
47
+ // 清明节:4月4日至6日放假 3 天
48
+ '04-04': 'holiday', '04-05': 'holiday', '04-06': 'holiday',
49
+ // 劳动节:5月1日至5日放假调休 5 天;5月9日(周六)上班
50
+ '05-01': 'holiday', '05-02': 'holiday', '05-03': 'holiday', '05-04': 'holiday', '05-05': 'holiday',
51
+ '05-09': 'workday',
52
+ // 端午节:6月19日至21日放假 3 天
53
+ '06-19': 'holiday', '06-20': 'holiday', '06-21': 'holiday',
54
+ // 中秋节:9月25日至27日放假 3 天
55
+ '09-25': 'holiday', '09-26': 'holiday', '09-27': 'holiday',
56
+ // 国庆节:10月1日至7日放假调休 7 天;9月20日(周日)、10月10日(周六)上班
57
+ '10-01': 'holiday', '10-02': 'holiday', '10-03': 'holiday', '10-04': 'holiday',
58
+ '10-05': 'holiday', '10-06': 'holiday', '10-07': 'holiday',
59
+ '09-20': 'workday', '10-10': 'workday',
60
+ },
61
+ }
62
+
63
+ /**
64
+ * 合并某年的工作日历:静态内置 + 外部覆盖(如未来在线同步的节假日数据),
65
+ * 覆盖条目优先;非法值忽略。返回该年的 { 'MM-DD': 'holiday'|'workday' } 映射。
66
+ */
67
+ export function calendarForYear(year, extra) {
68
+ const base = CN_HOLIDAYS[year] || {}
69
+ if (!extra || typeof extra !== 'object') return { ...base }
70
+ const out = { ...base }
71
+ for (const k of Object.keys(extra)) {
72
+ const v = extra[k]
73
+ if (v === 'holiday' || v === 'workday') out[k] = v
74
+ }
75
+ return out
76
+ }
77
+
78
+ /**
79
+ * 合并多年份的完整日历:{ 'YYYY': { 'MM-DD': 'holiday'|'workday' } }。
80
+ * 供 Host 定时器一次性构建后复用;extraByYear 为可选的外部覆盖(如在线同步数据)。
81
+ */
82
+ export function buildCalendar(extraByYear) {
83
+ const years = new Set([...Object.keys(CN_HOLIDAYS), ...Object.keys(extraByYear || {})])
84
+ const out = {}
85
+ for (const y of years) out[y] = calendarForYear(y, extraByYear && extraByYear[y])
86
+ return out
87
+ }
88
+
89
+ /**
90
+ * 判断某天是否为工作日(含节假日/调休语义):
91
+ * - 日历命中:'workday'(周末调休补班)→ 工作日;'holiday'(法定节假日)→ 非工作日;
92
+ * - 未命中:默认周一至周五为工作日、周六/周日为非工作日。
93
+ * @param {Date} date
94
+ * @param {object} [yearMap] 该年的 { 'MM-DD': 'holiday'|'workday' } 日历;缺省仅按星期判断
95
+ */
96
+ export function isCalendarWorkday(date, yearMap) {
97
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return false
98
+ const mm = String(date.getMonth() + 1).padStart(2, '0')
99
+ const dd = String(date.getDate()).padStart(2, '0')
100
+ const t = yearMap && yearMap[mm + '-' + dd]
101
+ if (t === 'holiday') return false // 法定节假日放假(即使工作日也不上班)
102
+ if (t === 'workday') return true // 周末调休补班(即使周末也要上班)
103
+ const dow = date.getDay()
104
+ return dow !== 0 && dow !== 6
105
+ }
106
+
107
+ /**
108
+ * 解析 timor.tech 整年节假日接口(https://timor.tech/api/holiday/year/<year>)的响应:
109
+ * { "holiday": { "MM-DD": { "holiday": true|false, "name": "…", "date": "YYYY-MM-DD" }, … } }
110
+ * holiday:true = 法定节假日放假;holiday:false = 周末调休补班。
111
+ * 返回该年 { 'MM-DD': 'holiday'|'workday' } 映射;响应结构非法/无有效条目返回 null。
112
+ */
113
+ export function parseTimorCalendarYear(json) {
114
+ if (!json || typeof json !== 'object') return null
115
+ const h = json.holiday
116
+ if (!h || typeof h !== 'object') return null
117
+ const out = {}
118
+ for (const k of Object.keys(h)) {
119
+ const v = h[k]
120
+ if (!v || typeof v !== 'object' || typeof v.holiday !== 'boolean') continue
121
+ if (!/^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(k)) continue
122
+ out[k] = v.holiday ? 'holiday' : 'workday'
123
+ }
124
+ return Object.keys(out).length > 0 ? out : null
125
+ }
126
+
127
+ /**
128
+ * 规整持久化的在线节假日缓存(Host 落盘 / 加载共用):
129
+ * { byYear: { 'YYYY': { 'MM-DD': 'holiday'|'workday' } }, fetchedAt: { 'YYYY': ts }, source }
130
+ * 非法字段丢弃、超限截断;损坏输入回退为空缓存(不影响功能——回落内置表/周一至周五)。
131
+ */
132
+ export function sanitizeCalendarCache(input) {
133
+ const out = { byYear: {}, fetchedAt: {}, source: 'timor.tech' }
134
+ if (!input || typeof input !== 'object') return out
135
+ if (typeof input.source === 'string' && input.source !== '') out.source = input.source.slice(0, 40)
136
+ const byYear = input.byYear && typeof input.byYear === 'object' ? input.byYear : {}
137
+ const fetchedAt = input.fetchedAt && typeof input.fetchedAt === 'object' ? input.fetchedAt : {}
138
+ for (const y of Object.keys(byYear)) {
139
+ if (!/^\d{4}$/.test(y)) continue
140
+ const m = byYear[y]
141
+ if (!m || typeof m !== 'object') continue
142
+ const clean = {}
143
+ for (const k of Object.keys(m)) {
144
+ const v = m[k]
145
+ if ((v === 'holiday' || v === 'workday') && /^(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(k)) {
146
+ clean[k] = v
147
+ }
148
+ }
149
+ if (Object.keys(clean).length > 0) {
150
+ out.byYear[y] = clean
151
+ out.fetchedAt[y] = Number(fetchedAt[y]) > 0 ? Number(fetchedAt[y]) : 0
152
+ }
153
+ }
154
+ return out
155
+ }