dsh-social-trace 0.1.13 → 0.1.18

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/lib/client.js CHANGED
@@ -55,7 +55,7 @@
55
55
  .st-independent{border-top:1px solid var(--line);padding-top:12px}.st-independent h3{font-size:13px;margin:0 0 10px}.st-independent-list{max-height:180px;overflow:auto}.st-independent-list>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:10px 0;border-bottom:1px solid var(--line);font-size:12px}.st-independent-list small{grid-column:1;color:var(--muted);line-height:1.6}.st-independent-list button{grid-column:2;grid-row:1/3;align-self:center}
56
56
 
57
57
  .st .st-library-tree{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;padding-top:14px}.st .st-library-tree .st-author-folder{margin:0;min-width:0}.st .st-library-tree .st-author-folder[open]{grid-column:1/-1}.st .st-library-tree .st-author-folder>summary{display:grid;grid-template-columns:24px minmax(0,1fr);gap:2px 6px;padding:10px;align-items:center}.st-library-tree .st-author-folder>summary>.st-chevron{display:none}.st-library-tree .st-author-folder>summary>.st-flow-avatar{grid-row:1/3}.st-library-tree .st-author-folder>summary>strong{font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.st-library-tree .st-author-folder>summary>small{font-size:10px;margin:0}.st-library-tree .st-author-folder>summary>b{grid-column:1/-1;font-size:10px;font-weight:400;margin:5px 0 0;text-align:left;white-space:normal}.st-library-pages{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:12px 0 0;font-size:11px;color:var(--muted)}.st .st-library-pages button{padding:2px 9px;font-size:16px}
58
- .st .st-meta-tag{display:inline-flex;align-items:center;max-width:100%;font-size:10px;font-weight:400;line-height:1.6;white-space:nowrap;padding:2px 6px;border:1px solid var(--line);border-radius:5px;background:var(--inset);color:var(--muted)}.st .st-author-open .st-uid-tag{margin-top:5px}.st .st-platform-badge{color:var(--accent);border-color:#7b5cff25;background:#7b5cff08}.st-author-table th:first-child{width:26%}.st .st-sort-heading{border:0;background:transparent;padding:0;display:inline-flex;gap:6px;align-items:center;color:inherit;font-size:12px}.st-sort-heading>span{color:var(--muted);font-size:10px}.st-reader-facts{max-width:900px}
58
+ .st .st-meta-tag{display:inline-flex;align-items:center;max-width:100%;font-size:10px;font-weight:400;line-height:1.6;white-space:nowrap;padding:2px 6px;border:1px solid var(--line);border-radius:5px;background:var(--inset);color:var(--muted)}.st .st-author-open .st-uid-tag{max-width:360px;overflow:hidden;text-overflow:ellipsis;margin-top:5px}.st .st-platform-badge{color:var(--accent);border-color:#7b5cff25;background:#7b5cff08}.st-author-table th:first-child{width:26%}.st .st-sort-heading{border:0;background:transparent;padding:0;display:inline-flex;gap:6px;align-items:center;color:inherit;font-size:12px}.st-sort-heading>span{color:var(--muted);font-size:10px}.st-reader-facts{max-width:900px}
59
59
 
60
60
  .st-reader-tabs{display:flex;align-items:center;gap:24px;border-bottom:1px solid var(--line);margin-bottom:20px}.st .st-reader-tabs button{border:0;border-bottom:2px solid transparent;background:transparent;border-radius:0;padding:12px 2px;color:var(--muted);font-size:13px}.st .st-reader-tabs button[aria-selected=true]{border-bottom-color:var(--accent);color:var(--accent)}.st-inline-settings{max-width:900px;padding:0 0 24px}.st-inline-settings form{display:grid;gap:16px}.st-inline-settings form label:not(.st-schedule-switch){display:grid;gap:6px}.st-inline-settings .st-task-control{grid-template-columns:72px auto;justify-content:start}.st-inline-settings .st-history-panel{max-width:700px}.st .st-author-progress{border-top:0;padding-top:0}.st-inline-settings .st-history-panel>.st-caption{margin-bottom:14px}
61
61
 
@@ -97,7 +97,7 @@
97
97
  const channelNames = { weixin: '微信', qq: 'QQ', feishu: '飞书', dingtalk: '钉钉', telegram: 'Telegram', slack: 'Slack', discord: 'Discord', wecom: '企业微信' };
98
98
  const pushDestinations = config => Array.isArray(config?.destinations) ? config.destinations : config?.botId && config?.targetId ? [{ botId: config.botId, targetId: config.targetId, enabled: true }] : [];
99
99
  const pushLabels = { unavailable: 'dsh-im 未接入', paused: '已暂停', checking: '正在读取配置', invalid: '配置需检查', failed: '发送失败', accepted: '最近发送成功', configured: '已配置 · 尚无成功发送记录' };
100
- const platformNames = { weibo: '微博', xueqiu: '雪球', x: 'X', bilibili: 'B 站', rednote: '小红书' };
100
+ const platformNames = { douyin: '抖音', weibo: '微博', xueqiu: '雪球', x: 'X', bilibili: 'B 站', rednote: '小红书' };
101
101
  const platformAccount = (state, id) => state.accounts?.[id] || state.account;
102
102
  const platformReady = (state, id) => {
103
103
  const account = platformAccount(state, id);
@@ -112,9 +112,12 @@
112
112
  ?? state?.targets.find(item => item.uid === uid)?.interval_ms ?? source?.targetDefaults?.intervalMs ?? state?.targetDefaults?.intervalMs;
113
113
  }
114
114
  const chinaClock = (value, seconds = true) => new Date(value).toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai', hourCycle: 'h23', hour: '2-digit', minute: '2-digit', ...(seconds ? { second: '2-digit' } : {}) });
115
- const taskProblem = job => job.kind === 'history' && /长文接口未返回全文/.test(job.message || '')
116
- ? '有一条长内容暂时无法完整读取,历史缓存已暂停;已保存内容保留,新动态监控不受此问题影响。'
117
- : (job.kind === 'history' ? '历史缓存已暂停:' : '新动态检查已暂停:') + (job.message || '读取未完成,请重试');
115
+ const shortUid = value => { const text = String(value || ''); return text.length > 22 ? text.slice(0, 10) + '…' + text.slice(-6) : text; };
116
+ const taskProblem = job => job.contentIssue
117
+ ? job.contentIssue.exhausted ? '自动重试仍未获取这条微博的全文;可查看原文,或跳过这条继续。' : '正在自动重试这条内容,无需操作。'
118
+ : job.kind === 'history' && /长文接口未返回全文/.test(job.message || '')
119
+ ? '这条微博的全文未获取到,插件会先自动重试,已有内容保留。'
120
+ : (job.kind === 'history' ? '历史缓存已暂停:' : '新动态检查已暂停:') + (job.message || '读取未完成,已保留进度');
118
121
  function checkHealthReason(source, uid = '') {
119
122
  const health = source?.health;
120
123
  if (!health) return '';
@@ -128,7 +131,7 @@
128
131
  const target = state?.targets.find(item => item.uid === uid);
129
132
  const source = state?.platforms?.find(item => item.id === platform);
130
133
  const account = state && platformAccount(state, platform);
131
- const result = (label, kind, reason, section = 'account') => ({ label, kind, reason, section });
134
+ const result = (label, kind, reason, section = 'account', affectedUid = '') => ({ label, kind, reason, section, ...(affectedUid ? { uid: affectedUid } : {}) });
132
135
  if (state?.update?.phase === 'applying') return result('插件更新中', 'paused', '等待版本更新完成,监控配置保持不变', 'service');
133
136
  if (!state || state.stopped) return result('后台未就绪', 'blocked', state?.serviceError || '等待本地服务连接');
134
137
  if (state.enabled === false) return result('总控已暂停', 'paused', '开启总控制器后继续,博主和平台设置保持不变');
@@ -143,11 +146,13 @@
143
146
  if (targets.length && targets.every(item => item.capture_mode === 'pending')) return result('等待配置博主', 'paused', '选择博主并确认记录方式', 'archive');
144
147
  if (targets.length && targets.every(item => item.enabled === false)) return result('博主均已暂停', 'paused', '选择至少一位博主开启监控', 'archive');
145
148
  const jobs = state.jobs.filter(job => (!uid || job.uid === uid) && (job.platform || state.targets.find(item => item.uid === job.uid)?.platform || 'weibo') === platform);
149
+ const retrying = jobs.filter(job => job.status === 'queued' && job.contentIssue && !job.contentIssue.exhausted);
150
+ if (retrying.length) return result('正在恢复缓存 · 无需操作', 'partial', taskProblem(retrying[0]), 'tasks', retrying[0].uid);
146
151
  const problems = jobs.filter(job => job.status === 'needs_review');
147
152
  if (problems.length) {
148
153
  const historyOnly = problems.every(job => job.kind === 'history');
149
154
  const names = [...new Set(problems.map(job => state.targets.find(item => item.uid === job.uid)?.name || job.uid))];
150
- return result(historyOnly ? names.length + ' 位博主的历史缓存已暂停' : names.length + ' 位博主的新动态检查需处理', jobs.some(job => job.status === 'queued') ? 'partial' : 'blocked', names.join('、') + ':' + taskProblem(problems[0]), 'tasks');
155
+ return result(historyOnly ? names[0] + (names.length > 1 ? '等 ' + names.length + ' 位博主' : '') + (problems.some(job => job.contentIssue || /长文接口未返回全文/.test(job.message || '')) ? ':有微博未读完整' : ':历史缓存中断') : names.length + ' 位博主的新动态检查需处理', jobs.some(job => job.status === 'queued') ? 'partial' : 'blocked', names.join('、') + ':' + taskProblem(problems[0]), 'tasks', problems[0].uid);
151
156
  }
152
157
  if (jobs.length && jobs.every(job => ['paused', 'complete'].includes(job.status))) return result('任务已暂停', 'paused', '在大屏开启需要执行的任务', 'tasks');
153
158
  const healthReason = jobs.some(job => job.kind === 'updates' && job.status === 'queued') && checkHealthReason(source, uid);
@@ -181,7 +186,8 @@
181
186
  if (job?.status === 'complete') return '已完成';
182
187
  if (kind === 'history' && job.status === 'paused' && /未开启历史缓存|尚未开启历史缓存/.test(job.message || '')) return '未开启';
183
188
  if (state.enabled === false || state.platforms?.find(item => item.id === target.platform)?.enabled === false || target.enabled === false || job?.status === 'paused') return '已暂停';
184
- if (job.status === 'needs_review') return '需处理';
189
+ if (job.status === 'needs_review') return job.contentIssue ? '有内容未读完整' : '需处理';
190
+ if (job.status === 'queued' && job.contentIssue && !job.contentIssue.exhausted) return '自动重试中';
185
191
  if (!platformReady(state, target.platform || 'weibo')) return '等待连接';
186
192
  if (kind === 'updates' && state.platforms?.find(item => item.id === (target.platform || 'weibo'))?.health?.delayed.some(check => check.uid === target.uid)) return '检查延迟';
187
193
  if (kind === 'updates' && progressFor(state, target.uid).catchup) return '补查中';
@@ -474,20 +480,80 @@
474
480
  }
475
481
  function AddAuthorDialog({ state, act, busy, error, close, connect, initialPlatform = 'weibo', clearError = () => {} }) {
476
482
  const dialog = React.useRef(null), [url, setUrl] = React.useState('');
477
- const [selectedPlatform, setSelectedPlatform] = React.useState(['weibo', 'xueqiu'].includes(initialPlatform) ? initialPlatform : 'weibo');
483
+ const [selectedPlatform, setSelectedPlatform] = React.useState(['weibo', 'xueqiu', 'douyin'].includes(initialPlatform) ? initialPlatform : 'weibo');
478
484
  const name = platformNames[selectedPlatform], connected = platformAccount(state, selectedPlatform)?.status === 'ready';
479
485
  React.useEffect(() => { dialog.current?.showModal(); dialog.current?.querySelector('input')?.focus(); }, []);
480
486
  return h('dialog', { ref: dialog, className: 'st-node-dialog', 'aria-label': '添加' + name + '博主', onCancel: close },
481
487
  h('div', { className: 'st-dialog-head' }, h('h2', null, '添加' + name + '博主'), h('button', { className: 'st-dialog-close', 'aria-label': '关闭添加博主', onClick: close }, '×')),
482
- h('div', { className: 'st-platform-tabs', role: 'tablist', 'aria-label': '添加博主的平台' }, ['weibo', 'xueqiu'].map(id => h('button', { key: id, role: 'tab', 'aria-selected': selectedPlatform === id, onClick: () => { setSelectedPlatform(id); setUrl(''); clearError(); } }, platformNames[id]))),
488
+ h('div', { className: 'st-platform-tabs', role: 'tablist', 'aria-label': '添加博主的平台' }, ['weibo', 'xueqiu', 'douyin'].map(id => h('button', { key: id, role: 'tab', 'aria-selected': selectedPlatform === id, onClick: () => { setSelectedPlatform(id); setUrl(''); clearError(); } }, platformNames[id]))),
483
489
  !connected ? h('div', null, h('p', null, '先连接你的' + name + '账号,再读取要关注的博主。'), h('button', { className: 'st-primary', onClick: () => connect(selectedPlatform) }, '去连接' + name))
484
490
  : h('form', { onSubmit: async event => { event.preventDefault(); if (await act('/targets', { platform: selectedPlatform, url })) close(); } },
485
- h('label', null, '博主主页链接或数字 UID', h('input', { 'aria-label': name + '博主主页或 UID', value: url, required: true, placeholder: selectedPlatform === 'weibo' ? 'https://weibo.com/u/…' : 'https://xueqiu.com/u/…', onChange: event => { setUrl(event.target.value); clearError(); } })),
491
+ h('label', null, selectedPlatform === 'douyin' ? '博主主页链接或 sec_uid' : '博主主页链接或数字 UID', h('input', { 'aria-label': name + '博主主页或 UID', value: url, required: true, placeholder: selectedPlatform === 'douyin' ? 'https://www.douyin.com/user/…' : selectedPlatform === 'weibo' ? 'https://weibo.com/u/…' : 'https://xueqiu.com/u/…', onChange: event => { setUrl(event.target.value); clearError(); } })),
486
492
  h('p', { className: 'st-caption' }, '先读取昵称、头像和消息总数,再由你决定从现在记录,还是缓存以前的消息。'),
487
493
  error ? h('p', { className: 'st-inline-error', role: 'alert' }, error) : null,
488
494
  h('button', { className: 'st-primary', disabled: busy }, busy ? '正在添加…' : '添加并读取资料')));
489
495
  }
490
- function NodeSettings({ context, state, act, busy, error, navigate, close, openSettings, embedded = false }) {
496
+ /* DOUYIN_UI_START */
497
+ const { DouyinConfigForm, DouyinNodeSettings } = (function createDouyinUI({ React, h, SettingRow, TaskControl, LegacyNodeSettings }) {
498
+ function DouyinConfigForm({ state, uid = '', act, busy, part = 'all' }) {
499
+ const base = { times: ['20:00'], repeat: 'daily', platformAI: true, saveVideo: false, saveAudio: false, transcribe: false, maxFileMB: 256, python: 'python3', ffmpeg: 'ffmpeg', modelPath: '', threads: 2, ...(state.douyin?.config || {}) };
500
+ const [value, setValue] = React.useState(() => ({ ...base, ...(state.douyin?.overrides?.[uid] || {}) }));
501
+ const [inherit, setInherit] = React.useState(!!uid && !state.douyin?.overrides?.[uid]);
502
+ const [message, setMessage] = React.useState('');
503
+ const change = (key, val) => { if (uid && inherit) { setInherit(false); setMessage('已切换为这位博主的独立配置,保存后生效'); } else setMessage(''); setValue(old => ({ ...old, [key]: val })); };
504
+ const disabled = busy;
505
+ return h('form', { className: 'st-douyin-config', 'aria-label': uid ? '抖音博主配置' : '抖音平台默认配置', onSubmit: async event => {
506
+ event.preventDefault();
507
+ if (await act('/douyin/config', { platform: 'douyin', uid: uid || undefined, inherit, section: part, config: value })) setMessage('抖音配置已保存');
508
+ } },
509
+ uid ? h(SettingRow, { label: '跟随平台', description: '修改下方选项会自动切换为独立配置,其他博主不受影响' }, h('input', { type: 'checkbox', role: 'switch', 'aria-label': '跟随抖音平台配置', checked: inherit, disabled: busy, onChange: event => { setInherit(event.target.checked); if (event.target.checked) setValue(base); } })) : h('p', { className: 'st-caption' }, '只更新跟随平台的博主;独立配置保持不变。'),
510
+ part !== 'media' ? h('section', { 'aria-label': '抖音定时检查' }, h('h3', null, '定时检查'),
511
+ h(SettingRow, { label: '执行日期' }, h('select', { 'aria-label': '抖音检查日期', value: value.repeat, disabled, onChange: event => change('repeat', event.target.value) }, h('option', { value: 'daily' }, '每天'), h('option', { value: 'weekdays' }, '工作日(周一至周五)'))),
512
+ value.times.map((time, index) => h(SettingRow, { key: index, label: '检查时间 ' + (index + 1), description: '北京时间' }, h('div', { className: 'st-actions' }, h('input', { type: 'time', 'aria-label': '抖音检查时间 ' + (index + 1), value: time, required: true, disabled, onChange: event => change('times', value.times.map((item, at) => at === index ? event.target.value : item)) }),
513
+ value.times.length > 1 ? h('button', { type: 'button', disabled, onClick: () => change('times', value.times.filter((item, at) => at !== index)) }, '删除') : null))),
514
+ h('button', { type: 'button', disabled: disabled || value.times.length >= 8, onClick: () => change('times', [...value.times, '']) }, '+ 添加检查时间'),
515
+ h('p', { className: 'st-caption' }, '只在指定时间检查;重启后会补查离线更新。下载与转写单独排队,不阻塞检查。')) : null,
516
+ part !== 'frequency' ? h('section', { 'aria-label': '抖音内容保存' }, h('h3', null, '内容保存'),
517
+ [['platformAI', '平台 AI 解析', '保存平台返回的摘要与章节,不是作者逐字原话'], ['saveVideo', '保存视频', '把新发现的视频下载到本地'], ['saveAudio', '保存音频', '从视频音轨提取,不把背景音乐当人声'], ['transcribe', '本地转写', '用本地 SenseVoice 生成识别稿;一次处理一个作品']].map(([key, label, description]) => h(SettingRow, { key, label, description }, h('input', { type: 'checkbox', role: 'switch', 'aria-label': label, checked: value[key], disabled, onChange: event => change(key, event.target.checked) }))),
518
+ h(SettingRow, { label: '单文件上限', description: 'MB,超过上限会停止该文件下载' }, h('input', { type: 'number', 'aria-label': '抖音单文件上限', min: 16, max: 2048, value: value.maxFileMB, disabled, onChange: event => change('maxFileMB', Number(event.target.value)) })),
519
+ value.saveAudio || value.transcribe ? h(SettingRow, { label: 'ffmpeg', description: '程序名或完整路径;音频和转写需要' }, h('input', { 'aria-label': 'ffmpeg 程序', value: value.ffmpeg, disabled, onChange: event => change('ffmpeg', event.target.value) })) : null,
520
+ value.transcribe ? h(React.Fragment, null,
521
+ h(SettingRow, { label: 'Python', description: '需安装 FunASR、PyTorch 依赖' }, h('input', { 'aria-label': '转写 Python', value: value.python, disabled, onChange: event => change('python', event.target.value) })),
522
+ h(SettingRow, { label: '模型目录', description: '已下载的 SenseVoice 本地目录;不会自动下载模型' }, h('input', { 'aria-label': 'SenseVoice 模型目录', value: value.modelPath, disabled, onChange: event => change('modelPath', event.target.value) })),
523
+ h(SettingRow, { label: 'CPU 线程', description: '1–4;空闲时不常驻模型' }, h('input', { type: 'number', 'aria-label': '转写 CPU 线程', min: 1, max: 4, value: value.threads, disabled, onChange: event => change('threads', Number(event.target.value)) }))) : null,
524
+ h('p', { className: 'st-caption' }, '关闭选项会停止后续处理,已有文件保留;音频和转写支持一小时以内的作品。图集保留配图,不生成音轨。')) : null,
525
+ message ? h('p', { role: 'status', className: 'st-caption' }, message) : null,
526
+ h('button', { className: 'st-primary', disabled: busy, type: 'submit' }, '保存抖音配置'));
527
+ }
528
+ function DouyinNodeSettings(props) {
529
+ const { context, state, act, busy, close, embedded } = props;
530
+ const dialog = React.useRef(null), [tab, setTab] = React.useState(context.tab || 'frequency');
531
+ const target = state.targets.find(item => item.uid === context.uid);
532
+ React.useEffect(() => { if (!embedded) dialog.current?.showModal(); }, []);
533
+ return h(embedded ? 'section' : 'dialog', { ref: embedded ? undefined : dialog, className: embedded ? 'st-inline-settings' : 'st-node-dialog', 'aria-label': '抖音详细设置', onCancel: close },
534
+ !embedded ? h('div', { className: 'st-dialog-head' }, h('h2', null, (target?.name || '抖音平台') + '的详细设置'), h('button', { onClick: close, 'aria-label': '关闭监控设置' }, '×')) : null,
535
+ h('div', { className: 'st-platform-tabs', role: 'tablist', 'aria-label': '抖音配置分组' }, [['frequency', '定时检查'], ['media', '内容保存'], ['history', '历史缓存']].map(([id, title]) => h('button', { key: id, type: 'button', role: 'tab', 'aria-selected': tab === id, onClick: () => setTab(id) }, title))),
536
+ props.error ? h('p', { role: 'alert', className: 'st-inline-error' }, props.error) : null,
537
+ h('div', { hidden: tab === 'history' }, target && tab === 'frequency' ? h(TaskControl, { state, target, kind: 'updates', act, busy, openSettings: props.openSettings }) : null, h(DouyinConfigForm, { state, uid: target?.uid, act, busy, part: tab === 'media' ? 'media' : 'frequency' })), tab === 'history' ? h(LegacyNodeSettings, { ...props, embedded: true, context: { ...context, tab: 'history' } }) : null);
538
+ }
539
+ return { DouyinConfigForm, DouyinNodeSettings };
540
+ })({ React, h, SettingRow, TaskControl, LegacyNodeSettings });
541
+ /* DOUYIN_UI_END */
542
+ function NodeSettings(props) { return h(props.context.platform === 'douyin' ? DouyinNodeSettings : LegacyNodeSettings, props); }
543
+ function ContentProblem({ job, name, act, busy, paused = false }) {
544
+ const issue = job.contentIssue;
545
+ if (!issue) return h('p', { className: 'st-inline-error', role: 'alert' }, h('strong', null, name), ' · ', taskProblem(job), ' ', h('button', { disabled: busy, onClick: () => act('/jobs/' + job.id + '/resume') }, job.kind === 'history' ? '重试历史缓存' : '重试新动态监控'));
546
+ const waiting = !issue.exhausted, stopped = paused || job.status === 'paused';
547
+ return h('section', { className: 'st-inline-error st-content-problem', role: waiting ? 'status' : 'alert', 'aria-label': '未读完整的内容' },
548
+ h('strong', null, stopped ? '已暂停,恢复后继续读取' : waiting ? '正在自动恢复,无需操作' : '这条微博仍未读完整'),
549
+ h('p', null, name, ' · ', issue.stage, issue.publishedAt ? ' · 发布于 ' + date(issue.publishedAt) : ''),
550
+ issue.excerpt ? h('p', { className: 'st-text' }, issue.excerpt) : null,
551
+ h('p', null, stopped ? '你已暂停监控或缓存,插件不会继续请求。' : waiting ? '微博暂未返回全文;将在 ' + chinaClock(issue.nextRetryAt, false) + ' 自动重试(' + issue.attempts + '/' + issue.maxAttempts + '),不需要点击重试。' : '已自动重试 ' + issue.attempts + ' 次,微博仍未返回全文。' + (job.kind === 'history' ? '历史缓存停在这条;已有记录和新动态监控不受影响。' : '新动态检查停在这条;其他博主和已存记录不受影响。')),
552
+ h('div', { className: 'st-actions' }, /^https:\/\/weibo\.com\/detail\/\d+$/.test(issue.url || '') ? h('a', { href: issue.url, target: '_blank', rel: 'noreferrer' }, '查看这条微博 ↗') : null,
553
+ !waiting && job.status === 'needs_review' ? h('button', { disabled: busy, className: 'st-primary', onClick: () => act('/jobs/skip-content', { id: job.id, postId: issue.postId, platform: job.platform }) }, job.kind === 'history' ? '跳过这条,继续缓存' : '跳过这条,继续检查') : null),
554
+ !waiting ? h('p', { className: 'st-caption' }, '跳过时保留已经读到的片段和原文链接,不标记为全文。') : null);
555
+ }
556
+ function LegacyNodeSettings({ context, state, act, busy, error, navigate, close, openSettings, embedded = false }) {
491
557
  const dialog = React.useRef(null);
492
558
  const target = state.targets.find(item => item.uid === context.uid), source = state.platforms?.find(item => item.id === context.platform);
493
559
  const [value, setValue] = React.useState(() => ({ intervalMinutes: (target?.policy?.intervalMs || target?.interval_ms || source?.targetDefaults?.intervalMs || 900000) / 60000,
@@ -507,18 +573,18 @@
507
573
  const running = owners.some(owner => streamStatus(state, owner, 'history') === '缓存中');
508
574
  const started = history.some(job => job.pages > 0 || job.pending > 0 || job.page > 1) || owners.some(owner => owner.capture_mode === 'history' || progressFor(state, owner.uid).history.started);
509
575
  const progress = target && progressFor(state, target.uid);
510
- const problems = state.jobs.filter(job => job.status === 'needs_review' && job.kind === (tab === 'history' ? 'history' : 'updates') && owners.some(owner => owner.uid === job.uid));
576
+ const problems = state.jobs.filter(job => (job.status === 'needs_review' || job.contentIssue) && job.kind === (tab === 'history' ? 'history' : 'updates') && owners.some(owner => owner.uid === job.uid));
511
577
  const run = async (path, body, message) => { if (await act(path, { platform: context.platform, ...body })) { setMessage(message); return true; } return false; };
512
578
  React.useEffect(() => { if (!embedded) dialog.current?.showModal(); }, []);
513
579
  return h(embedded ? 'section' : 'dialog', { ref: embedded ? undefined : dialog, className: embedded ? 'st-inline-settings' : 'st-node-dialog', role: embedded ? 'region' : undefined, 'aria-label': '节点监控设置', onCancel: embedded ? undefined : close },
514
580
  !embedded ? h('div', { className: 'st-dialog-head' }, h('h2', null, target ? target.name + '的监控设置' : context.scope === 'all' ? '平台默认监控设置' : platformNames[context.platform] + '监控设置'), h('button', { className: 'st-dialog-close', 'aria-label': '关闭监控设置', onClick: close }, '×')) : null,
515
581
  !embedded ? h('p', { className: 'st-caption' }, target ? '只影响这位博主。' : tab === 'frequency' ? '这是平台默认频率。只有跟随平台设置的博主会更新,单独设置的博主保持不变。' : '批量管理博主的历史缓存,不改变各博主的监控频率。') : null,
516
- !['weibo', 'xueqiu'].includes(context.platform) ? h('p', null, '开发中') : h(React.Fragment, null,
582
+ !['weibo', 'xueqiu', 'douyin'].includes(context.platform) ? h('p', null, '开发中') : h(React.Fragment, null,
517
583
  error ? h('p', { className: 'st-inline-error', role: 'alert' }, error) : null,
518
584
  message ? h('p', { className: 'st-dialog-status', role: 'status' }, message) : null,
519
585
  !embedded ? h('div', { className: 'st-platform-tabs', role: 'tablist', 'aria-label': '节点配置' }, [['frequency', '新动态监控'], ['history', '历史消息缓存']].map(([id, label]) =>
520
586
  h('button', { key: id, id: 'st-node-tab-' + id, role: 'tab', 'aria-selected': tab === id, 'aria-controls': 'st-node-panel-' + id, onClick: () => { setTab(id); setMessage(''); } }, label))) : null,
521
- problems.map(job => h('p', { key: job.id, className: 'st-inline-error', role: 'alert' }, h('strong', null, owners.find(owner => owner.uid === job.uid)?.name || job.uid), ' · ', taskProblem(job), ' ', h('button', { disabled: busy, onClick: () => act('/jobs/' + job.id + '/resume') }, job.kind === 'history' ? '重试历史缓存' : '重试新动态监控'))),
587
+ problems.map(job => h(ContentProblem, { key: job.id, job, name: owners.find(owner => owner.uid === job.uid)?.name || job.uid, act, busy, paused })),
522
588
  tab === 'frequency' && progress?.catchup ? h('p', { className: 'st-caption', role: 'status' }, streamStatus(state, target, 'updates') === '补查中' ? '正在补查离线期间的动态:从 ' : '离线补查' + streamStatus(state, target, 'updates') + ':从 ', date(progress.catchup.from), ' 比对,第 ', progress.catchup.page, ' 页,已补存 ', progress.catchup.saved, ' 条。完成后恢复设定频率。') : null,
523
589
  tab === 'frequency' ? h('form', { id: 'st-node-panel-frequency', role: 'tabpanel', 'aria-labelledby': embedded ? 'st-reader-tab-frequency' : 'st-node-tab-frequency', onSubmit: event => { event.preventDefault(); void run(target ? '/targets/policy' : '/settings', target ? { uid: target.uid, inherit: false, intervalMinutes: value.intervalMinutes, schedule: value.schedule } : { intervalMinutes: value.intervalMinutes, schedule: value.schedule }, '监控时间与频率已保存'); } },
524
590
  target ? h('p', { className: 'st-caption' }, target.policy ? '当前为这位博主单独设置。' : '已填入' + platformNames[context.platform] + '平台的时间;保存后只对这位博主生效。') : null,
@@ -546,8 +612,9 @@
546
612
  h('p', { className: 'st-history-state' }, '历史消息:', target ? streamStatus(state, target, 'history') : running ? '缓存中' : history.some(job => job.status === 'queued') ? '等待执行' : complete ? '已完成' : '已暂停'),
547
613
  h('p', { className: 'st-visibility-note' }, '只能缓存当前登录账号可见的消息。已删除或当前账号无权查看的消息无法缓存。'),
548
614
  target && progress ? h('dl', { className: 'st-cache-facts' },
549
- h('div', null, h('dt', null, '博主消息总数(含转发)'), h('dd', null, totalText(progress.history))),
615
+ h('div', null, h('dt', null, context.platform === 'douyin' ? '博主作品总数(视频与图集)' : '博主消息总数(含转发)'), h('dd', null, totalText(progress.history))),
550
616
  h('div', null, h('dt', null, '本地累计(新+历史)'), h('dd', null, progress.saved.toLocaleString(), ' 条')),
617
+ progress.history.incompleteCount ? h('div', null, h('dt', null, '其中仅保存片段'), h('dd', null, progress.history.incompleteCount, ' 条,未标记为全文')) : null,
551
618
  progress.history.originalOnly ? h('div', null, h('dt', null, '所选范围内原创已存'), h('dd', null, (progress.history.rangeSaved || 0).toLocaleString(), ' 条')) : null,
552
619
  h('div', null, h('dt', null, '预计剩余'), h('dd', null, progress.history.originalOnly ? '原创总数未知,无法估算' : typeof progress.history.estimatedRemaining === 'number' ? progress.history.estimatedRemaining.toLocaleString() + ' 条' : progress.history.status === 'complete' ? '可见范围已缓存' : '暂时无法估算')),
553
620
  h('div', null, h('dt', null, '最近保存'), h('dd', null, progress.lastSavedAt ? date(progress.lastSavedAt) : '尚未保存'))) : null,
@@ -557,10 +624,10 @@
557
624
  !target ? h('form', { className: 'st-history-pace', onSubmit: event => { event.preventDefault(); void run('/settings/history', { historyGapSeconds: value.historyGapSeconds }, '历史缓存速度已保存'); } },
558
625
  h(SettingRow, { label: '缓存速度', htmlFor: 'st-history-pace', description: '每页读取后的等待秒数,不影响新动态频率' }, h('input', { id: 'st-history-pace', type: 'number', min: 60, max: 86400, required: true, 'aria-label': '历史缓存每页等待秒数', value: value.historyGapSeconds, disabled: busy, onChange: event => setValue({ ...value, historyGapSeconds: Number(event.target.value) }) })),
559
626
  h('button', { type: 'submit', disabled: busy }, '保存缓存速度')) : null,
560
- h('label', { className: 'st-original-only' }, h('span', null, '仅缓存原创(不含转发)'), h('input', { type: 'checkbox', role: 'switch', checked: originalOnly, disabled: busy, onChange: event => setOriginalOnly(event.target.checked) })),
627
+ context.platform !== 'douyin' ? h('label', { className: 'st-original-only' }, h('span', null, '仅缓存原创(不含转发)'), h('input', { type: 'checkbox', role: 'switch', checked: originalOnly, disabled: busy, onChange: event => setOriginalOnly(event.target.checked) })) : null,
561
628
  paused ? h('p', { className: 'st-caption' }, '监控开关已暂停,安排的缓存会在恢复后执行。') : null,
562
629
  h('div', { className: 'st-actions', style: { marginTop: 14 } },
563
- !complete && !scheduled ? h('button', { className: 'st-primary', disabled: busy || !owners.length, onClick: () => run(target?.capture_mode === 'pending' ? '/targets/start' : '/history/range', { uid: target?.uid, mode: 'history', scope: target ? 'target' : 'platform', from: range.from, to: range.to, originalOnly, enableTarget: !!target }, paused ? '已安排缓存,恢复监控开关后执行' : '缓存任务已更新') }, target?.capture_mode === 'pending' ? '确认并开始记录' : !unchanged ? '应用筛选并缓存' : started ? '继续缓存' : '开始缓存') : null,
630
+ !complete && !scheduled && !problems.length ? h('button', { className: 'st-primary', disabled: busy || !owners.length, onClick: () => run(target?.capture_mode === 'pending' ? '/targets/start' : '/history/range', { uid: target?.uid, mode: 'history', scope: target ? 'target' : 'platform', from: range.from, to: range.to, originalOnly, enableTarget: !!target }, paused ? '已安排缓存,恢复监控开关后执行' : '缓存任务已更新') }, target?.capture_mode === 'pending' ? '确认并开始记录' : !unchanged ? '应用筛选并缓存' : started ? '继续缓存' : '开始缓存') : null,
564
631
  complete ? h('span', { className: 'st-caption' }, '所选可见消息已缓存;更改筛选后可重新缓存。') : null,
565
632
  history.some(job => job.status === 'queued') ? h('button', { className: scheduled ? 'st-primary' : '', disabled: busy, onClick: () => run('/history/pause', { uid: target?.uid, scope: target ? 'target' : 'platform' }, '以前消息的缓存已暂停,进度保留') }, '暂停缓存') : null))) : null));
566
633
  }
@@ -586,7 +653,7 @@
586
653
  const progress = progressFor(state, target.uid);
587
654
  const field = (label, value) => h('div', null, h('dt', null, label), h('dd', null, value));
588
655
  return h('section', { className: 'st-author-progress', 'aria-label': target.name + '保存进度' },
589
- h('dl', { className: 'st-save-facts st-reader-facts' }, field('消息总数(含转发)', totalText(progress.history)), field('本地已存', progress.saved.toLocaleString() + ' 条'), field('最近保存', progress.lastSavedAt ? date(progress.lastSavedAt) : '尚未保存')));
656
+ h('dl', { className: 'st-save-facts st-reader-facts' }, field(target.platform === 'douyin' ? '作品总数(视频与图集)' : '消息总数(含转发)', totalText(progress.history)), field('本地已存', progress.saved.toLocaleString() + ' 条'), field('最近保存', progress.lastSavedAt ? date(progress.lastSavedAt) : '尚未保存')));
590
657
  }
591
658
 
592
659
  function AuthorList({ state, act, busy, navigate, openSettings }) {
@@ -606,7 +673,7 @@
606
673
  ['新动态', '历史缓存', '详细设置'].map(label => h('th', { key: label, scope: 'col' }, label)))),
607
674
  h('tbody', null, rows.map(({ target, progress }) => h('tr', { key: target.uid, className: 'st-author-overview' },
608
675
  h('td', null, h('button', { className: 'st-author-open', onClick: () => navigate('archive', { uid: target.uid }) }, h(AuthorAvatar, { target, size: 'small' }),
609
- h('span', null, h('strong', null, target.name), h('span', { className: 'st-meta-tag st-uid-tag' }, 'UID ' + (target.external_uid || target.uid))))),
676
+ h('span', null, h('strong', null, target.name), h('span', { className: 'st-meta-tag st-uid-tag', title: 'UID ' + (target.external_uid || target.uid) }, 'UID ' + shortUid(target.external_uid || target.uid))))),
610
677
  h('td', null, h('span', { className: 'st-meta-tag st-platform-badge' }, platformNames[target.platform || 'weibo'] || target.platform)),
611
678
  h('td', null, totalText(progress.history)), h('td', null, progress.saved.toLocaleString() + ' 条'),
612
679
  h('td', null, h(TaskControl, { state, target, kind: 'updates', act, busy, openSettings })),
@@ -634,8 +701,27 @@
634
701
  h('p', { className: 'st-quote-preview' }, post.quote.text.slice(0, 180))),
635
702
  h('div', { className: 'st-quote-body' }, post.quote.article ? h('a', { href: post.quote.url, target: '_blank', rel: 'noreferrer' }, post.quote.text.split('\n\n')[0] + ' ↗') : post.quote.text, h(ImageGallery, { post, images: post.quote.images, quoted: true })));
636
703
  }
704
+ function VideoContent({ post, act, busy }) {
705
+ const video = post.video, labels = { not_requested: '未开启', queued: '待处理', running: '处理中', done: '已完成', failed: '失败', needs_setup: '需配置环境' };
706
+ const media = type => BASE + '/video?id=' + encodeURIComponent(post.id) + '&type=' + type;
707
+ return h('section', { className: 'st-video-content', 'aria-label': '抖音作品资料' },
708
+ h('div', { className: 'st-actions st-caption' }, [['video', '视频'], ['audio', '音频'], ['transcription', '转写']].map(([key, title]) => h('span', { key }, title + ':' + (labels[video.stages[key]] || video.stages[key])))),
709
+ video.videoAvailable ? h('video', { controls: true, preload: 'none', src: media('video'), style: { width: '100%', maxHeight: 400, marginTop: 12 } }) : null,
710
+ video.audioAvailable ? h('audio', { controls: true, preload: 'none', src: media('audio'), style: { maxWidth: '100%', marginTop: 12 } }) : null,
711
+ video.summary.status !== 'disabled' ? h('details', { open: video.summary.status === 'available', className: 'st-quote' }, h('summary', null, '平台 AI 解析 · ' + (video.summary.status === 'available' ? '已获取' : video.summary.status === 'unconfirmed' ? '状态未确认' : '本次未获取')),
712
+ h('p', { className: 'st-caption' }, '平台生成的归纳,不是作者逐字原话;数字与引用请核对视频。'), h('p', { className: 'st-text' }, video.summary.abstract || '本次没有返回摘要'),
713
+ (video.summary.chapters || []).map((chapter, index) => h('div', { key: index }, h('strong', null, Math.floor(chapter.atMs / 60000) + ':' + String(Math.floor(chapter.atMs / 1000) % 60).padStart(2, '0') + ' ' + chapter.title), h('p', null, chapter.text)))) : null,
714
+ h('details', null, h('summary', null, '本地文字稿 · ' + (video.transcript ? '已生成' : labels[video.stages.transcription] || '尚未生成')), h('p', { className: 'st-caption' }, '本地语音识别稿,可能有错字;不是平台 AI 摘要。'), h('div', { className: 'st-text' }, video.transcript || '尚无文字稿')),
715
+ video.error ? h('p', { className: 'st-inline-error' }, video.error) : null,
716
+ ['failed', 'needs_setup'].some(status => Object.values(video.stages).includes(status)) ? h('button', { disabled: busy, onClick: () => act('/video/retry', { id: post.id, platform: 'douyin' }) }, '重试媒体处理') : null,
717
+ h('div', { className: 'st-actions' }, video.videoAvailable ? h('a', { href: media('video') + '&download=1', download: true }, '下载本地视频') : null, video.audioAvailable ? h('a', { href: media('audio') + '&download=1', download: true }, '下载本地音频') : null));
718
+ }
637
719
  function FrequencyNote({ state, platform, uid = '', navigate }) {
638
720
  const source = state.platforms?.find(item => item.id === platform);
721
+ if (platform === 'douyin') {
722
+ const config = state.douyin?.overrides?.[uid] || state.douyin?.config || { times: ['20:00'], repeat: 'daily' };
723
+ return h('button', { className: 'st-frequency-note', 'aria-label': '抖音定时检查', onClick: navigate }, (uid && state.douyin?.overrides?.[uid] ? '独立 · ' : '') + (config.repeat === 'weekdays' ? '工作日 ' : '每天 ') + config.times.join('、') + '(北京时间)');
724
+ }
639
725
  const target = state.targets.find(item => item.uid === uid), schedule = target?.policy?.schedule || source?.schedule;
640
726
  const progress = state.progress?.find(row => row.uid === uid);
641
727
  const rules = schedule?.enabled ? '北京时间:' + schedule.windows.map(window => repeatText(window) + ' ' + window.start + '–' + window.end + ' ' + intervalText(window.intervalMinutes * 60000)).join(';') + ';其他时间 ' + intervalText(schedule.fallbackMinutes * 60000) : '固定频率';
@@ -859,7 +945,7 @@
859
945
  return h('div', { key: kind, className: 'st-stream-row' }, h('button', { className: 'st-stream-status' + (['监控中', '缓存中', '补查中'].includes(status) ? ' is-running' : ''), title: effective.reason, disabled: preview, onClick: () => {
860
946
  const status = streamStatus(state, item, kind);
861
947
  if (status === '等待连接') return navigate('settings', { platform: platform.id, section: 'account' });
862
- if (status === '需处理') return navigate('tasks', { uid: item.uid });
948
+ if (['需处理', '有内容未读完整'].includes(status)) return navigate('tasks', { uid: item.uid });
863
949
  openSettings({ scope: 'target', uid: item.uid, platform: platform.id, tab: kind === 'history' ? 'history' : 'frequency' });
864
950
  } }, h('span', null, label), h('b', null, preview ? '待设置' : status)),
865
951
  h('button', { role: 'switch', 'aria-label': item.name + (kind === 'history' ? '历史缓存' : '新动态') + '开关', 'aria-checked': item.enabled !== false && job?.status === 'queued', className: 'st-monitor-switch st-job-switch' + (item.enabled === false || job?.status !== 'queued' ? ' off' : ''),
@@ -883,7 +969,7 @@
883
969
  h('button', { className: 'st-node-gear', 'aria-label': '设置' + platform.name + '平台', disabled: preview || busy, onClick: () => openSettings({ scope: 'platform', platform: platform.id }) }, h(GearIcon)),
884
970
  h('button', { type: 'button', className: 'st-platform-select', onClick: () => fold('source-' + platform.id, !!folds['source-' + platform.id]), 'aria-expanded': !folds['source-' + platform.id],
885
971
  'aria-label': '展开或收起' + platform.name + '博主' },
886
- h('span', { className: 'st-node-icon' }, platform.id === 'weibo' ? h('img', { className: 'st-weibo-logo', src: BASE + '/assets/weibo.ico', alt: '微博' }) : platform.id === 'xueqiu' ? h('img', { className: 'st-platform-logo', src: BASE + '/assets/xueqiu.png', alt: '雪球' }) : platform.id === 'x' ? '𝕏' : platform.name.slice(0, 1)), h('strong', null, platform.name),
972
+ h('span', { className: 'st-node-icon' }, platform.id === 'weibo' ? h('img', { className: 'st-weibo-logo', src: BASE + '/assets/weibo.ico', alt: '微博' }) : platform.id === 'douyin' ? h('img', { className: 'st-platform-logo', src: BASE + '/assets/douyin.ico', alt: '抖音' }) : platform.id === 'xueqiu' ? h('img', { className: 'st-platform-logo', src: BASE + '/assets/xueqiu.png', alt: '雪球' }) : platform.id === 'x' ? '𝕏' : platform.name.slice(0, 1)), h('strong', null, platform.name),
887
973
  h('small', null, platform.targets.length, ' 位 · ', platform.count.toLocaleString(), ' 条已存'),
888
974
  h('span', { className: 'st-platform-fold-mark', 'aria-hidden': true }, folds['source-' + platform.id] ? '›' : '⌄')),
889
975
  h('button', { className: 'st-platform-tag st-platform-repair' + (!platformReady(state, platform.id) ? ' warn' : ''), onClick: () => navigate('settings', { platform: platform.id, section: 'account' }), 'aria-label': '查看' + platform.name + '平台' }, h('i'), statusText(platform.id)), toggle(platform.name, state.platforms?.find(source => source.id === platform.id)?.enabled, 'platform', { platform: platform.id }, state.platforms?.find(source => source.id === platform.id)?.enabled === false ? '平台已关闭' : '平台已开启')),
@@ -940,8 +1026,8 @@
940
1026
  h('p', null, waitUntil > Date.now() ? platformNames[target.platform] + '要求暂缓访问,冷却至 ' + date(waitUntil) + ' 后可读取资料。' : '先确认昵称、头像和平台显示的消息总数,此步骤不会缓存正文。'),
941
1027
  h('button', { disabled: busy || waitUntil > Date.now(), onClick: () => act('/targets/profile', { uid: target.uid, platform: target.platform, interactive: true }) }, '重新读取'))
942
1028
  : h(React.Fragment, null,
943
- h('p', { className: 'st-profile-total' }, '平台显示 ', h('strong', null, target.profile_total == null ? '总数暂不可用' : Number(target.profile_total).toLocaleString() + (target.platform === 'weibo' ? ' 条微博' : ' 条消息')),
944
- h('small', { className: 'st-caption' }, ' · 包含原创与转发,实际可保存数量以账号可访问范围为准')),
1029
+ h('p', { className: 'st-profile-total' }, '平台显示 ', h('strong', null, target.profile_total == null ? '总数暂不可用' : Number(target.profile_total).toLocaleString() + (target.platform === 'douyin' ? ' 个作品' : target.platform === 'weibo' ? ' 条微博' : ' 条消息')),
1030
+ h('small', { className: 'st-caption' }, target.platform === 'douyin' ? ' · 包含视频与图集,实际保存范围以当前账号可见内容为准' : ' · 包含原创与转发,实际可保存数量以账号可访问范围为准')),
945
1031
  h('p', { className: 'st-caption', style: { overflowWrap: 'anywhere' } }, '保存到:', state.storage?.documentsDirectory, '/', target.platform || 'weibo', '/', target.external_uid || target.uid, ' ', h('button', { className: 'st-link-button', onClick: () => navigate('settings', { section: 'workspace' }) }, '更改保存位置')),
946
1032
  h('h3', null, '从什么时候开始保存?'),
947
1033
  h('div', { className: 'st-capture-options' },
@@ -1003,7 +1089,7 @@
1003
1089
  React.useEffect(() => { if (!active) return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [active]);
1004
1090
  const labels = { read_author_index: '查看博主资料索引', search_posts: '检索本地消息', read_posts: '读取消息原文', list_status: '查看监控状态', add_monitor: '添加博主', control_monitor: '调整监控开关', set_frequency: '修改监控频率', create_summary_task: '创建总结任务', control_summary_task: '调整总结任务', run_summary_task: '执行总结任务', validate_sources: '核对回答来源' };
1005
1091
  const phases = { requesting: '连接模型', thinking: '模型正在思考', answering: '正在生成回答', preparing_tools: '准备查询与操作' };
1006
- const status = { completed: '已完成', failed: '执行失败', interrupted: '执行中断', cancelled: '已停止', awaiting_confirmation: '等待你确认', executing: '正在应用变更', running: '正在工作' };
1092
+ const status = { partial: '回答未完', completed: '已完成', failed: '执行失败', interrupted: '执行中断', cancelled: '已停止', awaiting_confirmation: '等待你确认', executing: '正在应用变更', running: '正在工作' };
1007
1093
  const seconds = Math.max(0, Math.floor(((active ? now : run.finished_at || run.created_at) - run.created_at) / 1000));
1008
1094
  const row = (step, index) => {
1009
1095
  const running = step.status === 'running' && active;
@@ -1084,13 +1170,14 @@
1084
1170
  h(ChatProgress, { run, pending, cancel: () => request('/chat/cancel', { id: run.id }) }),
1085
1171
  run.error ? h('p', { className: 'st-chat-run-error', role: 'alert' }, run.error, run.answer ? '(已保留生成内容,供你核对)' : '') : null,
1086
1172
  run.answer ? h(ChatMarkdown, { text: run.answer, sources: run.sources, streaming: run.status === 'running' }) : null,
1173
+ run.status === 'partial' ? h('div', { className: 'st-caption st-chat-partial', role: 'status' }, h('p', null, '回答暂未写完,以上正文已保留;可以从中断处继续,来源核对尚未完成。'), run.id === latest?.id ? h('button', { disabled: pending || !!data.busy, onClick: async () => { const draft = input; followLatest.current = true; await request('/chat/message', { message: '继续刚才未完成的回答,从中断处接着写', sessionId }); if (alive.current && draft) { setInput(draft); drafts.current.set(sessionId, draft); } } }, '继续回答') : null) : null,
1087
1174
  run.action && run.status === 'awaiting_confirmation' ? h('div', { className: 'st-chat-confirm', 'aria-label': '待确认的业务变更' }, h('p', null, run.action.label), run.action.actions ? h('ol', null, run.action.actions.map((action, index) => h('li', { key: index }, action.label))) : null,
1088
1175
  h('div', { className: 'st-actions' }, h('button', { className: 'st-primary', disabled: pending, onClick: () => request('/chat/confirm', { id: run.id }) }, '确认执行'),
1089
1176
  h('button', { disabled: pending, onClick: () => request('/chat/cancel', { id: run.id }) }, '取消变更'))) : null,
1090
- run.sources.length ? h('details', { className: 'st-chat-source-list' }, h('summary', null, '参考来源 · ', run.sources.length, ' 条'), h('div', { className: 'st-chat-sources' }, run.sources.map(source => /^https:\/\/(?:www\.)?(?:weibo\.com|xueqiu\.com)\//.test(source.url)
1177
+ run.sources.length ? h('details', { className: 'st-chat-source-list' }, h('summary', null, '参考来源 · ', run.sources.length, ' 条'), h('div', { className: 'st-chat-sources' }, run.sources.map(source => /^https:\/\/(?:www\.)?(?:weibo\.com|xueqiu\.com|douyin\.com)\//.test(source.url)
1091
1178
  ? h('a', { key: source.id, href: source.url, target: '_blank', rel: 'noreferrer' }, '[' + source.sourceId + '] ' + source.author + ' · ' + date(source.publishedAt))
1092
1179
  : h('span', { key: source.id }, '[' + source.sourceId + '] ' + source.author)))) : null,
1093
- run.sources.some(source => source.truncated) ? h('small', null, '部分来源使用了摘录;可继续要求读取原文核对。') : null,
1180
+ run.sources.some(source => source.truncated) ? h('small', null, '部分较长内容按节选分析,参考来源中保留了原文链接。') : null,
1094
1181
  null)), h('div', { ref: bottom })),
1095
1182
  data?.busy && !data.runs.some(run => run.id === data.busy) ? h('p', { role: 'status' }, '后台正在处理另一项任务,请稍候。') : null,
1096
1183
  !data?.runs.length ? compose : null,
@@ -1154,7 +1241,7 @@
1154
1241
  const navigate = (next, options = {}) => {
1155
1242
  if (next === 'tasks') {
1156
1243
  const owner = state?.targets.find(item => item.uid === (options.uid || uid));
1157
- const problem = state?.jobs.find(job => job.uid === owner?.uid && job.status === 'needs_review');
1244
+ const problem = state?.jobs.find(job => job.uid === owner?.uid && (job.status === 'needs_review' || job.contentIssue));
1158
1245
  next = 'archive'; options = { ...options, uid: owner?.uid || '', view: problem?.kind === 'updates' ? 'frequency' : 'history' };
1159
1246
  }
1160
1247
  if (next === 'settings' && options.section === 'frequency') {
@@ -1210,7 +1297,7 @@
1210
1297
  .then(result => { if (active) { setPosts(result.items); setPostsLoading(false); } })
1211
1298
  .catch(err => { if (active) { setError(err.message); setErrorSection('service'); setPostsLoading(false); } });
1212
1299
  return () => { active = false; };
1213
- }, [tab, uid, readerTab, query, postKind, offset, focusedPost, state?.count, latestSavedAt, state?.targets.find(item => item.uid === uid)?.capture_mode]);
1300
+ }, [tab, uid, readerTab, query, postKind, offset, focusedPost, state?.count, latestSavedAt, state?.douyin?.revision, state?.targets.find(item => item.uid === uid)?.capture_mode]);
1214
1301
  const act = async (path, body = {}) => {
1215
1302
  stateEpoch.current++;
1216
1303
  setBusy(true); setError(''); setSavedMessage('');
@@ -1223,7 +1310,8 @@
1223
1310
  if (added) navigate('archive', { uid: added.uid, platform: added.platform });
1224
1311
  setSavedMessage('已添加博主,正在读取昵称与头像;监控尚未开启');
1225
1312
  }
1226
- if (/^\/jobs\//.test(path)) {
1313
+ if (path === '/jobs/skip-content') setSavedMessage((state.targets.find(item => item.uid === state.jobs.find(job => job.id === body.id)?.uid)?.name || '所选博主') + ':已保留片段和链接,继续缓存其他内容');
1314
+ else if (/^\/jobs\//.test(path)) {
1227
1315
  const job = state.jobs.find(item => path.includes(item.id));
1228
1316
  setSavedMessage((state.targets.find(item => item.uid === job?.uid)?.name || '所选博主') + ':' + (job?.kind === 'history' ? '历史缓存' : '更新检查') + (path.endsWith('/pause') ? '已暂停,已保存内容和断点保留' : '已开启;账号未就绪或上级开关暂停时会等待'));
1229
1317
  }
@@ -1364,7 +1452,7 @@
1364
1452
  !showPreview ? button('+ 添加博主', () => navigate('settings', { section: 'add-target' })) : null,
1365
1453
  (showPreview || ['blocked', 'partial'].includes(effective.kind) || state.enabled === false || effectiveSource?.enabled === false || effective.section === 'archive') ? button(showPreview ? '开始配置 →' : effective.kind === 'partial' ? '查看异常' : effective.kind === 'blocked' ? effective.section === 'tasks' ? '检查任务' : effectiveAccount?.status === 'needs_login' ? '登录' + effectiveName : effectiveAccount?.status === 'needs_verification' ? '完成验证' : '恢复连接' : state.enabled === false ? '开启总控' : effectiveSource?.enabled === false ? '开启平台监控' : effective.section === 'archive' ? '选择监控对象' : '查看博主',
1366
1454
  () => showPreview ? navigate('settings', { section: 'account' }) : state.enabled === false ? act('/monitoring', { scope: 'all', enabled: true })
1367
- : effectiveSource?.enabled === false ? act('/monitoring', { scope: 'platform', platform: effectivePlatform, enabled: true }) : ['blocked', 'partial'].includes(effective.kind) && effective.section !== 'tasks' ? navigate('settings', { platform: effectivePlatform, section: effective.section }) : effective.section === 'archive' ? navigate('archive', { uid: '' }) : navigate('tasks', { uid: state.jobs.find(job => job.status === 'needs_review' && state.targets.find(target => target.uid === job.uid)?.platform === effectivePlatform)?.uid || uid }), 'st-primary') : null)),
1455
+ : effectiveSource?.enabled === false ? act('/monitoring', { scope: 'platform', platform: effectivePlatform, enabled: true }) : ['blocked', 'partial'].includes(effective.kind) && effective.section !== 'tasks' ? navigate('settings', { platform: effectivePlatform, section: effective.section }) : effective.section === 'archive' ? navigate('archive', { uid: '' }) : navigate('tasks', { uid: effective.uid || state.jobs.find(job => job.status === 'needs_review' && state.targets.find(target => target.uid === job.uid)?.platform === effectivePlatform)?.uid || uid }), 'st-primary') : null)),
1368
1456
  h(FlowOverview, { key: showPreview ? 'preview' : 'live', state: showPreview ? sample : state,
1369
1457
  uid: showPreview ? '' : uid, preview: showPreview,
1370
1458
  navigate, download, busy,
@@ -1383,7 +1471,7 @@
1383
1471
  context: { scope: 'target', uid: target.uid, platform: target.platform, tab: readerTab }, state, act, busy, error, navigate, openSettings: openNodeSettings, close: () => setReaderTab('content') });
1384
1472
  else if (tab === 'archive') content = h(React.Fragment, null,
1385
1473
  focusedPost ? h('div', { className: 'st-message-toolbar' }, h('span', { className: 'st-caption' }, '提醒对应的动态'), button('查看这位博主的全部内容', () => navigate('archive', { uid }))) : h(React.Fragment, null,
1386
- h('div', { className: 'st-feed-filters' }, [['all', '全部'], ['original', '原创 ' + originals], ['repost', '转发 ' + reposts]].map(([key, title]) => h('button', { key, type: 'button', 'aria-pressed': postKind === key,
1474
+ h('div', { className: 'st-feed-filters' }, (target?.platform === 'douyin' ? [['all', '全部'], ['video', '视频 ' + (state.douyin?.counts?.find(row => row.uid === uid)?.videos || 0)], ['images', '图集 ' + (state.douyin?.counts?.find(row => row.uid === uid)?.images || 0)]] : [['all', '全部'], ['original', '原创 ' + originals], ['repost', '转发 ' + reposts]]).map(([key, title]) => h('button', { key, type: 'button', 'aria-pressed': postKind === key,
1387
1475
  onClick: () => { setPostKind(key); setOffset(0); } }, title))),
1388
1476
  h('div', { className: 'st-toolbar' }, h('input', { 'aria-label': '搜索留存正文', placeholder: '搜索已留存的文字…', value: query,
1389
1477
  onChange: event => { setQuery(event.target.value); setOffset(0); } }),
@@ -1393,9 +1481,10 @@
1393
1481
  onClick: event => { event.preventDefault(); void download('jsonl'); } }, 'JSONL'))),
1394
1482
  postsLoading ? h('div', { className: 'st-empty', role: 'status' }, '正在读取本地档案…') : posts.length ? posts.map(post => h('article', { className: 'st-card', key: post.id },
1395
1483
  h('div', { className: 'st-card-head' }, h('div', null, h('h3', null, post.author), h(PostTimes, { publishedAt: post.published_at, detectedAt: post.detected_at, savedAt: post.first_seen_at, kind: post.capture_kind })),
1396
- h('span', { className: 'st-badge' + (post.complete ? '' : ' warn'), title: '首次保存于 ' + date(post.first_seen_at) }, post.article ? '文章链接' : post.complete ? post.quote ? '转发' : '原创' : '正文待补齐')),
1484
+ h('span', { className: 'st-badge' + (post.complete ? '' : ' warn'), title: '首次保存于 ' + date(post.first_seen_at) }, post.video ? post.video.imagePost ? '图集' : '视频作品' : post.article ? '文章链接' : post.complete ? post.quote ? '转发' : '原创' : '正文待补齐')),
1397
1485
  post.images?.length && post.text.trim() === '分享图片' || post.quote && ['转发微博', '快转微博'].includes(post.text.trim()) ? null : h('div', { className: 'st-text' }, post.article ? h('a', { href: post.url, target: '_blank', rel: 'noreferrer' }, post.text.split('\n\n')[0] + ' ↗') : post.text),
1398
1486
  h(ImageGallery, { post, images: post.images }), h(QuoteBlock, { post }),
1487
+ post.video ? h(VideoContent, { post, act, busy }) : null,
1399
1488
  !post.article ? h('div', { className: 'st-card-head st-caption st-post-footer' }, h('a', { href: post.url, target: '_blank', rel: 'noreferrer' }, '查看原文 ↗')) : null))
1400
1489
  : h('div', { className: 'st-empty' }, h('h3', null, focusedPost ? '这条动态暂不可用' : query ? '没有找到匹配内容' : state.targets.length ? '还没有留存内容' : '添加第一位博主'),
1401
1490
  h('p', null, focusedPost ? '可以查看这位博主的其他留存内容。' : query ? '试试其他关键词,或清空搜索。' : postKind !== 'all' ? '这个分类暂无消息,可以切换到全部查看。' : state.targets.length ? target && platformReady(state, target.platform || 'weibo') && streamStatus(state, target, 'history') === '未开启' ? '这里只保存开启监控后发布的新动态。需要以前的消息,可在「历史消息缓存」中开启。' : effective.reason : '添加博主后,动态和历史内容会逐步留存在本机。'),
@@ -1417,11 +1506,11 @@
1417
1506
  !state.notices.some(item => noticeKind === 'all' || item.kind === noticeKind) ? h('div', { className: 'st-empty' }, unreadNotices(state, noticeKind) ? '最近提醒中没有此类消息,未读数量包含更早的提醒。' : '暂无此类消息') : null,
1418
1507
  state.notices.length === 100 ? h('p', { className: 'st-caption' }, '显示最近 100 条提醒,未读数量统计全部提醒;完整正文可在关注与档案查看。') : null);
1419
1508
  else content = h('div', { className: 'st-settings-page' },
1420
- h('div', { className: 'st-platform-tabs', role: 'tablist', 'aria-label': '平台配置' }, [['weibo', '微博'], ['xueqiu', '雪球'], ['x', 'X']].map(([id, title]) => h('button', { key: id, role: 'tab', 'aria-selected': platform === id, onClick: () => { setPlatform(id); setFocusSection(''); setError(''); setSavedMessage(''); } }, title))),
1421
- !['weibo', 'xueqiu'].includes(platform) ? h('div', { className: 'st-development' }, h('h3', null, platformName + ' · 开发中'), h('p', null, '暂未开放配置')) : h(React.Fragment, null,
1509
+ h('div', { className: 'st-platform-tabs', role: 'tablist', 'aria-label': '平台配置' }, [['weibo', '微博'], ['xueqiu', '雪球'], ['douyin', '抖音'], ['x', 'X']].map(([id, title]) => h('button', { key: id, role: 'tab', 'aria-selected': platform === id, onClick: () => { setPlatform(id); setFocusSection(''); setError(''); setSavedMessage(''); } }, title))),
1510
+ !['weibo', 'xueqiu', 'douyin'].includes(platform) ? h('div', { className: 'st-development' }, h('h3', null, platformName + ' · 开发中'), h('p', null, '暂未开放配置')) : h(React.Fragment, null,
1422
1511
  h('section', { id: 'st-account', tabIndex: -1, 'aria-label': platformName + '账号连接', className: 'st-card st-connection' + (focusSection === 'account' ? ' st-focused' : '') },
1423
1512
  h(SettingRow, { label: platformName + '账号', description: state.platforms?.find(source => source.id === platform)?.connectionRecovery ? '连接暂时不可用,后台会自动检查恢复,无需反复操作' : loginOpened ? '保持登录窗口打开,完成后确认连接' : hasAuthorization ? account?.status === 'ready' ? '用于读取关注博主的内容' : '授权已保留,可检查连接状态' : '登录后才能监控与缓存消息' },
1424
- h('div', { className: 'st-account-controls' }, h('span', { className: 'st-connection-state ' + accountState }, state.platforms?.find(source => source.id === platform)?.connectionRecovery ? '自动恢复中' : hasAuthorization ? account?.status === 'ready' ? '已登录' : '授权已保存' : loginOpened ? '等待确认' : labels[accountState] || '未连接'),
1513
+ h('div', { className: 'st-account-controls' }, h('span', { className: 'st-connection-state ' + accountState }, state.platforms?.find(source => source.id === platform)?.connectionRecovery ? '自动恢复中' : hasAuthorization ? account?.status === 'ready' && platform !== 'douyin' ? '已登录' : '授权已保存' : loginOpened ? '等待确认' : labels[accountState] || '未连接'),
1425
1514
  !hasAuthorization ? button(loginOpened ? '我已登录,继续' : '登录' + platformName, () => act(loginOpened ? '/verify' : '/login'), 'st-primary') : null,
1426
1515
  button('检查连接', () => act('/verify')))),
1427
1516
  error && errorSection === 'account' ? h('p', { className: 'st-inline-error', role: 'alert' }, error) : !loginOpened && accountState !== 'ready' && account?.reason && !/请先登录|请在.*登录|请在.*验证/.test(account.reason) ? h('p', { className: 'st-caption' }, account.reason) : null),
@@ -1436,6 +1525,7 @@
1436
1525
  state.storage?.mediaError ? h(SettingRow, { label: '图片备份', description: '部分图片尚未保存,正文已保留。可以重试保存图片。' }, button('重试图片备份', () => act('/workspace/sync', { images: true }))) : null,
1437
1526
  state.storage?.error ? h(SettingRow, { label: '文档保存', description: '本地文档尚未同步完成,请重试;已采集的内容仍保留在资料库中。' }, button('重试文档同步', () => act('/workspace/sync'))) : null,
1438
1527
  h('details', { className: 'st-setting-help' }, h('summary', null, '更换保存位置说明'), h('p', null, '已有内容会复制到新位置,原文件保留。平台授权独立保存。'))),
1528
+ platform === 'douyin' ? h('section', { className: 'st-card' }, h('h2', null, '抖音默认配置'), h('p', { className: 'st-caption' }, '授权已保存后,实际访问情况以作品检查结果为准。'), h(DouyinConfigForm, { state, act, busy })) : null,
1439
1529
  h('section', { className: 'st-settings-im', 'aria-label': 'dsh-im 消息推送' }, h('h2', null, '消息推送'), h(IMSummary, { agent: state.agent }), button('配置推送渠道', () => { setAgentTab('settings'); setAgentOpen(true); })),
1440
1530
  h('p', { className: 'st-settings-footer' }, '电脑关闭或插件退出时停止采集;重新运行后会补查期间仍可见的动态。')));
1441
1531
 
@@ -1465,11 +1555,11 @@
1465
1555
  h('main', { className: 'st-main' + (tab === 'chat' ? ' st-chat-main' : ''), ref: main },
1466
1556
  h('header', { className: 'st-head' }, h('div', { className: 'st-page-title' }, tab === 'archive' && uid ? h('button', { className: 'st-back', 'aria-label': '返回博主列表', title: '返回博主列表', onClick: () => navigate('archive', { uid: '' }) }, h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, h('path', { d: 'm15 6-6 6 6 6' }))) : null, h('h1', null, titles[tab])),
1467
1557
  h('div', { className: 'st-head-tools' },
1468
- tab === 'overview' ? h('button', { className: 'st-flow-status ' + (showPreview ? 'preview' : effective.kind), title: effective.reason, onClick: () => effective.section === 'tasks' ? navigate('tasks', { uid: state.jobs.find(job => job.status === 'needs_review' && state.targets.find(target => target.uid === job.uid)?.platform === effectivePlatform)?.uid || '' }) : navigate('settings', { platform: effectivePlatform, section: effective.section }) }, h('i'), showPreview ? '未配置 · 效果预览' : effective.kind === 'ready' ? '监控中' : effective.label)
1558
+ tab === 'overview' ? h('button', { className: 'st-flow-status ' + (showPreview ? 'preview' : effective.kind), title: effective.reason, onClick: () => effective.section === 'tasks' ? navigate('tasks', { uid: effective.uid || state.jobs.find(job => job.status === 'needs_review' && state.targets.find(target => target.uid === job.uid)?.platform === effectivePlatform)?.uid || '' }) : navigate('settings', { platform: effectivePlatform, section: effective.section }) }, h('i'), showPreview ? '未配置 · 效果预览' : effective.kind === 'ready' ? '监控中' : effective.label)
1469
1559
  : null,
1470
1560
  tab === 'archive' ? button('+ 添加博主', () => navigate('settings', { section: 'add-target' }), 'st-primary') : null,
1471
1561
  null)),
1472
- firstUse && ['overview', 'settings'].includes(tab) && ['weibo', 'xueqiu'].includes(platform) ? h('ol', { className: 'st-setup-steps', 'aria-label': '首次使用流程' }, ['确认保存位置', '连接' + platformName, '添加博主', '选择记录方式'].map((text, i) => h('li', { key: text, 'aria-current': (state.storage?.workspaceConfigured === false ? i === 0 : accountState !== 'ready' ? i === 1 : !state.targets.length ? i === 2 : i === 3) ? 'step' : undefined },
1562
+ firstUse && ['overview', 'settings'].includes(tab) && ['weibo', 'xueqiu', 'douyin'].includes(platform) ? h('ol', { className: 'st-setup-steps', 'aria-label': '首次使用流程' }, ['确认保存位置', '连接' + platformName, '添加博主', '选择记录方式'].map((text, i) => h('li', { key: text, 'aria-current': (state.storage?.workspaceConfigured === false ? i === 0 : accountState !== 'ready' ? i === 1 : !state.targets.length ? i === 2 : i === 3) ? 'step' : undefined },
1473
1563
  button(text, () => i === 0 ? navigate('settings', { section: 'workspace' }) : i === 1 ? navigate('settings', { section: 'account' }) : i === 2 || !state.targets.length ? navigate('settings', { section: 'add-target' }) : navigate('archive', { uid: state.targets.find(item => item.capture_mode === 'pending')?.uid || '' }), 'st-link-button')))) : null,
1474
1564
  savedMessage ? h('div', { className: 'st-saved', role: 'status' }, h('span', null, savedMessage), h('small', null, '4 秒后自动关闭'), h('button', { 'aria-label': '关闭操作提醒', onClick: () => setSavedMessage('') }, '×')) : null,
1475
1565
  error && !agentOpen && !nodeSettings && !(tab === 'archive' && uid && readerTab !== 'content') && !adding && (tab !== 'settings' || ['service', 'history'].includes(errorSection)) ? h('div', { className: 'st-error', role: 'alert' }, error, ' ', button(errorSection === 'service' ? '重试连接' : errorSection === 'workspace' ? '查看保存位置' : errorSection === 'frequency' ? '查看频率设置' : errorSection === 'history' ? '检查缓存日期' : '处理' + platformName + '连接', () => errorSection === 'service' ? reload().then(() => setError('')).catch(err => setError(err.message)) : errorSection === 'history' ? navigate('archive', { uid }) : navigate('settings', { platform, section: errorSection })), ' ', button('关闭', () => setError(''))) : null,
@@ -1477,10 +1567,10 @@
1477
1567
  ' ', button('重试启动后台', () => act('/service/retry'))) : null,
1478
1568
  state && tab === 'archive' ? h('section', { className: 'st-identity' }, target ? h(AuthorAvatar, { target }) : null,
1479
1569
  h('select', { 'aria-label': '筛选博主', value: uid, onChange: event => navigate(tab, { uid: event.target.value }) }, h('option', { value: '' }, '全部博主'), state.targets.map(item => h('option', { key: item.uid, value: item.uid }, item.name))),
1480
- target ? h(React.Fragment, null, h('span', { className: 'st-meta-tag st-platform-badge' }, platformNames[target.platform || 'weibo']), h('span', { className: 'st-meta-tag st-uid-tag' }, 'UID ' + (target.external_uid || target.uid))) : null,
1570
+ target ? h(React.Fragment, null, h('span', { className: 'st-meta-tag st-platform-badge' }, platformNames[target.platform || 'weibo']), h('span', { className: 'st-meta-tag st-uid-tag', title: 'UID ' + (target.external_uid || target.uid) }, 'UID ' + shortUid(target.external_uid || target.uid))) : null,
1481
1571
  h('div', { className: 'st-identity-actions' },
1482
1572
  target ? h('details', { className: 'st-author-more' }, h('summary', null, '更多'), h('div', null,
1483
- h('a', { href: (target.platform === 'xueqiu' ? 'https://xueqiu.com/u/' : 'https://weibo.com/u/') + (target.external_uid || target.uid), target: '_blank', rel: 'noreferrer' }, platformNames[target.platform || 'weibo'] + '主页 ↗'),
1573
+ h('a', { href: (target.platform === 'douyin' ? 'https://www.douyin.com/user/' : target.platform === 'xueqiu' ? 'https://xueqiu.com/u/' : 'https://weibo.com/u/') + (target.external_uid || target.uid), target: '_blank', rel: 'noreferrer' }, platformNames[target.platform || 'weibo'] + '主页 ↗'),
1484
1574
  button('刷新昵称与头像', () => act('/targets/profile', { uid: target.uid, platform: target.platform, interactive: true })))) : null)) : null,
1485
1575
  tab === 'archive' && target ? h('div', { className: 'st-reader-tabs', role: 'tablist', 'aria-label': '博主详情' }, [['content', '留存内容'], ['frequency', '新消息监控'], ['history', '历史消息缓存']].map(([key, label]) => h('button', { key, id: 'st-reader-tab-' + key, role: 'tab', 'aria-controls': key === 'content' ? 'st-reader-content' : 'st-node-panel-' + key, 'aria-selected': readerTab === key, onClick: () => { setReaderTab(key); setError(''); setSavedMessage(''); } }, label))) : null,
1486
1576
  tab === 'archive' && target && readerTab === 'content' ? h('section', { id: 'st-reader-content', role: 'tabpanel', 'aria-labelledby': 'st-reader-tab-content' },
package/lib/documents.js CHANGED
@@ -11,6 +11,14 @@ const imageType = bytes => bytes[0] === 0xff && bytes[1] === 0xd8 ? 'image/jpeg'
11
11
  : /^GIF8[79]a/.test(bytes.subarray(0, 6).toString()) ? 'image/gif'
12
12
  : bytes.subarray(0, 4).toString() === 'RIFF' && bytes.subarray(8, 12).toString() === 'WEBP' ? 'image/webp' : null;
13
13
 
14
+ export function videoMarkdown(video) {
15
+ if (!video) return '';
16
+ return '\n## 平台 AI 解析(非作者逐字原文)\n\n' + (video.summary.abstract || '本次未获取到解析')
17
+ + (video.summary.status === 'unconfirmed' ? '\n\n平台未确认解析状态,请核对原视频。' : '')
18
+ + (video.summary.chapters || []).map(chapter => `\n- ${Math.floor(chapter.atMs / 60000)}:${String(Math.floor(chapter.atMs / 1000) % 60).padStart(2, '0')} ${chapter.title}:${chapter.text}`).join('')
19
+ + '\n\n## 本地转写(识别稿)\n\n' + (video.transcript || '尚未生成') + '\n';
20
+ }
21
+
14
22
  export class Documents {
15
23
  constructor(store, workspace) {
16
24
  this.store = store;
@@ -129,9 +137,13 @@ export class Documents {
129
137
  if (skipUnchanged && existsSync(destination) && statSync(destination).mtimeMs >= post.last_seen_at) return true;
130
138
  const published = new Date(post.publishedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
131
139
  const pictures = images => images.map(url => `\n![配图](./images/${imageName(url)})\n`).join('');
140
+ const video = post.video && this.store.posts({ ids: [post.id], limit: 1 })[0]?.video;
132
141
  const content = `# ${post.author}\n\n发布时间:${published}(北京时间)\n\n来源:${post.url}\n\n正文状态:${post.article ? '文章仅保留标题和链接' : post.complete ? '完整' : '待补齐'}\n\n${post.text}\n`
133
142
  + pictures(post.images || platformImages(post.raw, this.store.target(post.uid)?.platform))
134
- + (post.quote ? `\n---\n\n转发原文 · ${post.quote.author}\n\n${post.quote.text}\n` + pictures(post.quote.images || platformImages(post.raw?.retweeted_status, this.store.target(post.uid)?.platform)) : '');
143
+ + (post.quote ? `\n---\n\n转发原文 · ${post.quote.author}\n\n${post.quote.text}\n` + pictures(post.quote.images || platformImages(post.raw?.retweeted_status, this.store.target(post.uid)?.platform)) : '')
144
+ + (video ? videoMarkdown(video)
145
+ + (video.videoAvailable ? `\n\n[本地视频](./media/${post.id.replace('douyin:', '')}/video.mp4)` : '')
146
+ + (video.audioAvailable ? `\n\n[本地音频](./media/${post.id.replace('douyin:', '')}/audio.wav)` : '') + '\n' : '');
135
147
  temporary = join(folder, '.' + randomUUID() + '.tmp');
136
148
  writeFileSync(temporary, content, { mode: 0o600 });
137
149
  renameSync(temporary, join(folder, filename));