notes.js
1 import { cli, Strategy } from '@jackwener/opencli/registry'; 2 import { ArgumentError } from '@jackwener/opencli/errors'; 3 import { mubuPost, nodesToMarkdown, nodesToText, htmlToText } from './utils.js'; 4 5 // ── 日期工具 ────────────────────────────────────────────── 6 7 function localToday() { 8 const d = new Date(); 9 return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() }; 10 } 11 12 function lastDayOfMonth(year, month) { 13 return new Date(year, month, 0).getDate(); 14 } 15 16 function validateYear(year, label = '年份') { 17 if (!Number.isInteger(year) || year < 1) { 18 throw new ArgumentError(`${label} 非法:${year},应为正整数`); 19 } 20 } 21 22 function validateMonth(month) { 23 if (!Number.isInteger(month) || month < 1 || month > 12) { 24 throw new ArgumentError(`月份非法:${month},应为 1-12`); 25 } 26 } 27 28 function validateDay(year, month, day) { 29 const maxDay = lastDayOfMonth(year, month); 30 if (!Number.isInteger(day) || day < 1 || day > maxDay) { 31 throw new ArgumentError(`日期非法:${year}-${month}-${day}(${year} 年 ${month} 月共 ${maxDay} 天)`); 32 } 33 } 34 35 function parseDate(s) { 36 const parts = s.split('-').map(Number); 37 if (parts.length !== 3 || parts.some(isNaN)) { 38 throw new ArgumentError(`日期格式错误:${s},应为 YYYY-MM-DD`); 39 } 40 const [year, month, day] = parts; 41 validateYear(year); 42 validateMonth(month); 43 validateDay(year, month, day); 44 return { year, month, day }; 45 } 46 47 function parseMonth(s) { 48 const parts = s.split('-').map(Number); 49 if (parts.length !== 2 || parts.some(isNaN)) { 50 throw new ArgumentError(`月份格式错误:${s},应为 YYYY-MM`); 51 } 52 const [year, month] = parts; 53 validateYear(year); 54 validateMonth(month); 55 return { year, month }; 56 } 57 58 function dateToKey(d) { 59 return `${d.year}-${String(d.month).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`; 60 } 61 62 /** 将各种时间参数统一解析为 {start, end} */ 63 function resolveRange(kwargs) { 64 const dateStr = kwargs.date; 65 const monthStr = kwargs.month; 66 const yearArg = kwargs.year; 67 const fromStr = kwargs.from; 68 const toStr = kwargs.to; 69 70 // --from / --to 优先级最高 71 if (fromStr || toStr) { 72 if (!fromStr) throw new ArgumentError('使用 --to 时必须同时指定 --from'); 73 const start = parseDate(fromStr); 74 const end = toStr ? parseDate(toStr) : localToday(); 75 if (dateToKey(start) > dateToKey(end)) throw new ArgumentError('--from 不能晚于 --to'); 76 return { start, end }; 77 } 78 79 if (yearArg !== undefined && yearArg !== null) { 80 validateYear(yearArg, '--year'); 81 return { 82 start: { year: yearArg, month: 1, day: 1 }, 83 end: { year: yearArg, month: 12, day: 31 }, 84 }; 85 } 86 87 if (monthStr) { 88 const { year, month } = parseMonth(monthStr); 89 return { 90 start: { year, month, day: 1 }, 91 end: { year, month, day: lastDayOfMonth(year, month) }, 92 }; 93 } 94 95 if (dateStr) { 96 const d = parseDate(dateStr); 97 return { start: d, end: d }; 98 } 99 100 // 默认:今天 101 const today = localToday(); 102 return { start: today, end: today }; 103 } 104 105 // ── API 工具 ────────────────────────────────────────────── 106 107 async function getYearDocId(page, year) { 108 const raw = await page.evaluate(`localStorage.getItem('daily_notes_doc_list')`); 109 if (!raw) return null; 110 const list = JSON.parse(raw); 111 return list.find((d) => d.name === `${year}年`)?.id ?? null; 112 } 113 114 async function getYearNodes(page, docId) { 115 const data = await mubuPost(page, '/document/edit/get', { docId }); 116 const def = JSON.parse(data.definition); 117 return def.nodes ?? []; 118 } 119 120 /** 加载某年的所有 day 节点,返回带 dateKey 的列表 */ 121 async function loadYearEntries(page, year) { 122 const docId = await getYearDocId(page, year); 123 if (!docId) return []; 124 125 const yearNodes = await getYearNodes(page, docId); 126 const entries = []; 127 128 for (const monthNode of yearNodes) { 129 const monthNum = parseInt(htmlToText(monthNode.text), 10); 130 if (!monthNode.children?.length) continue; 131 132 for (const dayNode of monthNode.children) { 133 const plain = htmlToText(dayNode.text).replace(/\s+/g, ' ').trim(); 134 const compact = plain.replace(/\s/g, ''); 135 const match = compact.match(/^(\d+)月(\d+)日/); 136 if (!match) continue; 137 const m = parseInt(match[1], 10); 138 const d = parseInt(match[2], 10); 139 if (m !== monthNum) continue; 140 141 const dateKey = dateToKey({ year, month: m, day: d }); 142 entries.push({ dateKey, label: plain, node: dayNode }); 143 } 144 } 145 146 return entries; 147 } 148 149 /** 收集 [start, end] 范围内涉及的所有年份 */ 150 function yearsInRange(start, end) { 151 const years = []; 152 for (let y = start.year; y <= end.year; y++) years.push(y); 153 return years; 154 } 155 156 // ── 命令 ────────────────────────────────────────────────── 157 158 cli({ 159 site: 'mubu', 160 name: 'notes', 161 description: '读取幕布速记(默认今天)。支持 --date/--month/--year/--from/--to 指定时间范围,--list 为概览模式(日期+条数)。', 162 domain: 'mubu.com', 163 strategy: Strategy.COOKIE, 164 args: [ 165 { 166 name: 'list', 167 type: 'bool', 168 default: false, 169 help: '概览模式:只输出日期和条数,不含速记内容。可与任意时间范围参数组合。', 170 }, 171 { 172 name: 'date', 173 help: '单日,格式 YYYY-MM-DD。不指定时间范围则默认今天(系统本地时间)。', 174 }, 175 { 176 name: 'month', 177 help: '整月,格式 YYYY-MM。', 178 }, 179 { 180 name: 'year', 181 type: 'int', 182 help: '整年,格式 YYYY(整数)。', 183 }, 184 { 185 name: 'from', 186 help: '范围起始日,格式 YYYY-MM-DD。须与 --to 同时使用。', 187 }, 188 { 189 name: 'to', 190 help: '范围截止日,格式 YYYY-MM-DD。须与 --from 同时使用。', 191 }, 192 { 193 name: 'output', 194 default: 'md', 195 help: '输出格式:md(默认,Markdown)或 text(纯文本)', 196 }, 197 ], 198 columns: ['date', 'content'], 199 func: async (page, kwargs) => { 200 const isList = kwargs.list; 201 const format = kwargs.output; 202 if (format !== 'md' && format !== 'text') { 203 throw new ArgumentError(`--output 只接受 md 或 text,收到:${format}`); 204 } 205 206 await page.goto('https://mubu.com/app'); 207 208 const { start, end } = resolveRange(kwargs); 209 const startKey = dateToKey(start); 210 const endKey = dateToKey(end); 211 212 // 并行加载所有涉及年份的 day 节点,按范围过滤 213 const yearResults = await Promise.all( 214 yearsInRange(start, end).map((year) => loadYearEntries(page, year)), 215 ); 216 const allEntries = yearResults 217 .flat() 218 .filter((e) => e.dateKey >= startKey && e.dateKey <= endKey); 219 220 if (allEntries.length === 0) { 221 const label = startKey === endKey ? startKey : `${startKey} ~ ${endKey}`; 222 return [{ date: label, content: '该时间段暂无速记' }]; 223 } 224 225 // 概览模式 226 if (isList) { 227 return allEntries.map((e) => ({ 228 date: e.label, 229 content: `${e.node.children?.length ?? 0} 条记录`, 230 })); 231 } 232 233 // 内容模式 234 const render = (children) => 235 format === 'text' ? nodesToText(children) : nodesToMarkdown(children); 236 237 return allEntries 238 .filter((e) => e.node.children?.length) 239 .map((e) => ({ 240 date: e.label, 241 content: render(e.node.children ?? []) || '(空)', 242 })); 243 }, 244 });