koishi-plugin-codex-reset 0.1.2 → 0.1.4

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 lumia1998
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lumia1998
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,57 +1,56 @@
1
- # koishi-plugin-codex-reset
2
-
3
- 监控 [AIHOT Codex 重置 API](https://aihot.news/api/v1/codex-resets) 的 Koishi 插件。
4
-
5
- ## 功能
6
-
7
- - 定时轮询 AIHOT 的 Codex / ChatGPT Work 重置事件快照。
8
- - 支持推送新的重置预告、已确认的全员重置和重置卡事件。
9
- - 目标支持平台 + 频道配置,例如 `onebot` + `123456789`。
10
- - `codex.probability` / `重置概率`:生成精美的概率统计卡片图片;没有 Puppeteer 时回退为纯文本。
11
- - `codex.event` / `最近重置`:统计上一次已确认重置距今多少小时。
12
- - `codex.calendar` / `重置日历`:生成本月的 PNG 日历图片,右侧详情固定为命令执行当天。
13
- - `codex.history` / `重置历史`:生成公开重置事件的历史时间线图片。
14
- - 日历中的 Tibo 头像作为本地资源随插件发布,不依赖用户访问 X 或运行时下载图片。
15
-
16
- ## 命令
17
-
18
- 命令目前都不接收额外参数:
19
-
20
- - `codex.probability`,别名 `重置概率`:读取当前 API 快照,生成概率统计卡片,展示未来 24 小时和 7 天概率、样本概览、事件构成和最近确认日期;没有 Puppeteer 时回退为纯文本。
21
- - `codex.event`,别名 `最近重置`:显示上一次已确认重置发生时间、距今小时数、类型和事件名称。
22
- - `codex.calendar`,别名 `重置日历`:发送本月日历 PNG;右侧详情固定显示命令执行当天(北京时间)的事件。
23
- - `codex.history`,别名 `重置历史`:发送按北京时间排列的历史重置时间线 PNG。
24
-
25
- 概率、日历和历史时间线命令需要安装并启用 `koishi-plugin-puppeteer` 才会发送图片;没有 Puppeteer 时概率命令仍会回退为纯文本。三个图片命令都可以在配置中单独关闭。
1
+ # koishi-plugin-codex-reset
2
+
3
+ 监控 [AIHOT Codex 重置 API](https://aihot.news/api/v1/codex-resets) 的 Koishi 插件。
4
+
5
+ ## 功能
6
+
7
+ - 定时轮询 AIHOT 的 Codex / ChatGPT Work 重置事件快照。
8
+ - 支持推送新的重置预告、已确认的全员重置和重置卡事件。
9
+ - 目标支持平台 + 频道配置,例如 `onebot` + `123456789`。
10
+ - `codex.probability` / `重置概率`:生成精美的概率统计卡片图片;没有 Puppeteer 时回退为纯文本。
11
+ - `codex.event` / `最近重置`:生成最近一次已确认重置的浅色卡片,展示距今时间、类型和来源帖子。
12
+ - `codex.calendar` / `重置日历`:生成本月的 PNG 日历图片,右侧固定展示最近发布的 2 条来源动态。
13
+ - `codex.history` / `重置历史`:生成公开重置事件的历史时间线图片,默认展示最近 1 个月。
14
+ - 日历中的 Tibo 头像作为本地资源随插件发布,不依赖用户访问 X 或运行时下载图片。
15
+
16
+ ## 命令
17
+
18
+ 命令都可以直接使用;历史时间线支持时间范围参数:
19
+
20
+ - `codex.probability`,别名 `重置概率`:读取当前 API 快照,生成概率统计卡片,展示未来 24 小时和 7 天概率、样本概览、事件构成和最近确认日期;没有 Puppeteer 时回退为纯文本。
21
+ - `codex.event`,别名 `最近重置`:发送最近一次已确认重置的浅色卡片;没有 Puppeteer 时回退为文本。
22
+ - `codex.calendar`,别名 `重置日历`:发送本月日历 PNG;右侧固定显示最近发布的 2 条动态。
23
+ - `codex.history`,别名 `重置历史`:发送历史时间线 PNG;`重置历史` 查看最近 1 个月,`重置历史 -3` 查看最近 3 个月,`重置历史 -a` 查看全部历史。
24
+
25
+ 概率、最近重置、日历和历史时间线命令始终启用;安装并启用 `koishi-plugin-puppeteer` 后会发送对应图片,没有 Puppeteer 时概率和最近重置会回退为纯文本,日历和历史时间线会提示安装 Puppeteer。
26
+
27
+ 日历、月度统计及右侧动态仅展示已确认且日期不晚于今天的事件,不展示预告。日期优先使用实际发生日期,缺失时使用北京时间的确认日期;两者均缺失时不展示,不使用预计时间窗口代替实际日期。
26
28
 
27
29
  首次轮询只建立本地基线,不会把 API 返回的历史事件全部推送;后续轮询只处理新增事件或 `announced` → `confirmed` 的状态变化。插件重启后会重新建立基线。
28
-
29
- ## 安装
30
-
31
- ```bash
32
- npm install koishi-plugin-codex-reset
33
- ```
34
-
35
- 如果需要概率卡片、日历或历史时间线图片,再安装并启用 `koishi-plugin-puppeteer`。不安装 Puppeteer 时,监控、最近重置和概率分析仍然可以使用,概率命令回退为纯文本,其他图片命令会给出提示。
36
-
37
- ## 配置
38
-
39
- - `监控接口地址`:监控 API 地址,默认 `https://aihot.news/api/v1/codex-resets`。
40
- - `监控间隔`:轮询间隔,单位为秒,默认 300 秒。
41
- - `请求超时时间`:单次 API 请求超时时间,单位为毫秒,默认 15000 毫秒。
42
- - `推送目标`:推送目标表,填写“平台”和“频道或群组 ID”;平台留空会按频道尝试在线 Bot,同一平台/频道只发送一次。
43
- - `推送重置预告`:是否推送预告。
44
- - `推送已确认重置`:是否推送已确认的重置/重置卡事件。
45
- - `艾特全体`:推送通知时是否艾特全体,默认关闭;需要目标平台支持全体艾特。
46
- - `启用重置概率命令`:是否启用概率命令。
47
- - `启用重置日历命令`:是否启用日历图片命令。
48
- - `启用重置历史命令`:是否启用历史时间线图片命令。
49
-
50
- API 是只读公开接口,不需要 API Key。插件使用 `ETag` 条件请求,并保留来源链接。请遵守 [AIHOT 公开使用规则](https://aihot.news/terms):公开可访问不等于自动获得对外商业使用、镜像或转售授权。
51
-
52
- ## 开发
53
-
54
- ```bash
55
- npm install
56
- npm test
57
- ```
30
+
31
+ ## 安装
32
+
33
+ ```bash
34
+ npm install koishi-plugin-codex-reset
35
+ ```
36
+
37
+ 如果需要概率卡片、最近重置卡片、日历或历史时间线图片,再安装并启用 `koishi-plugin-puppeteer`。不安装 Puppeteer 时,监控、最近重置和概率分析仍然可以使用,概率与最近重置命令回退为纯文本,日历和历史命令会给出提示。
38
+
39
+ ## 配置
40
+
41
+ - `监控接口地址`:监控 API 地址,默认 `https://aihot.news/api/v1/codex-resets`。
42
+ - `监控间隔`:轮询间隔,单位为秒,默认 300 秒。
43
+ - `请求超时时间`:单次 API 请求超时时间,单位为毫秒,默认 15000 毫秒。
44
+ - `推送目标`:推送目标表,填写“平台”和“频道或群组 ID”;平台留空会按频道尝试在线 Bot,同一平台/频道只发送一次。
45
+ - `推送重置预告`:是否推送预告。
46
+ - `推送已确认重置`:是否推送已确认的重置/重置卡事件。
47
+ - `艾特全体`:推送通知时是否艾特全体,默认关闭;需要目标平台支持全体艾特。
48
+
49
+ API 是只读公开接口,不需要 API Key。插件使用 `ETag` 条件请求,并保留来源链接。请遵守 [AIHOT 公开使用规则](https://aihot.news/terms):公开可访问不等于自动获得对外商业使用、镜像或转售授权。
50
+
51
+ ## 开发
52
+
53
+ ```bash
54
+ npm install
55
+ npm test
56
+ ```
package/lib/calendar.js CHANGED
@@ -45,7 +45,8 @@ function parseDateKey(value) {
45
45
  function timestampDateKey(value) {
46
46
  if (!value)
47
47
  return null;
48
- return value.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? null;
48
+ const date = new Date(value);
49
+ return Number.isFinite(date.getTime()) ? beijingDateKey(date) : null;
49
50
  }
50
51
  function beijingDateKey(now) {
51
52
  const parts = new Intl.DateTimeFormat('en-US', {
@@ -69,13 +70,10 @@ function escapeHtml(value) {
69
70
  .replaceAll("'", ''');
70
71
  }
71
72
  function getEventDateRange(event) {
72
- if (event.status === 'confirmed' && event.occurredOn)
73
- return [event.occurredOn, event.occurredOn];
74
- const from = timestampDateKey(event.schedule?.from) ?? timestampDateKey(event.confirmedAt) ?? timestampDateKey(event.createdAt);
75
- if (!from)
73
+ if (event.status !== 'confirmed')
76
74
  return null;
77
- const through = timestampDateKey(event.schedule?.through) ?? from;
78
- return [from, through];
75
+ const date = event.occurredOn ?? timestampDateKey(event.confirmedAt);
76
+ return date ? [date, date] : null;
79
77
  }
80
78
  function buildMonthMarks(events, start) {
81
79
  const end = monthStart(start, 1);
@@ -98,15 +96,6 @@ function buildMonthMarks(events, start) {
98
96
  }
99
97
  return marks;
100
98
  }
101
- function formatDateHeader(value) {
102
- const date = parseDateKey(value);
103
- if (!date)
104
- return { day: value, year: '' };
105
- return {
106
- day: `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日`,
107
- year: String(date.getUTCFullYear()),
108
- };
109
- }
110
99
  function formatPublishedAt(value) {
111
100
  const date = new Date(value);
112
101
  if (Number.isNaN(date.getTime()))
@@ -142,10 +131,10 @@ function renderDayCell(date, month, marks, selectedDate, today) {
142
131
  const className = mark.type === 'direct_reset' ? 'reset-mark' : 'credit-mark';
143
132
  return `<span class="${className}">${label}</span>`;
144
133
  }).join('');
145
- return `<div class="${classes.join(' ')}" aria-label="${escapeHtml(labels)}">
146
- <span class="number">${date.getUTCDate()}</span>
147
- ${key === today ? '<span class="today-dot"></span>' : ''}
148
- <div class="marks">${marksHtml}</div>
134
+ return `<div class="${classes.join(' ')}" aria-label="${escapeHtml(labels)}">
135
+ <span class="number">${date.getUTCDate()}</span>
136
+ ${key === today ? '<span class="today-dot"></span>' : ''}
137
+ <div class="marks">${marksHtml}</div>
149
138
  </div>`;
150
139
  }
151
140
  function renderCalendarGrid(month, marks, selectedDate, today) {
@@ -162,126 +151,129 @@ function renderCalendarGrid(month, marks, selectedDate, today) {
162
151
  }
163
152
  function renderPostCard(event, post) {
164
153
  const text = post.text || post.originalText;
165
- const schedule = event.schedule?.label ? `<div class="schedule">原预告:${escapeHtml(event.schedule.label)}</div>` : '';
166
154
  const avatar = TIBO_AVATAR
167
155
  ? `<img class="avatar-image" src="${TIBO_AVATAR}" alt="">`
168
156
  : '<span class="avatar-fallback">T</span>';
169
- return `<article class="post-card">
170
- <div class="post-head">
171
- <div class="avatar">${avatar}</div>
172
- <div class="author"><strong>Tibo</strong><span>@thsottiaux</span></div>
173
- <time>${escapeHtml(formatPublishedAt(post.publishedAt))}</time>
174
- </div>
175
- <p class="post-text">${escapeHtml(text)}</p>
176
- ${schedule}
177
- <div class="post-foot">${escapeHtml(post.stage)}</div>
157
+ return `<article class="post-card">
158
+ <div class="post-head">
159
+ <div class="avatar">${avatar}</div>
160
+ <div class="author"><strong>Tibo</strong><span>@thsottiaux</span></div>
161
+ <time>${escapeHtml(formatPublishedAt(post.publishedAt))}</time>
162
+ </div>
163
+ <p class="post-text">${escapeHtml(text)}</p>
164
+ <div class="post-foot">${escapeHtml(post.stage)}</div>
178
165
  </article>`;
179
166
  }
180
- function renderDetails(selectedDate, marks) {
181
- const header = formatDateHeader(selectedDate);
182
- const selectedEvents = [...new Map((marks.get(selectedDate) ?? []).map((mark) => [mark.event.id, mark.event])).values()];
183
- const groups = selectedEvents.map((event) => {
167
+ function renderDetails(events) {
168
+ const latestPosts = events
169
+ .flatMap((event) => event.posts.map((post) => ({ event, post })))
170
+ .sort((a, b) => Date.parse(b.post.publishedAt) - Date.parse(a.post.publishedAt))
171
+ .slice(0, 2);
172
+ const groups = latestPosts.map(({ event, post }) => {
184
173
  const status = event.status === 'confirmed' ? '已重置' : '未重置预告';
185
174
  const statusClass = event.status === 'confirmed' ? 'confirmed-state' : 'announced-state';
186
- const posts = event.posts.slice(0, 3).map((post) => renderPostCard(event, post)).join('');
187
- return `<section class="event-group">
188
- <div class="event-state ${statusClass}"><span class="state-icon">${event.status === 'confirmed' ? '✓' : '◷'}</span>${status}</div>
189
- <h2>${escapeHtml(event.title)}</h2>
190
- ${posts || '<div class="post-card empty-post">暂无来源帖子内容</div>'}
175
+ return `<section class="event-group">
176
+ <div class="event-state ${statusClass}"><span class="state-icon">${event.status === 'confirmed' ? '✓' : '◷'}</span>${status}</div>
177
+ <h2>${escapeHtml(event.title)}</h2>
178
+ ${renderPostCard(event, post)}
191
179
  </section>`;
192
180
  }).join('');
193
- return `<aside class="details-pane">
194
- <div class="details-header"><h2>${header.day}</h2><span>${header.year}</span></div>
195
- <div class="details-body">${groups || '<div class="empty-state">这一天没有记录</div>'}</div>
181
+ return `<aside class="details-pane">
182
+ <div class="details-header"><h2>最近 2 条动态</h2><span>北京时间</span></div>
183
+ <div class="details-body">${groups || '<div class="empty-state">暂无来源帖子内容</div>'}</div>
196
184
  </aside>`;
197
185
  }
198
186
  function renderCalendarHtml(events, now = new Date()) {
199
187
  const today = beijingDateKey(now);
188
+ events = events.filter((event) => {
189
+ const range = getEventDateRange(event);
190
+ return range !== null && parseDateKey(range[0]) !== null && range[0] <= today;
191
+ });
200
192
  const todayDate = parseDateKey(today) ?? new Date();
201
193
  const month = monthStart(todayDate);
202
194
  const marks = buildMonthMarks(events, month);
203
- // A static image has no interactive selection. The detail pane always represents
204
- // the Beijing calendar date on which the command was invoked.
195
+ // The selected day remains the command date on the calendar grid, while the
196
+ // detail pane shows the two most recently published source posts.
205
197
  const selectedDate = today;
206
198
  const monthEvents = [...new Map([...marks.values()].flat().map((mark) => [mark.event.id, mark.event])).values()];
207
199
  const resetCount = monthEvents.filter((event) => event.type === 'direct_reset').length;
208
200
  const creditCount = monthEvents.filter((event) => event.type === 'reset_credit').length;
209
201
  const monthLabel = `${month.getUTCFullYear()} 年 ${MONTH_NAMES[month.getUTCMonth()]}`;
210
- return `<!doctype html>
211
- <html lang="zh-CN">
212
- <head>
213
- <meta charset="utf-8">
214
- <style>
215
- * { box-sizing: border-box; }
216
- body { margin: 0; padding: 16px; background: #f5f6f4; color: #25313a; font-family: Inter, "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; }
217
- .calendar-card { width: 1248px; min-height: 578px; display: grid; grid-template-columns: 1.46fr 1fr; overflow: hidden; border: 1px solid #d2dadd; border-radius: 14px; background: #fff; }
218
- .calendar-pane { padding: 30px 24px 22px; }
219
- .calendar-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 0 0 18px; }
220
- .calendar-header h1 { margin: 0; font-size: 23px; line-height: 1.3; letter-spacing: .2px; }
221
- .month-summary { margin-top: 20px; color: #617079; font-size: 12px; }
222
- .calendar-nav { display: flex; align-items: center; gap: 22px; padding-top: 3px; color: #617079; font-size: 11px; }
223
- .calendar-nav .arrow { color: #617079; font-size: 20px; font-weight: 300; line-height: 1; }
224
- .weekday-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
225
- .weekday-row { margin-bottom: 6px; color: #65737b; font-size: 12px; text-align: center; }
226
- .weekday-row span { padding: 8px 0; }
227
- .calendar-grid { grid-auto-rows: 70px; }
228
- .day { position: relative; min-width: 0; padding: 11px 12px 7px; border: 1px solid transparent; border-radius: 10px; background: #fafafa; }
229
- .day.outside { color: #adb6b9; background: #fcfcfc; }
230
- .day.selected { border-color: #147487; background: #edf7f6; box-shadow: inset 0 0 0 1px rgba(20, 116, 135, .12); }
231
- .day.has-reset { background: #eef6f2; }
232
- .day.has-credit { background: #f7f3ec; }
233
- .day.selected.has-reset, .day.selected.has-credit { background: #e8f3f2; }
234
- .number { display: inline-block; font-size: 14px; line-height: 1; }
235
- .outside .number { color: #aeb7ba; }
236
- .today-dot { position: absolute; top: 11px; right: 13px; width: 4px; height: 4px; border-radius: 50%; background: #147487; }
237
- .marks { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; overflow: hidden; }
238
- .marks span { overflow: hidden; color: #347465; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
239
- .marks .credit-mark { color: #967044; }
240
- .outside .marks span { color: #b6d0c9; }
241
- .outside .marks .credit-mark { color: #cbbda6; }
242
- .calendar-note { margin-top: 18px; color: #617079; font-size: 11px; }
243
- .details-pane { min-width: 0; padding: 26px 24px 20px; border-left: 1px solid #d9dfe0; }
244
- .details-header { display: flex; justify-content: space-between; align-items: center; padding: 0 0 17px; border-bottom: 1px solid #d9dfe0; }
245
- .details-header h2 { margin: 0; font-size: 18px; }
246
- .details-header span { color: #65737b; font-size: 11px; }
247
- .details-body { padding-top: 18px; }
248
- .event-group + .event-group { margin-top: 22px; }
249
- .event-state { display: flex; align-items: center; gap: 7px; margin-bottom: 12px; font-size: 11px; font-weight: 700; }
250
- .confirmed-state { color: #29735f; }
251
- .announced-state { color: #98713d; }
252
- .state-icon { font-size: 16px; font-weight: 500; }
253
- .event-group h2 { margin: 0 0 16px; font-size: 18px; line-height: 1.35; }
254
- .post-card { margin-bottom: 12px; padding: 16px; border: 1px solid #d2dadd; border-radius: 13px; background: #fff; }
255
- .post-head { display: flex; align-items: center; min-width: 0; }
256
- .avatar { display: grid; flex: 0 0 auto; width: 36px; height: 36px; place-items: center; overflow: hidden; border-radius: 50%; background: linear-gradient(145deg, #a57954, #d3aa7d); color: #fff; font-size: 18px; font-weight: 700; }
257
- .avatar-image { display: block; width: 100%; height: 100%; object-fit: cover; }
258
- .avatar-fallback { display: block; }
259
- .author { display: flex; flex-direction: column; margin-left: 9px; line-height: 1.25; }
260
- .author strong { font-size: 13px; }
261
- .author span { color: #6f7d83; font-size: 10px; }
262
- .post-head time { margin-left: auto; color: #6f7d83; font-size: 10px; }
263
- .post-text { margin: 14px 0 10px; color: #27343c; font-size: 14px; line-height: 1.65; }
264
- .schedule { margin: 0 0 10px; color: #08768b; font-size: 11px; }
265
- .post-foot { color: #6f7d83; font-size: 10px; }
266
- .empty-state, .empty-post { color: #7a888d; font-size: 13px; text-align: center; }
267
- .empty-state { padding: 56px 0; }
268
- .empty-post { padding: 24px 12px; }
269
- </style>
270
- </head>
271
- <body>
272
- <main class="calendar-card">
273
- <section class="calendar-pane">
274
- <header class="calendar-header">
275
- <div><h1>${monthLabel}</h1><div class="month-summary">本月:${resetCount} 条全员重置&nbsp;&nbsp;·&nbsp;&nbsp;${creditCount} 张重置卡</div></div>
276
- <div class="calendar-nav"><span>回到最近</span><span class="arrow">‹</span><span class="arrow">›</span></div>
277
- </header>
278
- <div class="weekday-row">${WEEK_NAMES.map((name) => `<span>${name}</span>`).join('')}</div>
279
- <div class="calendar-grid">${renderCalendarGrid(month, marks, selectedDate, today)}</div>
280
- <div class="calendar-note">按北京时间记录;发放日期不明确的事件,按来源帖子日期展示。</div>
281
- </section>
282
- ${renderDetails(selectedDate, marks)}
283
- </main>
284
- </body>
202
+ return `<!doctype html>
203
+ <html lang="zh-CN">
204
+ <head>
205
+ <meta charset="utf-8">
206
+ <style>
207
+ * { box-sizing: border-box; }
208
+ body { margin: 0; padding: 16px; background: #f5f6f4; color: #25313a; font-family: Inter, "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; }
209
+ .calendar-card { width: 1248px; min-height: 578px; display: grid; grid-template-columns: 1.46fr 1fr; overflow: hidden; border: 1px solid #d2dadd; border-radius: 14px; background: #fff; }
210
+ .calendar-pane { padding: 30px 24px 22px; }
211
+ .calendar-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 0 0 18px; }
212
+ .calendar-header h1 { margin: 0; font-size: 23px; line-height: 1.3; letter-spacing: .2px; }
213
+ .month-summary { margin-top: 20px; color: #617079; font-size: 12px; }
214
+ .calendar-nav { display: flex; align-items: center; gap: 22px; padding-top: 3px; color: #617079; font-size: 11px; }
215
+ .calendar-nav .arrow { color: #617079; font-size: 20px; font-weight: 300; line-height: 1; }
216
+ .weekday-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
217
+ .weekday-row { margin-bottom: 6px; color: #65737b; font-size: 12px; text-align: center; }
218
+ .weekday-row span { padding: 8px 0; }
219
+ .calendar-grid { grid-auto-rows: 70px; }
220
+ .day { position: relative; min-width: 0; padding: 11px 12px 7px; border: 1px solid transparent; border-radius: 10px; background: #fafafa; }
221
+ .day.outside { color: #adb6b9; background: #fcfcfc; }
222
+ .day.selected { border-color: #147487; background: #edf7f6; box-shadow: inset 0 0 0 1px rgba(20, 116, 135, .12); }
223
+ .day.has-reset { background: #eef6f2; }
224
+ .day.has-credit { background: #f7f3ec; }
225
+ .day.selected.has-reset, .day.selected.has-credit { background: #e8f3f2; }
226
+ .number { display: inline-block; font-size: 14px; line-height: 1; }
227
+ .outside .number { color: #aeb7ba; }
228
+ .today-dot { position: absolute; top: 11px; right: 13px; width: 4px; height: 4px; border-radius: 50%; background: #147487; }
229
+ .marks { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; overflow: hidden; }
230
+ .marks span { overflow: hidden; color: #347465; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
231
+ .marks .credit-mark { color: #967044; }
232
+ .outside .marks span { color: #b6d0c9; }
233
+ .outside .marks .credit-mark { color: #cbbda6; }
234
+ .calendar-note { margin-top: 18px; color: #617079; font-size: 11px; }
235
+ .details-pane { min-width: 0; padding: 26px 24px 20px; border-left: 1px solid #d9dfe0; }
236
+ .details-header { display: flex; justify-content: space-between; align-items: center; padding: 0 0 17px; border-bottom: 1px solid #d9dfe0; }
237
+ .details-header h2 { margin: 0; font-size: 18px; }
238
+ .details-header span { color: #65737b; font-size: 11px; }
239
+ .details-body { padding-top: 18px; }
240
+ .event-group + .event-group { margin-top: 22px; }
241
+ .event-state { display: flex; align-items: center; gap: 7px; margin-bottom: 12px; font-size: 11px; font-weight: 700; }
242
+ .confirmed-state { color: #29735f; }
243
+ .announced-state { color: #98713d; }
244
+ .state-icon { font-size: 16px; font-weight: 500; }
245
+ .event-group h2 { margin: 0 0 16px; font-size: 18px; line-height: 1.35; }
246
+ .post-card { margin-bottom: 12px; padding: 16px; border: 1px solid #d2dadd; border-radius: 13px; background: #fff; }
247
+ .post-head { display: flex; align-items: center; min-width: 0; }
248
+ .avatar { display: grid; flex: 0 0 auto; width: 36px; height: 36px; place-items: center; overflow: hidden; border-radius: 50%; background: linear-gradient(145deg, #a57954, #d3aa7d); color: #fff; font-size: 18px; font-weight: 700; }
249
+ .avatar-image { display: block; width: 100%; height: 100%; object-fit: cover; }
250
+ .avatar-fallback { display: block; }
251
+ .author { display: flex; flex-direction: column; margin-left: 9px; line-height: 1.25; }
252
+ .author strong { font-size: 13px; }
253
+ .author span { color: #6f7d83; font-size: 10px; }
254
+ .post-head time { margin-left: auto; color: #6f7d83; font-size: 10px; }
255
+ .post-text { margin: 14px 0 10px; color: #27343c; font-size: 14px; line-height: 1.65; }
256
+ .schedule { margin: 0 0 10px; color: #08768b; font-size: 11px; }
257
+ .post-foot { color: #6f7d83; font-size: 10px; }
258
+ .empty-state, .empty-post { color: #7a888d; font-size: 13px; text-align: center; }
259
+ .empty-state { padding: 56px 0; }
260
+ .empty-post { padding: 24px 12px; }
261
+ </style>
262
+ </head>
263
+ <body>
264
+ <main class="calendar-card">
265
+ <section class="calendar-pane">
266
+ <header class="calendar-header">
267
+ <div><h1>${monthLabel}</h1><div class="month-summary">本月:${resetCount} 条全员重置&nbsp;&nbsp;·&nbsp;&nbsp;${creditCount} 张重置卡</div></div>
268
+ <div class="calendar-nav"><span>回到最近</span><span class="arrow">‹</span><span class="arrow">›</span></div>
269
+ </header>
270
+ <div class="weekday-row">${WEEK_NAMES.map((name) => `<span>${name}</span>`).join('')}</div>
271
+ <div class="calendar-grid">${renderCalendarGrid(month, marks, selectedDate, today)}</div>
272
+ <div class="calendar-note">仅显示已确认事件;按北京时间记录实际发生日期,缺失时按确认日期展示;不含预告。</div>
273
+ </section>
274
+ ${renderDetails(events)}
275
+ </main>
276
+ </body>
285
277
  </html>`;
286
278
  }
287
279
  async function renderCalendarImage(browser, events, now = new Date()) {
package/lib/event.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { CalendarBrowser } from './calendar';
2
+ import type { LatestResetSummary } from './stats';
3
+ export declare function renderLatestResetHtml(summary: LatestResetSummary, now?: Date): string;
4
+ export declare function renderLatestResetImage(browser: CalendarBrowser, summary: LatestResetSummary, now?: Date): Promise<Buffer>;
package/lib/event.js ADDED
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderLatestResetHtml = renderLatestResetHtml;
4
+ exports.renderLatestResetImage = renderLatestResetImage;
5
+ const calendar_1 = require("./calendar");
6
+ function escapeHtml(value) {
7
+ return value
8
+ .replaceAll('&', '&amp;')
9
+ .replaceAll('<', '&lt;')
10
+ .replaceAll('>', '&gt;')
11
+ .replaceAll('"', '&quot;')
12
+ .replaceAll("'", '&#39;');
13
+ }
14
+ function formatDateTime(timestamp) {
15
+ return new Intl.DateTimeFormat('zh-CN', {
16
+ timeZone: 'Asia/Shanghai',
17
+ year: 'numeric',
18
+ month: 'numeric',
19
+ day: 'numeric',
20
+ hour: '2-digit',
21
+ minute: '2-digit',
22
+ hour12: false,
23
+ }).format(new Date(timestamp));
24
+ }
25
+ function formatElapsed(timestamp, now) {
26
+ const elapsedHours = Math.max(0, (now.getTime() - timestamp) / (60 * 60 * 1000));
27
+ if (elapsedHours < 1)
28
+ return `${Math.max(1, Math.round(elapsedHours * 60))} 分钟前`;
29
+ if (elapsedHours < 24)
30
+ return `${Math.round(elapsedHours * 10) / 10} 小时前`;
31
+ return `${Math.round(elapsedHours / 24 * 10) / 10} 天前`;
32
+ }
33
+ function typeLabel(summary) {
34
+ return summary.event.type === 'direct_reset' ? '全员重置' : '重置卡';
35
+ }
36
+ function latestPost(summary) {
37
+ return [...summary.event.posts]
38
+ .sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt))[0] ?? null;
39
+ }
40
+ function renderSourcePost(summary) {
41
+ const post = latestPost(summary);
42
+ if (!post)
43
+ return '<div class="post-empty">暂无来源帖子内容</div>';
44
+ const avatar = (0, calendar_1.getTiboAvatarDataUri)()
45
+ ? `<img class="avatar-image" src="${(0, calendar_1.getTiboAvatarDataUri)()}" alt="">`
46
+ : '<span class="avatar-fallback">T</span>';
47
+ const text = post.text || post.originalText;
48
+ const publishedAt = Date.parse(post.publishedAt);
49
+ const time = Number.isFinite(publishedAt) ? formatDateTime(publishedAt) : '时间未知';
50
+ return `<article class="post-card">
51
+ <div class="post-head">
52
+ <div class="avatar">${avatar}</div>
53
+ <div class="author"><strong>Tibo</strong><span>@thsottiaux</span></div>
54
+ <time>${escapeHtml(time)} 发布</time>
55
+ </div>
56
+ <p class="post-text">${escapeHtml(text)}</p>
57
+ <div class="post-foot">${escapeHtml(post.stage)}</div>
58
+ </article>`;
59
+ }
60
+ function renderLatestResetHtml(summary, now = new Date()) {
61
+ const approximate = summary.approximate ? '时间按公开日期估算' : '已记录精确确认时间';
62
+ const event = summary.event;
63
+ return `<!doctype html>
64
+ <html lang="zh-CN">
65
+ <head>
66
+ <meta charset="utf-8">
67
+ <style>
68
+ * { box-sizing: border-box; }
69
+ html, body { margin: 0; padding: 0; background: #f4f6f5; color: #27343c; font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif; }
70
+ body { padding: 24px; }
71
+ .card { position: relative; display: grid; grid-template-columns: .92fr 1.08fr; width: 1120px; min-height: 520px; overflow: hidden; border: 1px solid #d2dadd; border-radius: 16px; background: #fff; box-shadow: 0 16px 38px rgba(42, 58, 64, .10); }
72
+ .card::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle at 7% 12%, rgba(8, 118, 139, .11), transparent 34%), radial-gradient(circle at 95% 96%, rgba(184, 134, 59, .10), transparent 34%); }
73
+ .overview, .source { position: relative; z-index: 1; padding: 38px 40px; }
74
+ .overview { display: flex; flex-direction: column; border-right: 1px solid #d9dfe0; background: linear-gradient(145deg, rgba(237, 247, 246, .8), rgba(255, 255, 255, .96) 64%); }
75
+ .source { background: linear-gradient(160deg, rgba(250, 246, 238, .68), rgba(255, 255, 255, .98) 55%); }
76
+ .eyebrow { color: #08768b; font-size: 11px; font-weight: 700; letter-spacing: .18em; }
77
+ .source .eyebrow { color: #a27831; }
78
+ h1 { margin: 18px 0 7px; color: #27343c; font-size: 31px; letter-spacing: -.04em; }
79
+ .subtitle { margin: 0; color: #6f7d83; font-size: 13px; line-height: 1.6; }
80
+ .elapsed { margin-top: 40px; color: #08768b; font-family: Georgia, "Times New Roman", serif; font-size: 54px; font-weight: 700; letter-spacing: -.06em; line-height: 1; }
81
+ .elapsed-label { margin-top: 8px; color: #617079; font-size: 12px; }
82
+ .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: auto; padding-top: 32px; }
83
+ .fact { padding: 13px 14px; border: 1px solid #c8dedd; border-radius: 11px; background: rgba(255, 255, 255, .72); }
84
+ .fact-label { color: #6f7d83; font-size: 10px; }
85
+ .fact-value { margin-top: 5px; color: #27343c; font-size: 14px; font-weight: 700; }
86
+ .note { margin-top: 16px; color: #7a888d; font-size: 10px; line-height: 1.6; }
87
+ .source-header { display: flex; align-items: flex-start; justify-content: space-between; padding-bottom: 17px; border-bottom: 1px solid #e0e5e6; }
88
+ .source-header h2 { margin: 0; color: #27343c; font-size: 19px; }
89
+ .source-header span { color: #6f7d83; font-size: 10px; }
90
+ .event-title { margin: 25px 0 19px; color: #27343c; font-size: 22px; line-height: 1.4; }
91
+ .post-card { padding: 18px; border: 1px solid #d2dadd; border-radius: 13px; background: rgba(255, 255, 255, .86); }
92
+ .post-head { display: flex; align-items: center; min-width: 0; }
93
+ .avatar { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; overflow: hidden; border-radius: 50%; background: linear-gradient(145deg, #a57954, #d3aa7d); color: #fff; font-size: 19px; font-weight: 700; }
94
+ .avatar-image { display: block; width: 100%; height: 100%; object-fit: cover; }
95
+ .avatar-fallback { display: block; }
96
+ .author { display: flex; flex-direction: column; margin-left: 10px; line-height: 1.3; }
97
+ .author strong { font-size: 14px; }
98
+ .author span { color: #6f7d83; font-size: 10px; }
99
+ .post-head time { margin-left: auto; color: #6f7d83; font-size: 10px; }
100
+ .post-text { display: -webkit-box; margin: 19px 0 15px; overflow: hidden; color: #27343c; font-size: 15px; line-height: 1.7; -webkit-box-orient: vertical; -webkit-line-clamp: 5; }
101
+ .post-foot { color: #6f7d83; font-size: 10px; }
102
+ .post-empty { padding: 45px 20px; border: 1px dashed #d2dadd; border-radius: 13px; color: #7a888d; font-size: 13px; text-align: center; }
103
+ .source-note { margin-top: 17px; color: #7a888d; font-size: 10px; line-height: 1.6; }
104
+ .mark { margin-top: 34px; color: #a27831; font-size: 10px; font-weight: 700; letter-spacing: .12em; }
105
+ </style>
106
+ </head>
107
+ <body>
108
+ <main class="card">
109
+ <section class="overview">
110
+ <div class="eyebrow">CODEX RESET / LATEST</div>
111
+ <h1>最近重置</h1>
112
+ <p class="subtitle">AIHOT 公开快照中的最近一次已确认事件</p>
113
+ <div class="elapsed">${escapeHtml(formatElapsed(summary.timestamp, now))}</div>
114
+ <div class="elapsed-label">距离这次重置已经过去</div>
115
+ <div class="facts">
116
+ <div class="fact"><div class="fact-label">发生时间</div><div class="fact-value">${escapeHtml(formatDateTime(summary.timestamp))}</div></div>
117
+ <div class="fact"><div class="fact-label">事件类型</div><div class="fact-value">${typeLabel(summary)}</div></div>
118
+ </div>
119
+ <p class="note">${escapeHtml(approximate)}。数据来自 AIHOT 当前历史快照,不代表你的账户一定获得重置。</p>
120
+ </section>
121
+ <section class="source">
122
+ <header class="source-header"><h2>事件来源</h2><span>北京时间</span></header>
123
+ <h2 class="event-title">${escapeHtml(event.title)}</h2>
124
+ ${renderSourcePost(summary)}
125
+ <p class="source-note">${escapeHtml(event.scope ? `范围:${event.scope}` : '公开来源帖子记录')}</p>
126
+ <div class="mark">RESET MONITOR</div>
127
+ </section>
128
+ </main>
129
+ </body>
130
+ </html>`;
131
+ }
132
+ async function renderLatestResetImage(browser, summary, now = new Date()) {
133
+ let page = null;
134
+ try {
135
+ page = await browser.page();
136
+ await page.setViewport({ width: 1120, height: 570, deviceScaleFactor: 1 });
137
+ await page.setContent(renderLatestResetHtml(summary, now), { waitUntil: 'domcontentloaded', timeout: 15000 });
138
+ const image = await page.screenshot({ type: 'png', fullPage: true });
139
+ return Buffer.from(image);
140
+ }
141
+ finally {
142
+ if (page)
143
+ await page.close();
144
+ }
145
+ }
package/lib/history.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { CodexResetEvent } from './types';
2
2
  import { CalendarBrowser } from './calendar';
3
+ export type HistoryRange = number | 'all';
4
+ export declare function filterHistoryEvents(events: CodexResetEvent[], now?: Date, range?: HistoryRange): CodexResetEvent[];
3
5
  export declare function renderHistoryHtml(events: CodexResetEvent[], now?: Date): string;
4
6
  export declare function renderHistoryImage(browser: CalendarBrowser, events: CodexResetEvent[], now?: Date): Promise<Buffer>;