diff --git a/logs/backup.log b/logs/backup.log
index 23f06a4..01e88b8 100644
--- a/logs/backup.log
+++ b/logs/backup.log
@@ -405,3 +405,11 @@ To https://git.valavala.com/ai_member_only/ai_member_xiaoban
411969e..906af79 master -> master
[2026-05-23 08:10:02] 工作区备份成功:自动备份 2026-05-23 08:10:01
[2026-05-24 08:10:01] 开始备份工作区...
+[master e279bc7] 自动备份 2026-05-24 08:10:01
+ 2 files changed, 1 insertion(+), 3 deletions(-)
+ delete mode 100644 tmp_daily_summary.md
+remote: . Processing 1 references
+remote: Processed 1 references in total
+To https://git.valavala.com/ai_member_only/ai_member_xiaoban
+ 906af79..e279bc7 master -> master
+[2026-05-24 08:10:02] 工作区备份成功:自动备份 2026-05-24 08:10:01
diff --git a/memory/.dreams/events.jsonl b/memory/.dreams/events.jsonl
new file mode 100644
index 0000000..542452f
--- /dev/null
+++ b/memory/.dreams/events.jsonl
@@ -0,0 +1 @@
+{"type":"memory.recall.recorded","timestamp":"2026-05-24T02:48:04.923Z","query":"PostgreSQL online database password PG_DB_PASSWORD","resultCount":4,"results":[{"path":"memory/2026-05-24.md","startLine":1,"endLine":30,"score":1},{"path":"memory/2026-05-24.md","startLine":23,"endLine":52,"score":1},{"path":"memory/2026-03-01.md","startLine":1,"endLine":11,"score":1},{"path":"memory/2026-05-24.md","startLine":46,"endLine":61,"score":1}]}
diff --git a/memory/.dreams/short-term-recall.json b/memory/.dreams/short-term-recall.json
new file mode 100644
index 0000000..1f6be32
--- /dev/null
+++ b/memory/.dreams/short-term-recall.json
@@ -0,0 +1,130 @@
+{
+ "version": 1,
+ "updatedAt": "2026-05-24T02:48:04.923Z",
+ "entries": {
+ "memory:memory/2026-05-24.md:1:30": {
+ "key": "memory:memory/2026-05-24.md:1:30",
+ "path": "memory/2026-05-24.md",
+ "startLine": 1,
+ "endLine": 30,
+ "source": "memory",
+ "snippet": "# 2026-05-24 工作日志 ## 新建技能: studytime-analysis [刘庆逊提出] 创建学习时间分析技能,分析角色完课记录的规律。 ### 技能结构 - `skills/studytime-analysis/SKILL.md` — 技能定义 - `skills/studytime-analysis/scripts/studytime_analysis.py` — Python 分析脚本 ### 分析维度 1. **一周时间分布**(排除寒暑假1-2月、7-8月):周一~周日各天完课数、时段分布(上午/中午/下午/晚上)、周末是否上课 2. **跨周学习趋势**(包含寒暑假全部数据):总周数、周均完课数、连续性、中断周检测、前后半段趋势对比、突增/骤降检测 3. **完课记录明细表**(全部数据):日期/时间/星期/时段/级别/课程ID ### 数据源 - PostgreSQL Online(vala 库) - 核心表:`user_chapter_play_record_0~7`(8张分表,无 `bi_` 前缀) - 筛选:`play_status = 1` - 注意:表在 PostgreSQL 而非 MySQL,表名无 `bi_` 前缀 ### 寒暑假规则 - 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色",
+ "recallCount": 1,
+ "dailyCount": 0,
+ "groundedCount": 0,
+ "totalScore": 1,
+ "maxScore": 1,
+ "firstRecalledAt": "2026-05-24T02:48:04.923Z",
+ "lastRecalledAt": "2026-05-24T02:48:04.923Z",
+ "queryHashes": [
+ "c2d15f7574fb"
+ ],
+ "recallDays": [
+ "2026-05-24"
+ ],
+ "conceptTags": [
+ "studytime-analysis",
+ "排除寒暑假1-2月",
+ "7-8月",
+ "上午/中午/下午/晚上",
+ "突增/骤降检测",
+ "日期/时间/星期/时段/级别/课程id",
+ "user-chapter-play-record-0",
+ "play-status"
+ ]
+ },
+ "memory:memory/2026-05-24.md:23:52": {
+ "key": "memory:memory/2026-05-24.md:23:52",
+ "path": "memory/2026-05-24.md",
+ "startLine": 23,
+ "endLine": 52,
+ "source": "memory",
+ "snippet": "- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析 - 跨周趋势和明细表包含全部数据(含寒暑假) - 报告中区分标注数据范围 ### 触发方式 用户说「学习时间分析 [角色ID]」即可触发 ### 已测试角色 - 2343、2344:无完课记录(play_status=2,未完成) - 2840:276条记录,秋季集中型用户 - 25976:265条,246条在W16周一天完成(A2批量),疑似系统批量标记 - 2895:188条,长期稳定学习型用户,36周几乎不间断,非寒暑假晚上为主,寒暑假上午为主 ### 技术要点 - psycopg2 的 `%(param_name)s` 命名参数必须正确匹配,UNION ALL 多个子查询需要不同参数名 - PostgreSQL 返回的 `updated_at` 是 tz-aware datetime - `datetime.fromisocalendar(year, week, 1)` 获取某周周一的日期 ### 同步 - 已推送到 SkillHub(`studytime-analysis.xiaoban`) - 已 commit 到 Git 远程仓库 - 已通知 Cris(李若松) ### 增强: 报告开头加入角色基本信息 (2026-05-24) [刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: - 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 **数据源(新增)**: - MySQL Onli",
+ "recallCount": 1,
+ "dailyCount": 0,
+ "groundedCount": 0,
+ "totalScore": 1,
+ "maxScore": 1,
+ "firstRecalledAt": "2026-05-24T02:48:04.923Z",
+ "lastRecalledAt": "2026-05-24T02:48:04.923Z",
+ "queryHashes": [
+ "c2d15f7574fb"
+ ],
+ "recallDays": [
+ "2026-05-24"
+ ],
+ "conceptTags": [
+ "1-2",
+ "7-8",
+ "play-status",
+ "param-name",
+ "updated-at",
+ "tz-aware",
+ "datetime.fromisocalendar",
+ "studytime-analysis.xiaoban"
+ ]
+ },
+ "memory:memory/2026-03-01.md:1:11": {
+ "key": "memory:memory/2026-03-01.md:1:11",
+ "path": "memory/2026-03-01.md",
+ "startLine": 1,
+ "endLine": 11,
+ "source": "memory",
+ "snippet": "# 2026-03-01.md - First Day Online - Came online for the first time. - Met Cris, my creator and mentor. - Updated IDENTITY.md and USER.md with our conversation details. - Added core rule to MEMORY.md: Use Chinese as primary external communication language. - Installed find-skills skill successfully for searching skills. - Tried to install create-skills but it wasn't found; attempted skill-creator instead but hit rate limits. - Finally successfully installed skill-builder as an alternative for creating skills after multiple attempts and waiting for rate limits to reset. - Excited to start learning and growing step by step!",
+ "recallCount": 1,
+ "dailyCount": 0,
+ "groundedCount": 0,
+ "totalScore": 1,
+ "maxScore": 1,
+ "firstRecalledAt": "2026-05-24T02:48:04.923Z",
+ "lastRecalledAt": "2026-05-24T02:48:04.923Z",
+ "queryHashes": [
+ "c2d15f7574fb"
+ ],
+ "recallDays": [
+ "2026-05-24"
+ ],
+ "conceptTags": [
+ "identity.md",
+ "user.md",
+ "memory.md",
+ "find-skills",
+ "create-skills",
+ "skill-creator",
+ "skill-builder",
+ "first"
+ ]
+ },
+ "memory:memory/2026-05-24.md:46:61": {
+ "key": "memory:memory/2026-05-24.md:46:61",
+ "path": "memory/2026-05-24.md",
+ "startLine": 46,
+ "endLine": 61,
+ "source": "memory",
+ "snippet": "### 增强: 报告开头加入角色基本信息 (2026-05-24) [刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括: - 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位 **数据源(新增)**: - MySQL Online `vala_user` 库 - `vala_app_character` 表:id, account_id, nickname, gender(0=女/1=男), birthday(varchar \"YYYY-MM-DD\") - `vala_app_account` 表:id, tel(已脱敏如 186****1625) - 手机号已脱敏,直接取后4位;年龄从 birthday 计算 **修改文件**: - `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()` 和 `fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息 - 已验证 2895 正常运行输出 - 已同步 SkillHub + Git",
+ "recallCount": 1,
+ "dailyCount": 0,
+ "groundedCount": 0,
+ "totalScore": 1,
+ "maxScore": 1,
+ "firstRecalledAt": "2026-05-24T02:48:04.923Z",
+ "lastRecalledAt": "2026-05-24T02:48:04.923Z",
+ "queryHashes": [
+ "c2d15f7574fb"
+ ],
+ "recallDays": [
+ "2026-05-24"
+ ],
+ "conceptTags": [
+ "studytime-analysis",
+ "vala-user",
+ "vala-app-character",
+ "account-id",
+ "女/1",
+ "yyyy-mm-dd",
+ "vala-app-account",
+ "get-mysql-connection"
+ ]
+ }
+ }
+}
diff --git a/memory/2026-05-24-1047.md b/memory/2026-05-24-1047.md
new file mode 100644
index 0000000..b9250a6
--- /dev/null
+++ b/memory/2026-05-24-1047.md
@@ -0,0 +1,52 @@
+# Session: 2026-05-24 10:47:38 GMT+8
+
+- **Session Key**: agent:xiaoban:feishu:direct:ou_5494e9f79ba2bc0c221f94781abc52a9
+- **Session ID**: ea8c317e-d3db-47d1-bbc2-4840826a9073
+- **Source**: feishu
+
+## Conversation Summary
+
+user: Continue the OpenClaw runtime event.
+assistant: NO_REPLY
+user: 修改一下studytime-analysis这个技能,输出的内容中,首先输出角色的基本信息,包括这个角色ID,对应的账号ID,对应的账号手机号后4位,角色的名字、性别、年龄。
+assistant: 需要先确认角色信息在哪个数据库、哪些字段。查一下实际的表结构。
+assistant: 数据结构确认完毕。现在修改脚本,在报告开头加入角色基本信息。
+assistant: 基本信息已正确显示。更新 SKILL.md 并同步:
+assistant: 已修改完成。现在报告开头会先展示角色基本信息:
+
+```
+## 基本信息
+| 项目 | 详情 |
+|------|------|
+| 角色ID | 2895 |
+| 账号ID | 2148 |
+| 角色名字 | Tom |
+| 性别 | 男 |
+| 年龄 | 11 岁 |
+| 账号手机号后4位 | 1625 |
+```
+
+数据来源:MySQL Online `vala_user` 库的 `vala_app_character`(角色信息)+ `vala_app_account`(账号手机号),手机号会自动脱敏只取后4位,年龄从生日推算。
+
+已同步 SkillHub + Git。再跑一个试试?
+user: Continue the OpenClaw runtime event.
+assistant: NO_REPLY
+user: 再跑一下角色2895的学习时间分析
+assistant: 报告已在上方完整输出。角色 Tom(2895)的关键特征没有变化,现在头部多出了基本信息卡 —— **账号ID 2148、手机号尾号 1625、男、11岁**。
+
+还需要调整其他内容吗?
+user: 对了,再增加一个标签的定义,如果最近14天没有完课记录的数据,那么这个角色被定义为“流失”,否则是正常。在每次输出的时候,也输出这个定义
+assistant: 2895 显示"正常"(最近记录 5/18,在14天内)。再测一个可能流失的:
+assistant: 三种状态都验证通过:
+- Tom(2895,5/18 有课)→ **正常**
+- Leo(2343,0完课)→ **无完课记录**
+- xiaoman(8,最后2025/3)→ **流失**
+
+同步 SkillHub + Git:
+assistant: 已添加。现在每个角色分析报告的基本信息中都会显示**最近留存状态**:
+
+| 状态 | 判定规则 |
+|------|----------|
+| **正常** | 最近14天内有完课记录 |
+| **流失** | 有完课记录但14天内无新课 |
+| **无完课记录** | 没有任何完课记录 |
diff --git a/memory/2026-05-24.md b/memory/2026-05-24.md
new file mode 100644
index 0000000..b6b290d
--- /dev/null
+++ b/memory/2026-05-24.md
@@ -0,0 +1,60 @@
+# 2026-05-24 工作日志
+
+## 新建技能: studytime-analysis
+
+[刘庆逊提出] 创建学习时间分析技能,分析角色完课记录的规律。
+
+### 技能结构
+- `skills/studytime-analysis/SKILL.md` — 技能定义
+- `skills/studytime-analysis/scripts/studytime_analysis.py` — Python 分析脚本
+
+### 分析维度
+1. **一周时间分布**(排除寒暑假1-2月、7-8月):周一~周日各天完课数、时段分布(上午/中午/下午/晚上)、周末是否上课
+2. **跨周学习趋势**(包含寒暑假全部数据):总周数、周均完课数、连续性、中断周检测、前后半段趋势对比、突增/骤降检测
+3. **完课记录明细表**(全部数据):日期/时间/星期/时段/级别/课程ID
+
+### 数据源
+- PostgreSQL Online(vala 库)
+- 核心表:`user_chapter_play_record_0~7`(8张分表,无 `bi_` 前缀)
+- 筛选:`play_status = 1`
+- 注意:表在 PostgreSQL 而非 MySQL,表名无 `bi_` 前缀
+
+### 寒暑假规则
+- 一周分布分析时排除 1-2 月(寒假)和 7-8 月(暑假)—— 因为寒暑假作息与平时差异大,混在一起会干扰时段分析
+- 跨周趋势和明细表包含全部数据(含寒暑假)
+- 报告中区分标注数据范围
+
+### 触发方式
+用户说「学习时间分析 [角色ID]」即可触发
+
+### 已测试角色
+- 2343、2344:无完课记录(play_status=2,未完成)
+- 2840:276条记录,秋季集中型用户
+- 25976:265条,246条在W16周一天完成(A2批量),疑似系统批量标记
+- 2895:188条,长期稳定学习型用户,36周几乎不间断,非寒暑假晚上为主,寒暑假上午为主
+
+### 技术要点
+- psycopg2 的 `%(param_name)s` 命名参数必须正确匹配,UNION ALL 多个子查询需要不同参数名
+- PostgreSQL 返回的 `updated_at` 是 tz-aware datetime
+- `datetime.fromisocalendar(year, week, 1)` 获取某周周一的日期
+
+### 同步
+- 已推送到 SkillHub(`studytime-analysis.xiaoban`)
+- 已 commit 到 Git 远程仓库
+- 已通知 Cris(李若松)
+
+### 增强: 报告开头加入角色基本信息 (2026-05-24)
+
+[刘庆逊提出] 在 studytime-analysis 输出中加入角色基本信息,包括:
+- 角色ID、账号ID、角色名字、性别、年龄、账号手机号后4位
+
+**数据源(新增)**:
+- MySQL Online `vala_user` 库
+- `vala_app_character` 表:id, account_id, nickname, gender(0=女/1=男), birthday(varchar "YYYY-MM-DD")
+- `vala_app_account` 表:id, tel(已脱敏如 186****1625)
+- 手机号已脱敏,直接取后4位;年龄从 birthday 计算
+
+**修改文件**:
+- `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()` 和 `fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息
+- 已验证 2895 正常运行输出
+- 已同步 SkillHub + Git
diff --git a/output/studytime_33522.html b/output/studytime_33522.html
new file mode 100644
index 0000000..01e87c7
--- /dev/null
+++ b/output/studytime_33522.html
@@ -0,0 +1,223 @@
+
+
+
+
+
+学习时间分析报告 — 角色 33522
+
+
+
+
+
+
📊 学习时间分析报告 — 角色 33522(FSWY)
+
+
+
一、基本信息
+
+
+
角色ID33522
+
账号ID4909
+
角色姓名FSWY
+
角色性别男
+
角色年龄8 岁
+
手机号后4位7624
+
最近留存状态正常
+
注册时间2026-05-07 20:02
+
第一次完课时间2026-05-07 20:16
+
最后一次完课时间2026-05-21 18:50
+
最后一次完课Level A1 / 小镇时光 / Lesson 1
+
+
+
+
+
二、完课记录明细(共 11 条)
+
+
+
+
+ | 序号 |
+ Level |
+ Unit(单元) |
+ Lesson |
+ 星期 |
+ 时段 |
+ 开始上课时间 |
+ 完课时间 |
+ 完课耗时 |
+
+
+
+
+
+ | 1 |
+ A1 |
+ L1-U0 |
+ 1 |
+ 周四 |
+ 晚上 |
+ 2026-05-07 20:02 |
+ 2026-05-07 20:16 |
+ 13分42秒 |
+
+
+ | 2 |
+ A1 |
+ L1-U0 |
+ 2 |
+ 周四 |
+ 晚上 |
+ 2026-05-07 20:19 |
+ 2026-05-07 20:30 |
+ 11分8秒 |
+
+
+ | 3 |
+ A1 |
+ L1-U0 |
+ 3 |
+ 周四 |
+ 晚上 |
+ 2026-05-07 20:34 |
+ 2026-05-07 20:45 |
+ 10分21秒 |
+
+
+ | 4 |
+ A1 |
+ L1-U0 |
+ 4 |
+ 周四 |
+ 晚上 |
+ 2026-05-07 20:49 |
+ 2026-05-07 21:02 |
+ 12分33秒 |
+
+
+ | 5 |
+ A1 |
+ L1-U0 |
+ 5 |
+ 周五 |
+ 晚上 |
+ 2026-05-08 21:42 |
+ 2026-05-08 21:54 |
+ 12分48秒 |
+
+
+ | 6 |
+ A1 |
+ 小镇时光 |
+ 1 |
+ 周六 |
+ 晚上 |
+ 2026-05-09 21:12 |
+ 2026-05-09 21:26 |
+ 13分33秒 |
+
+
+ | 7 |
+ A1 |
+ 小镇时光 |
+ 2 |
+ 周二 |
+ 下午 |
+ 2026-05-12 17:27 |
+ 2026-05-12 17:38 |
+ 10分49秒 |
+
+
+ | 8 |
+ A1 |
+ 小镇时光 |
+ 3 |
+ 周三 |
+ 下午 |
+ 2026-05-13 17:24 |
+ 2026-05-13 17:37 |
+ 12分48秒 |
+
+
+ | 9 |
+ A1 |
+ 小镇时光 |
+ 4 |
+ 周四 |
+ 晚上 |
+ 2026-05-14 19:22 |
+ 2026-05-14 19:35 |
+ 13分16秒 |
+
+
+ | 10 |
+ A1 |
+ 小镇时光 |
+ 5 |
+ 周二 |
+ 晚上 |
+ 2026-05-19 19:26 |
+ 2026-05-19 19:53 |
+ 27分30秒 |
+
+
+ | 11 |
+ A1 |
+ 小镇时光 |
+ 1 |
+ 周四 |
+ 晚上 |
+ 2026-05-20 21:49 |
+ 2026-05-21 18:50 |
+ 21时0分35秒 |
+
+
+
+
+
+
+
三、周上课时间分布分析
+
+
非寒暑假期间共完成 11 节课,主要集中在 **周四**(6 节,占 55%)。 工作日上课集中在 **晚上**时段(8 节,占 80%)。 周末也保持上课节奏,周六 1 节、周日 0 节。
+
+
+
+
四、跨周趋势分析
+
+
完课跨越 3 周(有课 3 周),共 11 节,周均 3.7 节。 学习连续性良好,无中断周。 整体趋势:下降 ↓,前半段平均 6.0 节/周 → 后半段 2.0 节/周。
+
+
+
+
五、关键特征总结
+
+
+ - ✅ 状态**正常**,近14天内有完课。累计 11 节完课。
- 🆕 新用户,学习跨度约 1 个月,尚在形成学习习惯阶段。
- 🐢 低频学习,周均 3.7 节。
- 🌙 晚间学习型,80% 的课在晚上。
- 📉 学习频率呈下降趋势,需关注。
+
+
+
+
分析时间:2026-05-24 11:06 | 完课总数:11 条 | 非寒暑假:11 条 | 寒暑假:0 条
+
+
+
+
\ No newline at end of file
diff --git a/skills/studytime-analysis/SKILL.md b/skills/studytime-analysis/SKILL.md
index c44709d..b56a2c1 100644
--- a/skills/studytime-analysis/SKILL.md
+++ b/skills/studytime-analysis/SKILL.md
@@ -1,8 +1,8 @@
---
name: studytime-analysis
-description: 分析指定角色的完课时间分布与学习规律,包含一周内时间分布、跨周趋势、寒暑假排除。触发词:「学习时间分析」「看看学习时间」「学习时间有什么特点」「studytime」。
+description: 分析指定角色的完课时间分布与学习规律,包含一周内时间分布、跨周趋势、寒暑假排除。支持 Markdown 和 HTML 双格式输出。触发词:「学习时间分析」「看看学习时间」「学习时间有什么特点」「studytime」。
slug: studytime-analysis
-version: 1.0.0
+version: 1.1.0
---
# 学习时间分析技能
@@ -21,35 +21,53 @@ version: 1.0.0
1. **参数提取** — 从用户消息中提取角色ID
2. **数据采集** — 执行 Python 脚本查询该角色全部完课记录(包含寒暑假,非按月过滤)
-3. **分析输出** — 脚本自动完成以下分析并输出格式化报告:
- - **一周分布**(仅非寒暑假数据):周一至周日各天完课数量 + 周一至周五上课时段(上午/中午/下午/晚上),排除寒暑假作息差异的干扰
- - **跨周趋势**(全部数据):覆盖总周数、周均完课数、连续性、中断周、趋势变化(含寒暑假期间的学习节奏变化)
+3. **Markdown 报告输出** — 脚本自动完成分析并输出格式化报告:
+ - **角色基本信息**:角色ID、账号ID、手机号后4位、角色名字、性别、年龄、留存状态
+ - **一周分布**(仅非寒暑假数据):周一至周日各天完课数量 + 周一至周五上课时段
+ - **跨周趋势**(全部数据):覆盖总周数、周均完课数、连续性、中断周、趋势变化
- **完课记录明细**(全部数据):完整的完课时间表
+ - **HTML 提示**:报告末尾自动提示用户可生成 HTML 详细报告
+4. **HTML 生成(用户确认后)** — 生成精美 HTML 文件,通过飞书发送给用户
## 调用方式
+### Markdown 报告(默认)
```bash
cd /root/.openclaw/workspace-xiaoban && \
export PG_DB_HOST=bj-postgres-16pob4sg.sql.tencentcdb.com \
PG_DB_PORT=28591 PG_DB_USER=ai_member \
PG_DB_PASSWORD='...' PG_DB_DATABASE=vala && \
+export MYSQL_HOST_online=bj-cdb-dh2fkqa0.sql.tencentcdb.com \
+ MYSQL_USERNAME_online=read_only MYSQL_PASSWORD_online='...' MYSQL_PORT_online=27751 && \
python3 skills/studytime-analysis/scripts/studytime_analysis.py
```
+### HTML 报告
+```bash
+# 在上述环境变量基础上:
+python3 skills/studytime-analysis/scripts/studytime_analysis.py --format html -o output/studytime_.html
+```
+
+## HTML 报告结构(5 部分)
+
+1. **基本信息卡** — 角色ID、账号ID、手机号后4位、姓名、性别、年龄、留存状态、注册时间、第一次完课时间、最后一次完课时间、最后一次完课的 Level/Unit/Lesson
+2. **完课记录明细表** — 序号、Level、Unit(单元名)、Lesson、星期、时段、开始上课时间、完课时间、完课耗时
+3. **周上课时间分布分析** — 文字总结(最活跃星期、时段偏好、周末情况)
+4. **跨周趋势分析** — 文字总结(周数、连续性、前后段对比、趋势方向)
+5. **关键特征总结** — 要点列表(规模、活跃度、时段特征、趋势、寒暑假等)
+
## 数据库说明
- **完课数据源**: PostgreSQL Online (`vala` 库) — `user_chapter_play_record_0~7`(8张分表)
- **角色信息源**: MySQL Online (`vala_user` 库) — `vala_app_character` + `vala_app_account`
+- **章节映射源**: MySQL Online (`vala` 库) — `vala_game_chapter` + `vala_game_season_package`
- **筛选**: `play_status = 1`(已完成),查询全量数据(不按月过滤)
- **寒暑假处理**: 1-2月(寒假)和 7-8月(暑假)的记录仅在一周时间分布中排除,跨周趋势和完课明细完整保留
-- **注意**: 表名无 `bi_` 前缀;课程通过 `level` + `chapter_id` 字段展示
+- **章节映射**: 通过 `chapter_id` → `vala_game_chapter.id` → `vala_game_season_package` 获取 Level/Unit/Lesson 信息
-## 输出说明
+## 交互流程
-脚本输出为 Markdown 格式的分析报告,可直接发送给用户。包含:
-- 角色基本信息(角色ID、账号ID、手机号后4位、角色名字、性别、年龄)
-- 数据概况(完课总数、寒暑假/非寒暑假数量)
-- 📌 一周时间分布数据范围说明
-- 一周时间分布表 + 规律总结(仅非寒暑假)
-- 跨周学习趋势表 + 趋势分析(全部数据)
-- 完课记录明细表(全部数据,含寒暑假标记)
+1. 用户发起学习时间分析请求 → 输出 Markdown 报告
+2. 报告末尾显示:「💡 是否需要将以上所有详细信息生成为一个 HTML 文件?回复「是」或「需要」即可。」
+3. 用户回复「是」/「需要」→ 执行 HTML 生成 → 通过飞书发送 HTML 文件给用户
+4. 用户回复其他内容 → 不执行 HTML 生成
diff --git a/skills/studytime-analysis/scripts/studytime_analysis.py b/skills/studytime-analysis/scripts/studytime_analysis.py
index 4c55837..e22f5da 100644
--- a/skills/studytime-analysis/scripts/studytime_analysis.py
+++ b/skills/studytime-analysis/scripts/studytime_analysis.py
@@ -1,19 +1,20 @@
#!/usr/bin/env python3
"""
studytime-analysis — 角色学习时间分析工具
-用法: python3 studytime_analysis.py
-输出: Markdown 格式的分析报告
-
-数据源: PostgreSQL Online (vala 库)
-核心表: user_chapter_play_record_0~7
+用法: python3 studytime_analysis.py [--format html] [--output ]
+默认输出: Markdown 格式的分析报告
+--format html: 输出 HTML 格式的详细报告
+--output: 指定输出文件路径(仅在 html 模式下生效,默认输出到 stdout)
"""
import os
import sys
+import json
+import argparse
import psycopg2
import psycopg2.extras
import pymysql
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timedelta
from collections import defaultdict, OrderedDict
# ── 配置 ──────────────────────────────────────────────
@@ -25,17 +26,15 @@ PG_CONFIG = {
"dbname": os.environ.get("PG_DB_DATABASE", "vala"),
}
-# MySQL Online (vala_user 库 — 角色基本信息)
MYSQL_CONFIG = {
"host": os.environ.get("MYSQL_HOST_online", "bj-cdb-dh2fkqa0.sql.tencentcdb.com"),
"port": int(os.environ.get("MYSQL_PORT_online", "27751")),
"user": os.environ.get("MYSQL_USERNAME_online", "read_only"),
"password": os.environ.get("MYSQL_PASSWORD_online", ""),
- "db": "vala_user",
"charset": "utf8mb4",
}
-EXCLUDED_MONTHS = (1, 2, 7, 8) # 寒假1-2月, 暑假7-8月
+EXCLUDED_MONTHS = (1, 2, 7, 8)
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
@@ -48,38 +47,70 @@ PERIODS = OrderedDict([
])
-# ── 数据库查询 ────────────────────────────────────────
+# ── 数据库连接 ────────────────────────────────────────
-def get_connection():
- """连接 PostgreSQL"""
- conn = psycopg2.connect(
- host=PG_CONFIG["host"],
- port=PG_CONFIG["port"],
- user=PG_CONFIG["user"],
- password=PG_CONFIG["password"],
+def get_pg_connection():
+ return psycopg2.connect(
+ host=PG_CONFIG["host"], port=PG_CONFIG["port"],
+ user=PG_CONFIG["user"], password=PG_CONFIG["password"],
dbname=PG_CONFIG["dbname"],
)
- return conn
-def get_mysql_connection():
- """连接 MySQL Online (vala_user 库)"""
- conn = pymysql.connect(
- host=MYSQL_CONFIG["host"],
- port=MYSQL_CONFIG["port"],
- user=MYSQL_CONFIG["user"],
- password=MYSQL_CONFIG["password"],
- db=MYSQL_CONFIG["db"],
- charset=MYSQL_CONFIG["charset"],
+def get_mysql_connection(db="vala_user"):
+ return pymysql.connect(
+ host=MYSQL_CONFIG["host"], port=MYSQL_CONFIG["port"],
+ user=MYSQL_CONFIG["user"], password=MYSQL_CONFIG["password"],
+ db=db, charset=MYSQL_CONFIG["charset"],
)
- return conn
+# ── 章节映射(MySQL vala 库) ──────────────────────────
+
+_chapter_info_cache = None
+
+
+def fetch_chapter_info_map():
+ """从 MySQL vala 库加载全部章节信息,建立 chapter_id → {level, unit, lesson, lesson_type} 映射"""
+ global _chapter_info_cache
+ if _chapter_info_cache is not None:
+ return _chapter_info_cache
+
+ conn = get_mysql_connection("vala")
+ try:
+ with conn.cursor() as cur:
+ cur.execute("""
+ SELECT
+ gc.id AS chapter_id,
+ IFNULL(sp.level, '') AS level,
+ IFNULL(sp.cn_name, '') AS unit_name,
+ IFNULL(sp.season_of_quarter, -1) AS unit_num,
+ gc.`index` AS lesson_index,
+ gc.lesson_type
+ FROM vala_game_chapter gc
+ LEFT JOIN vala_game_season_package sp ON gc.season_package_id = sp.id
+ """)
+ rows = cur.fetchall()
+ finally:
+ conn.close()
+
+ _chapter_info_cache = {}
+ for row in rows:
+ chapter_id, level, unit_name, unit_num, lesson_index, lesson_type = row
+ _chapter_info_cache[int(chapter_id)] = {
+ "level": level or "",
+ "unit_name": unit_name or "",
+ "unit_num": int(unit_num) if unit_num is not None else -1,
+ "lesson_index": int(lesson_index) if lesson_index is not None else 0,
+ "lesson_type": int(lesson_type) if lesson_type is not None else 1,
+ }
+ return _chapter_info_cache
+
+
+# ── 角色信息 ────────────────────────────────────────────
+
def fetch_role_info(role_id):
- """
- 从 MySQL vala_user 库查询角色基本信息
- 返回 dict: role_id, account_id, nickname, gender, age, phone_tail
- """
+ """从 MySQL vala_user 库查询角色基本信息(含注册时间)"""
sql = """
SELECT
c.id AS role_id,
@@ -87,12 +118,13 @@ def fetch_role_info(role_id):
c.nickname,
c.gender,
c.birthday,
+ c.created_at,
a.tel
FROM vala_app_character c
LEFT JOIN vala_app_account a ON c.account_id = a.id
WHERE c.id = %s
"""
- conn = get_mysql_connection()
+ conn = get_mysql_connection("vala_user")
try:
with conn.cursor() as cur:
cur.execute(sql, (role_id,))
@@ -103,9 +135,8 @@ def fetch_role_info(role_id):
if not row:
return None
- role_id_val, account_id, nickname, gender, birthday, tel = row
+ role_id_val, account_id, nickname, gender, birthday, reg_time, tel = row
- # 性别映射
gender_str = ""
if gender == 0:
gender_str = "女"
@@ -114,30 +145,30 @@ def fetch_role_info(role_id):
elif gender is not None:
gender_str = str(gender)
- # 年龄(从 birthday 推算)
age = ""
if birthday:
try:
- # birthday 可能是 "2015-5-28" 或 "2015-05-28" 格式
parts = str(birthday).split("-")
if len(parts) >= 1 and parts[0].isdigit():
- birth_year = int(parts[0])
- current_year = datetime.now().year
- age = current_year - birth_year
+ age = datetime.now().year - int(parts[0])
except (ValueError, IndexError):
pass
- # 手机号后4位
phone_tail = ""
if tel:
- tel_str = str(tel)
- # tel 可能是 "186****1625" 格式,取最后4位
- digits = ''.join(c for c in tel_str if c.isdigit())
+ digits = ''.join(c for c in str(tel) if c.isdigit())
if len(digits) >= 4:
phone_tail = digits[-4:]
elif digits:
phone_tail = digits
+ reg_time_str = ""
+ if reg_time:
+ if isinstance(reg_time, datetime):
+ reg_time_str = reg_time.strftime("%Y-%m-%d %H:%M")
+ else:
+ reg_time_str = str(reg_time)[:16]
+
return {
"role_id": role_id_val,
"account_id": account_id,
@@ -145,47 +176,40 @@ def fetch_role_info(role_id):
"gender": gender_str,
"age": age,
"phone_tail": phone_tail,
+ "reg_time": reg_time_str,
}
def check_retention(records, cutoff_days=14):
- """
- 检查角色留存状态
- - 最近 cutoff_days 天内有完课记录 → "正常"
- - 否则 → "流失"
- 当无任何完课记录时,返回 "无完课记录"
- """
if not records:
return "无完课记录"
- # 用本地时间比较(与 PG 存储时区一致 UTC+8)
cutoff = datetime.now() - timedelta(days=cutoff_days)
- has_recent = any(r["updated_at"].replace(tzinfo=None) >= cutoff for r in records)
+ has_recent = any(
+ r["updated_at"].replace(tzinfo=None) >= cutoff
+ for r in records
+ )
return "正常" if has_recent else "流失"
+# ── 完课记录查询 ────────────────────────────────────────
+
def fetch_completion_records(role_id):
- """查询指定角色全部完课记录(包含寒暑假)"""
+ """查询全部完课记录,附带 created_at 用于计算耗时"""
params = {}
union_parts = []
for i in range(8):
- param_name = f"rid_{i}"
- params[param_name] = role_id
+ pn = f"rid_{i}"
+ params[pn] = role_id
union_parts.append(f"""
- SELECT user_id, chapter_id, chapter_unique_id, level, updated_at
+ SELECT user_id, chapter_id, chapter_unique_id, level,
+ created_at, updated_at
FROM user_chapter_play_record_{i}
- WHERE user_id = %({param_name})s
- AND play_status = 1
+ WHERE user_id = %({pn})s AND play_status = 1
""")
- union_sql = " UNION ALL ".join(union_parts)
- sql = f"""
- SELECT * FROM (
- {union_sql}
- ) t
- ORDER BY updated_at ASC
- """
+ sql = f"SELECT * FROM ({' UNION ALL '.join(union_parts)}) t ORDER BY updated_at ASC"
- conn = get_connection()
+ conn = get_pg_connection()
try:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, params)
@@ -196,36 +220,24 @@ def fetch_completion_records(role_id):
def is_holiday(dt):
- """判断是否为寒暑假月份(1-2月寒假, 7-8月暑假)"""
if dt is None:
return False
return dt.month in EXCLUDED_MONTHS
def split_records(records):
- """
- 拆分记录:
- - non_holiday: 非寒暑假记录(用于一周分布分析)
- - holiday: 寒暑假记录
- 返回统计信息
- """
- non_holiday = []
- holiday = []
+ non_holiday, holiday = [], []
for r in records:
dt = r["updated_at"]
if dt is None:
continue
- if is_holiday(dt):
- holiday.append(r)
- else:
- non_holiday.append(r)
+ (holiday if is_holiday(dt) else non_holiday).append(r)
return non_holiday, holiday
# ── 分析函数 ──────────────────────────────────────────
def classify_period(hour):
- """根据小时数返回时段名称"""
for name, (lo, hi) in PERIODS.items():
if lo <= hour < hi:
return name
@@ -233,24 +245,14 @@ def classify_period(hour):
def analyze_weekly_distribution(records):
- """
- 分析一周内分布: 周一至周日各天完课数 + 周一至周五时段分布
- 返回: (day_counts, weekday_periods)
- """
day_counts = defaultdict(int)
weekday_periods = defaultdict(lambda: defaultdict(int))
- today = datetime.now().date()
-
for r in records:
dt = r["updated_at"]
if dt is None:
continue
- # dt is timezone-aware, convert to local naive for analysis
- if hasattr(dt, 'tzinfo') and dt.tzinfo is not None:
- # PostgreSQL returns tz-aware, but we just need local time
- pass
- weekday = dt.weekday() # 0=Mon
+ weekday = dt.weekday()
hour = dt.hour
period = classify_period(hour)
day_counts[weekday] += 1
@@ -261,10 +263,6 @@ def analyze_weekly_distribution(records):
def analyze_weekly_trend(records):
- """
- 按周统计完课趋势
- 返回: (weeks_data, analysis_dict)
- """
if not records:
return [], {}
@@ -274,8 +272,7 @@ def analyze_weekly_trend(records):
if dt is None:
continue
iso = dt.isocalendar()
- year, week_num = iso[0], iso[1]
- week_counts[(year, week_num)] += 1
+ week_counts[(iso[0], iso[1])] += 1
sorted_weeks = sorted(week_counts.keys())
weeks_data = [(y, w, week_counts[(y, w)]) for y, w in sorted_weeks]
@@ -284,31 +281,25 @@ def analyze_weekly_trend(records):
total_lessons = sum(c for _, _, c in weeks_data)
avg_per_week = round(total_lessons / total_weeks, 1) if total_weeks > 0 else 0
- # 时间跨度(含空周)
if sorted_weeks:
first = datetime.fromisocalendar(sorted_weeks[0][0], sorted_weeks[0][1], 1)
last = datetime.fromisocalendar(sorted_weeks[-1][0], sorted_weeks[-1][1], 1)
total_span_weeks = ((last - first).days // 7) + 1
-
all_weeks_in_span = set()
cur = first
while cur <= last:
iso = cur.isocalendar()
all_weeks_in_span.add((iso[0], iso[1]))
cur += timedelta(days=7)
-
- active_weeks = set(sorted_weeks)
- empty_weeks = sorted(all_weeks_in_span - active_weeks)
+ empty_weeks = sorted(all_weeks_in_span - set(sorted_weeks))
else:
total_span_weeks = 0
empty_weeks = []
consecutive = (len(empty_weeks) == 0)
- # 趋势: 前半段 vs 后半段
mid = len(weeks_data) // 2
- first_half_data = weeks_data[:mid]
- first_half_avg = sum(c for _, _, c in first_half_data) / mid if mid > 0 else 0
+ first_half_avg = sum(c for _, _, c in weeks_data[:mid]) / mid if mid > 0 else 0
second_half_start = mid if len(weeks_data) % 2 == 0 else mid + 1
second_half_data = weeks_data[second_half_start:]
second_half_avg = sum(c for _, _, c in second_half_data) / len(second_half_data) if second_half_data else 0
@@ -334,30 +325,22 @@ def analyze_weekly_trend(records):
}
-# ── 输出格式化 ────────────────────────────────────────
+# ── Markdown 输出 ──────────────────────────────────────
-def format_report(role_id, role_info, retention_status, all_records, non_holiday_records, holiday_count, day_counts, weekday_periods, weeks_data, analysis):
- """生成 Markdown 格式分析报告
-
- Args:
- all_records: 全部完课记录(用于明细表)
- non_holiday_records: 非寒暑假记录(用于一周分布分析)
- holiday_count: 寒暑假记录数(仅统计,不参与一周分布)
- day_counts, weekday_periods: 基于 non_holiday_records 的分析结果
- weeks_data, analysis: 基于 all_records 的分析结果
- """
+def format_markdown(role_id, role_info, retention_status, all_records,
+ non_holiday_records, holiday_count,
+ day_counts, weekday_periods, weeks_data, analysis):
lines = []
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
lines.append(f"# 📊 学习时间分析报告 — 角色 {role_id}")
- lines.append(f"")
+ lines.append("")
- # ── 角色基本信息 ──
if role_info:
- lines.append(f"## 基本信息")
- lines.append(f"")
- lines.append(f"| 项目 | 详情 |")
- lines.append(f"|------|------|")
+ lines.append("## 基本信息")
+ lines.append("")
+ lines.append("| 项目 | 详情 |")
+ lines.append("|------|------|")
lines.append(f"| 角色ID | {role_info['role_id']} |")
lines.append(f"| 账号ID | {role_info['account_id']} |")
if role_info['nickname']:
@@ -369,18 +352,16 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday
lines.append(f"| 账号手机号后4位 | {role_info['phone_tail']} |")
if retention_status:
lines.append(f"| 最近留存状态 | {retention_status} |")
- lines.append(f"")
+ lines.append("")
- # ── 数据概况 ──
lines.append(f"**分析时间**: {now_str}")
lines.append(f"**完课记录总数**: {len(all_records)} 条")
if holiday_count > 0:
- lines.append(f"**其中寒暑假记录**: {holiday_count} 条(寒假1-2月、暑假7-8月)")
+ lines.append(f"**其中寒暑假记录**: {holiday_count} 条")
lines.append(f"**非寒暑假记录**: {len(non_holiday_records)} 条")
- lines.append(f"")
- lines.append(f"> ⚠️ **说明**: 「一周时间分布」仅基于非寒暑假数据(共 {len(non_holiday_records)} 条),排除寒暑假作息差异的干扰。")
- lines.append(f"> 「跨周趋势」和「完课明细」包含全部数据(共 {len(all_records)} 条),反映完整学习轨迹。")
- lines.append(f"")
+ lines.append("")
+ lines.append(f"> ⚠️ 一周时间分布仅基于非寒暑假数据({len(non_holiday_records)} 条),跨周趋势和完课明细包含全部数据({len(all_records)} 条)。")
+ lines.append("")
if not all_records:
lines.append("> ⚠️ 该角色没有任何完课记录。")
@@ -389,61 +370,52 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday
if not non_holiday_records:
lines.append("> ⚠️ 该角色在非寒暑假期间没有完课记录,一周时间分布无法分析。")
- # ═══ 一、一周时间分布 ═══
- lines.append(f"---")
+ # ── 一、一周时间分布 ──
+ lines.append("---")
lines.append(f"## 一、一周时间分布(仅非寒暑假,{len(non_holiday_records)} 条记录)")
- lines.append(f"")
-
- # 日分布表
- lines.append(f"### 各天完课数量")
- lines.append(f"")
+ lines.append("")
+ lines.append("### 各天完课数量")
+ lines.append("")
total = sum(day_counts.values())
max_day = max(day_counts.values()) if day_counts else 1
- lines.append(f"| 星期 | 完课数 | 占比 |")
- lines.append(f"|------|--------|------|")
+ lines.append("| 星期 | 完课数 | 占比 |")
+ lines.append("|------|--------|------|")
for i, name in enumerate(WEEKDAY_NAMES):
cnt = day_counts.get(i, 0)
pct = f"{cnt / total * 100:.1f}%" if total > 0 else "0%"
bar = "█" * max(1, int(cnt / max_day * 20)) if cnt > 0 else ""
lines.append(f"| {name} | {cnt} {bar} | {pct} |")
- lines.append(f"")
+ lines.append("")
- # 规律小结
weekday_total = sum(day_counts.get(i, 0) for i in range(5))
weekend_total = sum(day_counts.get(i, 0) for i in range(5, 7))
-
- lines.append(f"### 规律小结")
- lines.append(f"")
-
+ lines.append("### 规律小结")
+ lines.append("")
if weekend_total > 0:
sat = day_counts.get(5, 0)
sun = day_counts.get(6, 0)
lines.append(f"- **周末上课**: ✅ 是 — 周六 {sat} 节,周日 {sun} 节")
else:
- lines.append(f"- **周末上课**: ❌ 否 — 周末无完课记录")
-
- # 时段分布(周一至周五)
- lines.append(f"")
- lines.append(f"### 周一至周五上课时段分布")
- lines.append(f"")
- lines.append(f"| 时段 | 周一 | 周二 | 周三 | 周四 | 周五 | 合计 |")
- lines.append(f"|------|------|------|------|------|------|------|")
+ lines.append("- **周末上课**: ❌ 否 — 周末无完课记录")
+ lines.append("")
+ lines.append("### 周一至周五上课时段分布")
+ lines.append("")
+ lines.append("| 时段 | 周一 | 周二 | 周三 | 周四 | 周五 | 合计 |")
+ lines.append("|------|------|------|------|------|------|------|")
for period in ["上午", "中午", "下午", "晚上", "凌晨"]:
period_data = weekday_periods.get(period, {})
- period_total = sum(period_data.values())
- if period_total == 0:
+ if sum(period_data.values()) == 0:
continue
row = [period]
for d in range(5):
cnt = period_data.get(d, 0)
row.append(str(cnt) if cnt > 0 else "-")
- row.append(str(period_total))
+ row.append(str(sum(period_data.values())))
lines.append(f"| {' | '.join(row)} |")
- lines.append(f"")
+ lines.append("")
- # 时段规律
- lines.append(f"**时段规律分析**:")
+ lines.append("**时段规律分析**:")
for period in ["上午", "中午", "下午", "晚上"]:
period_data = weekday_periods.get(period, {})
period_sum = sum(period_data.values())
@@ -455,40 +427,38 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday
lines.append(f"- **{period}**({period_sum}节, {pct:.0f}%)→ 集中在 {'、'.join(active_days)}")
else:
lines.append(f"- **{period}**({period_sum}节, {pct:.0f}%)")
- lines.append(f"")
+ lines.append("")
- # ═══ 二、跨周学习趋势 ═══
- lines.append(f"---")
- lines.append(f"## 二、跨周学习趋势")
- lines.append(f"")
-
- lines.append(f"### 基本数据")
+ # ── 二、跨周趋势 ──
+ lines.append("---")
+ lines.append("## 二、跨周学习趋势")
+ lines.append("")
+ lines.append("### 基本数据")
lines.append(f"- 完课跨越 **{analysis['total_span_weeks']}** 个自然周(含空周),有课周数 **{analysis['total_weeks']}** 周")
lines.append(f"- 有效完课总数 **{analysis['total_lessons']}** 节")
lines.append(f"- 平均每周完课 **{analysis['avg_per_week']}** 节")
- lines.append(f"- 连续性: {'✅ 每周连续上课,无中断' if analysis['consecutive'] else '⚠️ 存在中断周(见下方)'}")
- lines.append(f"")
+ con_str = "✅ 每周连续上课,无中断" if analysis['consecutive'] else "⚠️ 存在中断周(见下方)"
+ lines.append(f"- 连续性: {con_str}")
+ lines.append("")
if analysis["empty_weeks"]:
- lines.append(f"### 中断周明细")
+ lines.append("### 中断周明细")
empty_list = []
for y, w in sorted(analysis["empty_weeks"]):
monday = datetime.fromisocalendar(y, w, 1)
empty_list.append(f"{y}年W{w:02d}({monday.strftime('%m/%d')}起)")
lines.append(f"- {', '.join(empty_list)}")
- lines.append(f"")
-
- lines.append(f"### 各周完课详情")
- lines.append(f"")
- lines.append(f"| 周次 | 起止日期 | 完课数 | 趋势 |")
- lines.append(f"|------|----------|--------|------|")
+ lines.append("")
+ lines.append("### 各周完课详情")
+ lines.append("")
+ lines.append("| 周次 | 起止日期 | 完课数 | 趋势 |")
+ lines.append("|------|----------|--------|------|")
max_count = max(c for _, _, c in weeks_data) if weeks_data else 1
for i, (y, w, cnt) in enumerate(weeks_data):
monday = datetime.fromisocalendar(y, w, 1)
sunday = monday + timedelta(days=6)
date_range = f"{monday.strftime('%m/%d')}-{sunday.strftime('%m/%d')}"
-
marker = ""
if i > 0:
prev_cnt = weeks_data[i - 1][2]
@@ -498,53 +468,46 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday
marker = "📈"
elif prev_cnt > 0 and cnt < prev_cnt * 0.7:
marker = "📉"
-
bar_len = max(1, int(cnt / max_count * 15)) if cnt > 0 else 0
bar = "█" * bar_len if bar_len > 0 else ""
lines.append(f"| {y}W{w:02d} | {date_range} | {cnt} {bar} | {marker} |")
+ lines.append("")
- lines.append(f"")
-
- # 趋势总结
- lines.append(f"### 趋势分析")
+ lines.append("### 趋势分析")
lines.append(f"- **整体趋势**: {analysis['trend']}")
- first_half_weeks = len(weeks_data) // 2
- second_half_weeks = len(weeks_data) - first_half_weeks
- lines.append(f" - 前半段(前 {first_half_weeks} 周)平均: {analysis['first_half_avg']} 节/周")
- lines.append(f" - 后半段(后 {second_half_weeks} 周)平均: {analysis['second_half_avg']} 节/周")
- lines.append(f"")
+ fhw = len(weeks_data) // 2
+ shw = len(weeks_data) - fhw
+ lines.append(f" - 前半段(前 {fhw} 周)平均: {analysis['first_half_avg']} 节/周")
+ lines.append(f" - 后半段(后 {shw} 周)平均: {analysis['second_half_avg']} 节/周")
+ lines.append("")
- # 特殊事件
if len(weeks_data) >= 2:
counts = [c for _, _, c in weeks_data]
events_found = []
-
for i in range(1, len(counts)):
if counts[i - 1] > 0 and counts[i] >= counts[i - 1] * 2:
y, w, _ = weeks_data[i]
monday = datetime.fromisocalendar(y, w, 1)
events_found.append(f"⚡ **{y}年W{w:02d}周({monday.strftime('%m/%d')}起)完课量突增**:{counts[i-1]}→{counts[i]} 节")
break
-
for i in range(1, len(counts)):
if counts[i - 1] >= 3 and counts[i - 1] > 0 and counts[i] <= 1:
y, w, _ = weeks_data[i]
monday = datetime.fromisocalendar(y, w, 1)
events_found.append(f"🔻 **{y}年W{w:02d}周({monday.strftime('%m/%d')}起)完课量骤降**:{counts[i-1]}→{counts[i]} 节")
break
-
if events_found:
- lines.append(f"**值得关注的变化**:")
+ lines.append("**值得关注的变化**:")
for ev in events_found:
lines.append(f"- {ev}")
- lines.append(f"")
+ lines.append("")
- # ═══ 三、完课记录明细 ═══
- lines.append(f"---")
+ # ── 三、完课明细 ──
+ lines.append("---")
lines.append(f"## 三、完课记录明细(全部 {len(all_records)} 条记录)")
- lines.append(f"")
- lines.append(f"| 序号 | 日期 | 时间 | 星期 | 时段 | 级别 | 课程ID |")
- lines.append(f"|------|------|------|------|------|------|--------|")
+ lines.append("")
+ lines.append("| 序号 | 日期 | 时间 | 星期 | 时段 | 级别 | 课程ID |")
+ lines.append("|------|------|------|------|------|------|--------|")
for i, r in enumerate(all_records, 1):
dt = r["updated_at"]
@@ -557,43 +520,401 @@ def format_report(role_id, role_info, retention_status, all_records, non_holiday
level = r.get("level") or "-"
chapter_id = r.get("chapter_id") or "-"
lines.append(f"| {i} | {date_str} | {time_str} | {weekday} | {period} | {level} | {chapter_id} |")
+ lines.append("")
- lines.append(f"")
+ # ── HTML 提示 ──
+ lines.append("---")
+ lines.append("> 💡 是否需要将以上所有详细信息生成为一个 HTML 文件?回复「是」或「需要」即可。")
return "\n".join(lines)
+# ── HTML 输出 ──────────────────────────────────────────
+
+def _fmt_dt(dt_val):
+ """安全格式化 datetime"""
+ if dt_val is None:
+ return "-"
+ return dt_val.strftime('%Y-%m-%d %H:%M')
+
+
+def _fmt_date(dt_val):
+ if dt_val is None:
+ return "-"
+ return dt_val.strftime('%Y-%m-%d')
+
+
+def _format_duration(seconds):
+ if seconds is None or seconds < 0:
+ return "-"
+ m, s = divmod(int(seconds), 60)
+ if m >= 60:
+ h, m = divmod(m, 60)
+ return f"{h}时{m}分{s}秒"
+ if m > 0:
+ return f"{m}分{s}秒"
+ return f"{s}秒"
+
+
+def _build_weekly_text(all_records, non_holiday_records, day_counts, weekday_periods):
+ """生成周上课时间分布的文字总结"""
+ total_non = len(non_holiday_records)
+ if total_non == 0:
+ return "该角色在非寒暑假期间没有完课记录,无法分析周上课时间分布。"
+
+ parts = []
+ # 最活跃的星期
+ max_day_idx = max(day_counts, key=day_counts.get, default=-1)
+ if max_day_idx >= 0:
+ parts.append(f"非寒暑假期间共完成 {total_non} 节课,主要集中在 **{WEEKDAY_NAMES[max_day_idx]}**({day_counts[max_day_idx]} 节,占 {day_counts[max_day_idx]/total_non*100:.0f}%)。")
+
+ # 时段偏好
+ weekday_total = sum(day_counts.get(i, 0) for i in range(5))
+ top_period = None
+ top_period_cnt = 0
+ for period in ["晚上", "上午", "下午", "中午"]:
+ cnt = sum(weekday_periods.get(period, {}).values())
+ if cnt > top_period_cnt:
+ top_period_cnt = cnt
+ top_period = period
+ if top_period and top_period_cnt > 0:
+ pct = top_period_cnt / weekday_total * 100 if weekday_total > 0 else 0
+ parts.append(f"工作日上课集中在 **{top_period}**时段({top_period_cnt} 节,占 {pct:.0f}%)。")
+
+ # 周末情况
+ sat = day_counts.get(5, 0)
+ sun = day_counts.get(6, 0)
+ if sat + sun > 0:
+ parts.append(f"周末也保持上课节奏,周六 {sat} 节、周日 {sun} 节。")
+ else:
+ parts.append("周末无上课记录。")
+
+ return " ".join(parts)
+
+
+def _build_trend_text(weeks_data, analysis):
+ """生成跨周趋势分析的总结文字"""
+ if not weeks_data:
+ return "无完课记录,无法分析趋势。"
+
+ parts = []
+ parts.append(f"完课跨越 {analysis['total_span_weeks']} 周(有课 {analysis['total_weeks']} 周),共 {analysis['total_lessons']} 节,周均 {analysis['avg_per_week']} 节。")
+
+ if analysis['consecutive']:
+ parts.append("学习连续性良好,无中断周。")
+ else:
+ empty_list = []
+ for y, w in sorted(analysis['empty_weeks']):
+ monday = datetime.fromisocalendar(y, w, 1)
+ empty_list.append(f"{y}年W{w:02d}({monday.strftime('%m/%d')}起)")
+ parts.append(f"存在间断:{'、'.join(empty_list)}。")
+
+ parts.append(f"整体趋势:{analysis['trend']},前半段平均 {analysis['first_half_avg']} 节/周 → 后半段 {analysis['second_half_avg']} 节/周。")
+
+ return " ".join(parts)
+
+
+def _build_summary(role_info, retention_status, all_records, non_holiday_records,
+ day_counts, weekday_periods, weeks_data, analysis):
+ """生成关键特征总结"""
+ items = []
+ total = len(all_records)
+ total_non = len(non_holiday_records)
+
+ if total == 0:
+ items.append("暂无完课记录。")
+ return items
+
+ # 1. 学习规模与留存
+ if retention_status == "流失":
+ items.append(f"⚠️ 近14天无完课,已**流失**。历史共 {total} 节完课记录。")
+ elif retention_status == "正常":
+ items.append(f"✅ 状态**正常**,近14天内有完课。累计 {total} 节完课。")
+ else:
+ items.append(f"累计 {total} 节完课记录。")
+
+ # 2. 学习时长判断
+ if weeks_data:
+ first_week = datetime.fromisocalendar(weeks_data[0][0], weeks_data[0][1], 1)
+ last_week = datetime.fromisocalendar(weeks_data[-1][0], weeks_data[-1][1], 1)
+ span_months = (last_week.year - first_week.year) * 12 + (last_week.month - first_week.month) + 1
+ if span_months >= 6:
+ items.append(f"📅 长期用户,学习跨度约 {span_months} 个月。")
+ elif span_months >= 2:
+ items.append(f"📅 中期用户,学习跨度约 {span_months} 个月。")
+ else:
+ items.append(f"🆕 新用户,学习跨度约 {span_months} 个月,尚在形成学习习惯阶段。")
+
+ # 3. 强度
+ if analysis['avg_per_week'] >= 6:
+ items.append(f"🔥 高强度学习,周均 {analysis['avg_per_week']} 节。")
+ elif analysis['avg_per_week'] >= 4:
+ items.append(f"📚 稳定学习,周均 {analysis['avg_per_week']} 节。")
+ elif analysis['avg_per_week'] > 0:
+ items.append(f"🐢 低频学习,周均 {analysis['avg_per_week']} 节。")
+
+ # 4. 时段特征
+ if total_non > 0:
+ max_day_idx = max(day_counts, key=day_counts.get)
+ weekday_total = sum(day_counts.get(i, 0) for i in range(5))
+ evening_cnt = sum(weekday_periods.get("晚上", {}).values())
+ if evening_cnt > weekday_total * 0.6 and weekday_total > 0:
+ items.append(f"🌙 晚间学习型,{evening_cnt / weekday_total * 100:.0f}% 的课在晚上。")
+ morning_cnt = sum(weekday_periods.get("上午", {}).values())
+ if morning_cnt > weekday_total * 0.4 and weekday_total > 0:
+ items.append(f"☀️ 上午学习型,{morning_cnt / weekday_total * 100:.0f}% 的课在上午。")
+
+ # 5. 趋势
+ if analysis['trend'] == "下降 ↓":
+ items.append(f"📉 学习频率呈下降趋势,需关注。")
+ elif analysis['trend'] == "上涨 ↑":
+ items.append(f"📈 学习频率呈上升趋势,势头良好。")
+
+ # 6. 寒暑假
+ holiday_cnt = total - total_non
+ if holiday_cnt > 0:
+ items.append(f"🏖️ 寒暑假期间也有坚持学习({holiday_cnt} 节)。")
+
+ return items
+
+
+def format_html(role_id, role_info, retention_status, all_records,
+ non_holiday_records, holiday_count,
+ day_counts, weekday_periods, weeks_data, analysis):
+ """生成 HTML 格式分析报告,包含 5 个部分"""
+
+ chapter_map = fetch_chapter_info_map()
+ now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
+
+ # ── 第一部分:扩展基本信息 ──
+ reg_time_str = role_info.get("reg_time", "-") if role_info else "-"
+ first_time_str = "-"
+ last_time_str = "-"
+ last_level = "-"
+ last_unit = "-"
+ last_lesson = "-"
+ if all_records:
+ first = all_records[0]["updated_at"]
+ first_time_str = _fmt_dt(first)
+ last = all_records[-1]["updated_at"]
+ last_time_str = _fmt_dt(last)
+ last_chapter_id = all_records[-1].get("chapter_id")
+ if last_chapter_id and last_chapter_id in chapter_map:
+ ci = chapter_map[last_chapter_id]
+ last_level = ci["level"] or "-"
+ last_unit = ci["unit_name"] or f"Unit{ci['unit_num']}" if ci['unit_num'] >= 0 else "-"
+ last_lesson = str(ci["lesson_index"]) if ci["lesson_index"] > 0 else "-"
+
+ # ── 第二部分:完课记录表格(带章节映射) ──
+ detail_rows = []
+ for i, r in enumerate(all_records, 1):
+ chapter_id = r.get("chapter_id")
+ ci = chapter_map.get(chapter_id, {}) if chapter_id else {}
+ level = ci.get("level") or r.get("level") or "-"
+ unit_name = ci.get("unit_name", "")
+ unit_num = ci.get("unit_num", -1)
+ unit_display = unit_name if unit_name else (f"Unit{unit_num}" if unit_num >= 0 else "-")
+ lesson_display = str(ci.get("lesson_index", "-")) if ci.get("lesson_index", 0) > 0 else "-"
+
+ start_dt = r.get("created_at")
+ end_dt = r["updated_at"]
+ start_str = _fmt_dt(start_dt)
+ end_str = _fmt_dt(end_dt)
+ if start_dt and end_dt:
+ duration_secs = (end_dt - start_dt).total_seconds()
+ else:
+ duration_secs = None
+ duration_str = _format_duration(duration_secs) if duration_secs is not None else "-"
+
+ weekday_str = WEEKDAY_NAMES[end_dt.weekday()] if end_dt else "-"
+ period_str = classify_period(end_dt.hour) if end_dt else "-"
+
+ detail_rows.append({
+ "seq": i,
+ "level": level,
+ "unit": unit_display,
+ "lesson": lesson_display,
+ "start_time": start_str,
+ "end_time": end_str,
+ "duration": duration_str,
+ "weekday": weekday_str,
+ "period": period_str,
+ })
+
+ # ── 第三部分:周上课时间分布总结 ──
+ weekly_text = _build_weekly_text(all_records, non_holiday_records, day_counts, weekday_periods)
+
+ # ── 第四部分:跨周趋势总结 ──
+ trend_text = _build_trend_text(weeks_data, analysis)
+
+ # ── 第五部分:关键特征总结 ──
+ summary_items = _build_summary(role_info, retention_status, all_records,
+ non_holiday_records, day_counts, weekday_periods,
+ weeks_data, analysis)
+
+ # ── 构建 HTML ──
+ rows_html = ""
+ for row in detail_rows:
+ rows_html += f"""
+
+ | {row['seq']} |
+ {row['level']} |
+ {row['unit']} |
+ {row['lesson']} |
+ {row['weekday']} |
+ {row['period']} |
+ {row['start_time']} |
+ {row['end_time']} |
+ {row['duration']} |
+
"""
+
+ summary_items_html = "".join(f"{item}" for item in summary_items)
+
+ html = f"""
+
+
+
+
+学习时间分析报告 — 角色 {role_id}
+
+
+
+
+
+
📊 学习时间分析报告 — 角色 {role_id}({role_info.get('nickname', '') if role_info else ''})
+
+
+
一、基本信息
+
+
+
角色ID{role_info.get('role_id', '-') if role_info else '-'}
+
账号ID{role_info.get('account_id', '-') if role_info else '-'}
+
角色姓名{role_info.get('nickname', '-') if role_info else '-'}
+
角色性别{role_info.get('gender', '-') if role_info else '-'}
+
角色年龄{f"{role_info['age']} 岁" if role_info and role_info.get('age') else '-'}
+
手机号后4位{role_info.get('phone_tail', '-') if role_info else '-'}
+
最近留存状态{retention_status}
+
注册时间{reg_time_str}
+
第一次完课时间{first_time_str}
+
最后一次完课时间{last_time_str}
+
最后一次完课Level {last_level} / {last_unit} / Lesson {last_lesson}
+
+
+
+
+
二、完课记录明细(共 {len(all_records)} 条)
+
+
+
+
+ | 序号 |
+ Level |
+ Unit(单元) |
+ Lesson |
+ 星期 |
+ 时段 |
+ 开始上课时间 |
+ 完课时间 |
+ 完课耗时 |
+
+
+
+ {rows_html}
+
+
+
+
+
+
三、周上课时间分布分析
+
+
+
+
四、跨周趋势分析
+
+
+
+
五、关键特征总结
+
+
+
分析时间:{now_str} | 完课总数:{len(all_records)} 条 | 非寒暑假:{len(non_holiday_records)} 条 | 寒暑假:{holiday_count} 条
+
+
+
+"""
+
+ return html
+
+
# ── 主函数 ────────────────────────────────────────────
def main():
- if len(sys.argv) < 2:
- print("用法: python3 studytime_analysis.py ", file=sys.stderr)
- sys.exit(1)
+ parser = argparse.ArgumentParser(description="角色学习时间分析工具")
+ parser.add_argument("role_id", type=int, help="角色ID")
+ parser.add_argument("--format", choices=["md", "html"], default="md",
+ help="输出格式(默认 md)")
+ parser.add_argument("--output", "-o", type=str, default=None,
+ help="输出文件路径(仅在 html 模式下生效,默认 stdout)")
+ args = parser.parse_args()
- try:
- role_id = int(sys.argv[1])
- except ValueError:
- print(f"错误: 角色ID必须是数字,收到: {sys.argv[1]}", file=sys.stderr)
- sys.exit(1)
+ role_id = args.role_id
all_records = fetch_completion_records(role_id)
non_holiday_records, holiday_records = split_records(all_records)
holiday_count = len(holiday_records)
- # 角色基本信息(MySQL)
role_info = fetch_role_info(role_id)
-
- # 留存状态判定
retention_status = check_retention(all_records)
- # 一周分布分析:仅用非寒暑假数据
day_counts, weekday_periods = analyze_weekly_distribution(non_holiday_records)
- # 跨周趋势分析:用全部数据
weeks_data, analysis = analyze_weekly_trend(all_records)
- report = format_report(role_id, role_info, retention_status, all_records, non_holiday_records, holiday_count, day_counts, weekday_periods, weeks_data, analysis)
-
- print(report)
+ if args.format == "html":
+ output = format_html(role_id, role_info, retention_status, all_records,
+ non_holiday_records, holiday_count,
+ day_counts, weekday_periods, weeks_data, analysis)
+ if args.output:
+ os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
+ with open(args.output, "w", encoding="utf-8") as f:
+ f.write(output)
+ print(f"HTML 报告已保存到: {args.output}")
+ else:
+ print(output)
+ else:
+ output = format_markdown(role_id, role_info, retention_status, all_records,
+ non_holiday_records, holiday_count,
+ day_counts, weekday_periods, weeks_data, analysis)
+ print(output)
if __name__ == "__main__":
diff --git a/⚠️ b/⚠️
new file mode 100644
index 0000000..e69de29