feat(wechat): MindSpace public draft push with 143 host defaults

Add public-page WeChat draft button, publication standard conversion for poem/prose/generic pages, and quick push APIs. Default Studio release SSH targets to john@180.159.29.143 with 105 edge upstream via 10.10.0.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-11 12:32:13 +08:00
parent c5284a70c9
commit d8eaef3fbd
29 changed files with 2094 additions and 140 deletions
+12 -1
View File
@@ -52,7 +52,9 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# Tool Gateway Queue v0/agent/runs 后台任务队列,默认保守限流)。
# MEMIND_AGENT_RUN_QUEUE_CONCURRENCY=1
# MEMIND_AGENT_RUN_TIMEOUT_MS=900000
# MEMIND_AGENT_RUN_TIMEOUT_MS=1200000
# Goose 页面任务超时且未产出 HTML 时,由 Cursor 执行器接管重试(需 MEMIND_CURSOR_EXECUTOR_ENABLED=1
# MEMIND_CURSOR_PAGE_TIMEOUT_TAKEOVER=1
# P6.1 外部 Tool Worker 接管入口。默认 Portal 内部 auto-dispatch 保持开启;
# 只有外部 worker 验证就绪后,才把 Portal 设为 0 并独立运行 scripts/agent-run-worker.mjs。
# MEMIND_AGENT_RUN_AUTODISPATCH=1
@@ -130,6 +132,15 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# TKMIND_API_TARGET=https://127.0.0.1:18006
# TKMIND_API_TARGET_1=https://127.0.0.1:18007
# DashScope / nassaapi 中转兼容代理(deepseek-cp 等;默认 :18037
# - 自动把 max_tokens 截到 131072,避免 DashScope 400
# - 出站限流默认 10 次/分钟,与中转商配额对齐
# MEMIND_RELAY_COMPAT_PORT=18037
# MEMIND_RELAY_UPSTREAM_BASE=https://card.nassaapi.xyz
# MEMIND_RELAY_MAX_PER_MINUTE=10
# MEMIND_RELAY_COMPAT_HOST=host.docker.internal
# MEMIND_RELAY_COMPAT_ENABLED=1
# H5 Router normalized decisiondocs/h5-session-architecture-20260706.md Patch 3
# 默认 0/offgateway 仍读 legacy route
# shadow:只打 [router-shadow] 日志,行为不变
+19 -1
View File
@@ -1,6 +1,24 @@
# 历史分支处置登记
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
## `feature/wechat-news-morning-draft-worker`
**状态:禁止再次引用。已 fast-forward 并入 `origin/main` 并完成 103 Portal + memind_adm 发布。**
审计日期:2026-09-11
Memind HEAD`1f6e8c9b` · memind_adm HEAD`799afd1`
### 交付内容
- Portal`wechat-news-morning-draft-worker` 定时生成当日 `daily-news-MMDD.html` 并自动推微信草稿箱
- 管理后台:今日早报状态卡片 + 自动预览当天页面与草稿
### 103 发布记录
- Portal runtime`20260911-090522-1f6e8c9b`(快速发布,`STUDIO_HOST=10.10.0.2`
- memind_adm`20260911-092700-799afd1`
- 发布后日志:`WeChat news morning draft worker enabled`
---
它是分支复用、合并、cherry-pick 和清理前的必查清单。
## `feature/memfuse-recall-benchmark`
+3 -3
View File
@@ -188,10 +188,10 @@ ls -lh goosed.tar
# 3. 传输到 103
# 方式 1: 通过 scp
scp goosed.tar john@103:~/incoming/
scp goosed.tar john@180.159.29.143:~/incoming/
# 方式 2: 通过 rsync
rsync -av goosed.tar john@103:~/incoming/
rsync -av goosed.tar john@180.159.29.143:~/incoming/
# 4. 在 103 上导入(后续操作)
# docker load -i goosed.tar
@@ -287,7 +287,7 @@ docker logs <container-id>
docker save tkmind/goosed:local | gzip > goosed.tar.gz
# 传输
scp goosed.tar.gz john@103:~/
scp goosed.tar.gz john@180.159.29.143:~/
# 在 103 上解压导入
gunzip < goosed.tar.gz | docker load
+57
View File
@@ -0,0 +1,57 @@
# 公众号草稿推送格式标准
版本:`2026-09-11`(代码常量 `WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION`
本规范随公众号推送能力与 MindSpace 发布链路演进**持续更新完善**;版本号标识当前快照,发现规范缺口或实现偏差时,应同步修订本文档、校验用例与代码常量。
## 适用范围
所有 MindSpace 页面推送到**用户绑定公众号草稿箱**的流程,包括:
- 页面详情「推送到公众号草稿箱」
- **公开页右下角「公众号」按钮**(页面 owner 登录后可见,服务端注入)
- 公开页 quick API`mindspace-chat-wechat-draft.mjs`
- 后续新增的页面级草稿推送入口
**必须**走 `mindspace-wechat-page-draft.mjs``pushPageDraft()`,禁止旁路拼装 HTML 或直接调用微信 draft API。
每日新闻早报定时推送(`wechat-news-morning-draft.mjs`)复用同一套底部 layout(阅读原文 + 关注区),栏目正文保持早报专用结构。
## 正文 layoutMindSpace 页面)
自上而下:
1. **Hero 实图**(若有):与页面 `mindspace-cover.cover` / hero 背景 / 侧车缩略图同源,经 `uploadimg` 上传
2. **页面正文**:按 profile 转换(诗页 / 散文 / 技能模板 / 通用语义提取)
3. **阅读原文**:12px 小胶囊链接,位于正文末尾、关注区之前;有 `publicUrl` 时必须出现
4. **欢迎关注 TKMind 智趣** + 公众号二维码(无 logo、无额外导语)
## 封面(thumb_media_id
优先级:
1. **Hero 位图**webp/png/jpeg 等,与正文顶部 hero 同源)
2. `{页面}.thumbnail.png` 侧车图(仅当无 hero 时)
3. `{页面}.thumbnail.svg` 矢量侧车图(无 PNG 时,推送前转为 PNG)
4. TKMind 占位图
无 AI 生成图时,正文 Hero 与封面均可回退到 PNG/SVG 侧车缩略图。
## 校验
本地/CI
```bash
npm run verify:mindspace-wechat-mp
```
核心断言见 `wechat-draft-publication-standard.test.mjs`
## 相关文件
| 文件 | 职责 |
|------|------|
| `wechat-draft-publication-standard.mjs` | 标准常量 + 合规校验 |
| `wechat-draft-article-layout.mjs` | 阅读原文 + 底部关注区 |
| `mindspace-wechat-page-draft.mjs` | 转换、封面上传、`pushPageDraft` |
| `mindspace-chat-wechat-draft.mjs` | 公开页 quick 推送(委托 `pushPageDraft` |
+162
View File
@@ -0,0 +1,162 @@
import path from 'node:path';
import { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import { validatePageForWechatDraft } from './mindspace-wechat-page-draft.mjs';
function normalizePublicHtmlRelativePath(relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized || !normalized.startsWith('public/') || !normalized.toLowerCase().endsWith('.html')) {
throw Object.assign(new Error('仅支持 workspace 公开 HTML 页面'), { code: 'invalid_public_html_path' });
}
return normalized;
}
function titleFromPublicHtml(html, relativePath) {
const match = String(html ?? '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const fromTitle = match?.[1]?.replace(/\s+/g, ' ').trim();
if (fromTitle) return fromTitle;
const basename = path.basename(String(relativePath ?? ''), '.html');
return basename || 'MindSpace 页面';
}
async function resolvePublicHtmlContext({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
readWorkspaceHtml,
}) {
if (!mindSpacePages) {
throw Object.assign(new Error('MindSpace 页面服务未启用'), { code: 'mindspace_unavailable' });
}
if (typeof readWorkspaceHtml !== 'function') {
throw Object.assign(new Error('MindSpace 页面读取服务不可用'), { code: 'mindspace_read_unavailable' });
}
const normalized = normalizePublicHtmlRelativePath(relativePath);
const { html: content } = await readWorkspaceHtml({
userId: user.id,
relativePath: normalized,
});
const page = await mindSpacePages.findPageByRelativePath(user.id, normalized);
if (!page?.id) {
throw Object.assign(new Error('无法识别页面对应的 MindSpace 记录'), { code: 'page_not_found' });
}
let publicUrl = '';
if (mindSpacePublications?.getCurrent) {
const publication = await mindSpacePublications.getCurrent(user.id, page.id);
publicUrl = publication?.publicUrl ?? '';
}
return {
normalized,
content,
page,
pageTitle: page.title || titleFromPublicHtml(content, normalized),
pageSummary: page.summary ?? '',
publicUrl,
};
}
export async function getQuickWechatDraftFromPublicHtmlStatus({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
readWorkspaceHtml,
getWechatMpConfig,
getWechatPageDraft,
}) {
const configService = getWechatMpConfig?.();
const config = configService
? await configService.getConfig(user.id)
: { configured: false };
let context = null;
let validation = null;
try {
context = await resolvePublicHtmlContext({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
readWorkspaceHtml,
});
validation = validatePageForWechatDraft(context.content, {
pageTitle: context.pageTitle,
pageSummary: context.pageSummary,
publicUrl: context.publicUrl,
});
} catch (error) {
if (error?.code === 'page_not_found' || error?.code === 'invalid_public_html_path') {
return {
configured: Boolean(config.configured),
pageId: null,
canPush: false,
validation: {
ok: false,
profile: 'unknown',
blockers: [error.message],
warnings: [],
estimatedContentLength: 0,
},
};
}
throw error;
}
let latestRun = null;
const draftService = getWechatPageDraft?.();
if (draftService?.listRuns) {
const runs = await draftService.listRuns(user.id, { pageId: context.page.id, limit: 1 });
latestRun = runs[0] ?? null;
}
return {
configured: Boolean(config.configured),
pageId: context.page.id,
pageTitle: context.pageTitle,
canPush: Boolean(config.configured) && validation.ok,
validation,
latestRun,
};
}
export async function quickWechatDraftFromPublicHtml({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
readWorkspaceHtml,
getWechatPageDraft,
}) {
const draftService = getWechatPageDraft?.();
if (!draftService?.pushPageDraft) {
throw Object.assign(new Error('公众号草稿推送未启用'), { code: 'wechat_draft_unavailable' });
}
const context = await resolvePublicHtmlContext({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
readWorkspaceHtml,
});
const validation = validatePageForWechatDraft(context.content, {
pageTitle: context.pageTitle,
pageSummary: context.pageSummary,
publicUrl: context.publicUrl,
});
if (!validation.ok) {
throw Object.assign(new Error(validation.blockers[0] ?? '页面不符合公众号草稿推送要求'), {
code: 'wechat_draft_validation_failed',
validation,
});
}
return draftService.pushPageDraft(user.id, context.page.id, {
triggeredBy: user.id,
skipValidation: true,
});
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getQuickWechatDraftFromPublicHtmlStatus,
quickWechatDraftFromPublicHtml,
} from './mindspace-chat-wechat-draft.mjs';
const SAMPLE_HTML = `<!DOCTYPE html><html><head><title>测试页面</title></head><body><h1>标题</h1><p>正文</p></body></html>`;
test('getQuickWechatDraftFromPublicHtmlStatus reports validation and config state', async () => {
const result = await getQuickWechatDraftFromPublicHtmlStatus({
user: { id: 'user-1' },
relativePath: 'public/demo.html',
mindSpacePages: {
async findPageByRelativePath(userId, relativePath) {
assert.equal(userId, 'user-1');
assert.equal(relativePath, 'public/demo.html');
return { id: 'page-1', title: '测试页面', summary: '摘要' };
},
},
mindSpacePublications: {
async getCurrent(userId, pageId) {
assert.equal(userId, 'user-1');
assert.equal(pageId, 'page-1');
return { publicUrl: 'https://m.tkmind.cn/u/demo/pages/demo.html' };
},
},
readWorkspaceHtml: async ({ userId, relativePath }) => {
assert.equal(userId, 'user-1');
assert.equal(relativePath, 'public/demo.html');
return { html: SAMPLE_HTML };
},
getWechatMpConfig: () => ({
async getConfig() {
return { configured: true };
},
}),
getWechatPageDraft: () => ({
async listRuns() {
return [];
},
}),
});
assert.equal(result.pageId, 'page-1');
assert.equal(result.configured, true);
assert.equal(result.canPush, true);
assert.equal(result.validation.profile, 'generic');
});
test('quickWechatDraftFromPublicHtml validates before push', async () => {
await assert.rejects(
() => quickWechatDraftFromPublicHtml({
user: { id: 'user-1' },
relativePath: 'public/empty.html',
mindSpacePages: {
async findPageByRelativePath() {
return { id: 'page-1', title: '', summary: '' };
},
},
mindSpacePublications: {
async getCurrent() {
return { publicUrl: 'https://example.com/empty.html' };
},
},
readWorkspaceHtml: async () => ({ html: '' }),
getWechatPageDraft: () => ({
async pushPageDraft() {
throw new Error('should not push invalid page');
},
}),
}),
(error) => error.code === 'wechat_draft_validation_failed',
);
});
test('quickWechatDraftFromPublicHtml delegates to draft service', async () => {
const calls = [];
const result = await quickWechatDraftFromPublicHtml({
user: { id: 'user-1' },
relativePath: 'public/demo.html',
mindSpacePages: {
async findPageByRelativePath() {
return { id: 'page-1', title: '测试页面', summary: '摘要' };
},
},
mindSpacePublications: {
async getCurrent() {
return { publicUrl: 'https://example.com/demo.html' };
},
},
readWorkspaceHtml: async () => ({ html: SAMPLE_HTML }),
getWechatPageDraft: () => ({
async pushPageDraft(userId, pageId, options) {
calls.push([userId, pageId, options]);
return { ok: true, draftMediaId: 'draft-1' };
},
}),
});
assert.deepEqual(calls, [['user-1', 'page-1', { triggeredBy: 'user-1', skipValidation: true }]]);
assert.equal(result.draftMediaId, 'draft-1');
});
+156 -10
View File
@@ -7,7 +7,9 @@ if(!root)return;
var shareButton=root.querySelector('[data-action="share"]');
var captureButton=root.querySelector('[data-action="capture"]');
var plazaButton=root.querySelector('[data-action="plaza"]');
var wechatButton=root.querySelector('[data-action="wechat-draft"]');
var dialog=root.querySelector('[data-mindspace-public-share-dialog]');
var wechatDialog=root.querySelector('[data-mindspace-wechat-dialog]');
var plazaCancel=root.querySelector('[data-action="plaza-cancel"]');
var plazaConfirm=root.querySelector('[data-action="plaza-confirm"]');
var plazaAlreadyClose=root.querySelector('[data-action="plaza-already-close"]');
@@ -103,6 +105,92 @@ showPlazaView('error');
setStatus(e&&e.message?e.message:'发布失败',true);
}finally{publishing=false;plazaConfirm.disabled=false;}
});
if(wechatButton&&wechatDialog){
var wechatCancel=wechatDialog.querySelector('[data-action="wechat-cancel"]');
var wechatConfirm=wechatDialog.querySelector('[data-action="wechat-confirm"]');
var wechatSuccessClose=wechatDialog.querySelector('[data-action="wechat-success-close"]');
var wechatErrorClose=wechatDialog.querySelector('[data-action="wechat-error-close"]');
var wechatBlockedClose=wechatDialog.querySelector('[data-action="wechat-blocked-close"]');
var wechatForbiddenClose=wechatDialog.querySelector('[data-action="wechat-forbidden-close"]');
var wechatDraftId=wechatDialog.querySelector('[data-wechat-draft-id]');
var wechatHint=wechatDialog.querySelector('[data-wechat-view="confirm"] [data-wechat-hint]');
var wechatViews={
confirm:wechatDialog.querySelector('[data-wechat-view="confirm"]'),
loading:wechatDialog.querySelector('[data-wechat-view="loading"]'),
success:wechatDialog.querySelector('[data-wechat-view="success"]'),
error:wechatDialog.querySelector('[data-wechat-view="error"]'),
blocked:wechatDialog.querySelector('[data-wechat-view="blocked"]'),
forbidden:wechatDialog.querySelector('[data-wechat-view="forbidden"]')
};
var pushing=false;
var wechatChecking=false;
function isPageOwner(){return root.getAttribute('data-is-owner')==='true';}
function showWechatView(name){Object.keys(wechatViews).forEach(function(key){var view=wechatViews[key];if(view)view.hidden=key!==name;});}
function openWechatDialog(){
wechatDialog.hidden=false;
if(!isPageOwner()){showWechatView('forbidden');return;}
showWechatView('loading');
void refreshWechatDialog();
}
function closeWechatDialog(){wechatDialog.hidden=true;showWechatView('confirm');}
function setWechatHint(text){if(wechatHint)wechatHint.textContent=text||'';}
async function refreshWechatDialog(){
if(wechatChecking)return;
var relativePath=workspaceRelativePath();
if(!relativePath){showWechatView('error');setWechatHint('无法识别页面路径');return;}
wechatChecking=true;
try{
var res=await fetch('/api/mindspace/v1/pages/quick-wechat-draft-from-public-html/status?relative_path='+encodeURIComponent(relativePath),{credentials:'same-origin'});
var payload=await res.json().catch(function(){return{};});
if(!res.ok){
if(res.status===401)throw new Error('请先登录 MindSpace');
var errBody=apiErrorBody(payload);
throw new Error(errBody.message||'无法检查公众号草稿状态');
}
var data=payload.data||{};
if(!data.configured){showWechatView('blocked');setWechatHint('请先在 M 配置中绑定公众号 AppID 和 AppSecret。');return;}
var blockers=(data.validation&&data.validation.blockers)||[];
if(!data.canPush){showWechatView('error');setWechatHint(blockers[0]||'当前页面暂不符合推送要求');return;}
setWechatHint(data.pageTitle||'当前页面');
showWechatView('confirm');
}catch(e){
showWechatView('error');
setWechatHint(e&&e.message?e.message:'无法检查公众号草稿状态');
}finally{wechatChecking=false;}
}
wechatButton.addEventListener('click',openWechatDialog);
wechatCancel&&wechatCancel.addEventListener('click',closeWechatDialog);
wechatSuccessClose&&wechatSuccessClose.addEventListener('click',closeWechatDialog);
wechatErrorClose&&wechatErrorClose.addEventListener('click',closeWechatDialog);
wechatBlockedClose&&wechatBlockedClose.addEventListener('click',closeWechatDialog);
wechatForbiddenClose&&wechatForbiddenClose.addEventListener('click',closeWechatDialog);
wechatDialog.addEventListener('click',function(event){if(event.target===wechatDialog)closeWechatDialog();});
wechatConfirm&&wechatConfirm.addEventListener('click',async function(){
if(pushing)return;
var relativePath=workspaceRelativePath();
if(!relativePath){setStatus('无法识别页面路径',true);closeWechatDialog();return;}
pushing=true;
wechatConfirm.disabled=true;
showWechatView('loading');
setWechatHint('正在推送到公众号草稿箱…');
try{
var res=await fetch('/api/mindspace/v1/pages/quick-wechat-draft-from-public-html',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({relative_path:relativePath})});
var payload=await res.json().catch(function(){return{};});
if(!res.ok){
if(res.status===401)throw new Error('请先登录 MindSpace');
var errBody=apiErrorBody(payload);
throw new Error(errBody.message||'推送失败');
}
var data=payload.data||{};
showWechatView('success');
if(wechatDraftId){wechatDraftId.textContent=data.draftMediaId?'草稿 media_id'+data.draftMediaId:'';}
setStatus('已推送到公众号草稿箱',false,true);
}catch(e){
showWechatView('error');
setWechatHint(e&&e.message?e.message:'推送失败');
}finally{pushing=false;wechatConfirm.disabled=false;}
});
}
})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
@@ -110,9 +198,16 @@ const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
const PUBLIC_FILE_SHARE_WIDGET_BLOCK_PATTERN =
/<style id="mindspace-public-share-style">[\s\S]*?<\/script>\s*/i;
export function stripPublicFileShareWidget(html) {
return String(html ?? '').replace(PUBLIC_FILE_SHARE_WIDGET_BLOCK_PATTERN, '');
}
export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
let source = stripPublicFileShareWidget(html);
if (!source) {
return { html: source, scriptHashes: [] };
}
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — the "发布 Plaza" entry mutates content
@@ -164,6 +259,55 @@ export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
const plazaButtonMarkup = isOwner
? ' <button type="button" data-action="plaza">发布 Plaza</button>\n'
: '';
// REGRESSION GUARD: mindspace-public-wechat-visibility — the "公众号" entry is visible to all
// viewers so visitors discover the feature, but only the page owner may push to WeChat draft.
const wechatButtonMarkup = ' <button type="button" data-action="wechat-draft">公众号</button>\n';
const wechatDialogMarkup = ` <div data-mindspace-wechat-dialog hidden>
<div data-mindspace-public-share-dialog-panel role="dialog" aria-modal="true" aria-labelledby="mindspace-wechat-draft-title">
<div data-wechat-view="confirm">
<h3 id="mindspace-wechat-draft-title">推送到公众号草稿箱</h3>
<p data-wechat-hint>将把当前页面按标准格式推送到你的公众号草稿箱,确认继续?</p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="wechat-cancel">取消</button>
<button type="button" data-action="wechat-confirm">确认推送</button>
</div>
</div>
<div data-wechat-view="loading" hidden>
<h3>请稍候</h3>
<p data-wechat-hint>正在检查页面与公众号配置…</p>
</div>
<div data-wechat-view="blocked" hidden>
<h3>尚未配置公众号</h3>
<p>请先在 M 配置中绑定公众号 AppID 和 AppSecret。</p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="wechat-blocked-close">知道了</button>
</div>
</div>
<div data-wechat-view="forbidden" hidden>
<h3>暂时无权限</h3>
<p>仅页面作者可推送到公众号草稿箱。</p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="wechat-forbidden-close">知道了</button>
</div>
</div>
<div data-wechat-view="success" hidden>
<h3>推送成功</h3>
<p>内容已写入公众号草稿箱,请登录微信公众平台审阅后发表。</p>
<p data-wechat-draft-id style="font-size:12px;opacity:.82;"></p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="wechat-success-close">关闭</button>
</div>
</div>
<div data-wechat-view="error" hidden>
<h3>操作失败</h3>
<p data-wechat-hint>请稍后重试,或先登录 MindSpace。</p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="wechat-error-close">关闭</button>
</div>
</div>
</div>
</div>
`;
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
@@ -171,29 +315,30 @@ export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button[data-action="wechat-draft"]{background:#07c160}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog],[data-mindspace-wechat-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden],[data-mindspace-wechat-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden],[data-mindspace-public-share-dialog-panel] [data-wechat-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"],[data-mindspace-public-share-dialog-actions] [data-action="wechat-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="wechat-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="wechat-error-close"],[data-mindspace-public-share-dialog-actions] [data-action="wechat-blocked-close"],[data-mindspace-public-share-dialog-actions] [data-action="wechat-forbidden-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"],[data-mindspace-public-share-dialog-actions] [data-action="wechat-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
${plazaDialogMarkup} <small aria-live="polite"></small>
<div data-mindspace-public-share data-is-owner="${isOwner ? 'true' : 'false'}">
${plazaDialogMarkup}${wechatDialogMarkup} <small aria-live="polite"></small>
<div data-mindspace-public-share-actions>
${plazaButtonMarkup} <button type="button" data-action="capture">保存长图</button>
${wechatButtonMarkup}${plazaButtonMarkup} <button type="button" data-action="capture">保存长图</button>
<button type="button" data-action="share">公开分享</button>
</div>
</div>
@@ -212,4 +357,5 @@ ${plazaButtonMarkup} <button type="button" data-action="capture">保存长图
export const publicShareWidgetInternals = {
PUBLIC_FILE_SHARE_SCRIPT_HASH,
stripPublicFileShareWidget,
};
+22 -3
View File
@@ -7,7 +7,10 @@ test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
assert.match(result.html, /data-action="plaza"/);
assert.match(result.html, /发布到 Plaza/);
assert.match(result.html, /data-action="plaza-confirm"/);
assert.match(result.html, /data-action="wechat-draft"/);
assert.match(result.html, /公众号/);
assert.match(result.html, /quick-plaza-from-public-html/);
assert.match(result.html, /quick-wechat-draft-from-public-html/);
assert.match(result.html, /quick-plaza-from-public-html\/status/);
assert.match(result.html, /data-plaza-view="already"/);
assert.match(result.html, /已在广场发布/);
@@ -19,16 +22,20 @@ test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton hides Plaza entry and dialog for non-owner viewers', () => {
test('injectPublicFileShareButton hides Plaza entry but keeps WeChat entry for non-owner viewers', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
{ isOwner: false },
);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza">/);
assert.match(result.html, /<button type="button" data-action="wechat-draft">/);
assert.match(result.html, /data-is-owner="false"/);
assert.match(result.html, /暂时无权限/);
assert.match(result.html, /data-wechat-view="forbidden"/);
assert.match(result.html, /isPageOwner/);
assert.doesNotMatch(result.html, /发布到 Plaza/);
assert.doesNotMatch(result.html, /<button type="button" data-action="plaza-confirm">/);
assert.doesNotMatch(result.html, /<div data-mindspace-public-share-dialog/);
assert.doesNotMatch(result.html, /data-mindspace-public-share-dialog-panel role="dialog"/);
assert.doesNotMatch(result.html, /<div data-mindspace-public-share-dialog hidden>/);
// Non-Plaza actions must remain available to visitors.
assert.match(result.html, /data-action="capture"/);
assert.match(result.html, /data-action="share"/);
@@ -36,6 +43,18 @@ test('injectPublicFileShareButton hides Plaza entry and dialog for non-owner vie
assert.equal(result.scriptHashes.length, 1);
});
test('injectPublicFileShareButton upgrades stale baked widget markup', () => {
const stale = injectPublicFileShareButton('<!doctype html><html><body><h1>Hi</h1></body></html>', {
isOwner: false,
}).html;
const upgraded = injectPublicFileShareButton(stale, { isOwner: true });
assert.match(upgraded.html, /data-action="wechat-draft"/);
assert.match(upgraded.html, /data-is-owner="true"/);
assert.match(upgraded.html, /data-action="plaza"/);
assert.doesNotMatch(upgraded.html, /data-is-owner="false"/);
assert.equal(upgraded.scriptHashes.length, 1);
});
test('injectPublicFileShareButton keeps Plaza entry when isOwner explicitly true', () => {
const result = injectPublicFileShareButton(
'<!doctype html><html><body><h1>Hi</h1></body></html>',
+767 -50
View File
@@ -8,6 +8,7 @@ import {
PUBLIC_ZONE_DIR,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs';
import { resolvePageWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
@@ -17,8 +18,17 @@ import {
buildDailyNewsWechatDraftArticleForPush,
convertNewsPageHtmlToWechatArticle,
isDailyNewsFormat,
uploadWechatArticleContentImage,
uploadWechatPermanentThumb,
} from './wechat-news-morning-draft.mjs';
import { finalizeWechatDraftArticleForPush } from './wechat-draft-article-layout.mjs';
import {
verifyWechatDraftCoverResolution,
verifyWechatDraftPublicationContent,
WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION,
} from './wechat-draft-publication-standard.mjs';
export { WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION };
const RUNS_TABLE = 'h5_user_wechat_page_draft_runs';
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
@@ -46,19 +56,618 @@ function stripHtml(value) {
.trim();
}
function inlineHtmlToWechatText(value) {
return String(value ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '<span style="color:#d98f4a;font-style:normal;">$1</span>')
.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '<strong>$1</strong>')
.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '<strong>$1</strong>')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function stripLegacyReadOriginalFooter(content) {
return String(content ?? '')
.replace(/<p[^>]*>\s*阅读原文[:][\s\S]*?<\/p>/gi, '')
.trim();
}
export function isNewsHotspotFormat(html) {
const source = String(html ?? '');
if (isDailyNewsFormat(source)) return false;
if (/news-hotspot|今日新闻热点/u.test(source)) return true;
if (!/class="card"/.test(source)) return false;
return /<h2[^>]*>/.test(source)
&& (/<span class="tag"/.test(source) || /<div class="k"/.test(source));
}
function normalizeImageRef(value) {
const ref = String(value ?? '').trim();
if (!ref || /^data:/i.test(ref)) return '';
return ref;
}
function resolveImageFilePath(imageRef, publishDir, htmlRelativePath = '') {
const ref = normalizeImageRef(imageRef);
if (!ref) return null;
if (/^https?:\/\//i.test(ref)) return ref;
const htmlDir = htmlRelativePath
? path.dirname(path.join(publishDir, htmlRelativePath))
: publishDir;
const publicDir = path.join(publishDir, PUBLIC_ZONE_DIR);
const candidates = [
path.resolve(htmlDir, ref.replace(/^\.\//, '')),
path.resolve(publicDir, ref.replace(/^\.\//, '')),
path.resolve(publishDir, ref.replace(/^\.\//, '')),
path.resolve(publishDir, ref.replace(/^\/+/, '')),
];
for (const candidate of candidates) {
if (/^https?:\/\//i.test(candidate)) return candidate;
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
function normalizeWechatImageUrl(imageUrl) {
return String(imageUrl ?? '').trim().replace(/^http:\/\//i, 'https://');
}
function renderWechatImage(imageUrl, alt = '') {
const safeUrl = normalizeWechatImageUrl(imageUrl);
if (!safeUrl) return '';
return [
'<section style="margin:0 0 16px;text-align:center;">',
`<img src="${safeUrl}" alt="${escapeHtml(alt)}" style="width:100%;max-width:100%;display:block;margin:0 auto;border-radius:10px;" />`,
'</section>',
].join('');
}
function resolveSidecarThumbnailPaths(publishDir, htmlRelativePath) {
if (!publishDir || !htmlRelativePath) return [];
const htmlPath = path.join(publishDir, htmlRelativePath);
const pngPath = htmlPath.replace(/\.html$/i, '.thumbnail.png');
const svgPath = htmlPath.replace(/\.html$/i, '.thumbnail.svg');
if (fs.existsSync(pngPath)) return [pngPath];
if (fs.existsSync(svgPath)) return [svgPath];
return [];
}
function collectPageImageCandidatesFromHtml(html, { publishDir = '', htmlRelativePath = '' } = {}) {
const source = String(html ?? '');
const refs = [];
const coverPath = extractMindspaceCoverPath(source);
if (coverPath) refs.push(coverPath);
for (const match of source.matchAll(/background-image:\s*url\(["']?([^"')]+)["']?\)/gi)) {
refs.push(match[1]);
}
for (const match of source.matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
refs.push(match[1]);
}
const seen = new Set();
const resolved = [];
for (const ref of refs) {
const normalized = normalizeImageRef(ref);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
resolved.push({
ref: normalized,
absPath: resolveImageFilePath(normalized, publishDir, htmlRelativePath),
});
}
return resolved;
}
export function collectPageImageCandidates(html, { publishDir = '', htmlRelativePath = '' } = {}) {
const resolved = collectPageImageCandidatesFromHtml(html, { publishDir, htmlRelativePath });
if (
htmlRelativePath
&& publishDir
&& !resolveHeroImagePath(html, { publishDir, htmlRelativePath })
) {
const seen = new Set(resolved.map((item) => item.ref));
for (const sidecarPath of resolveSidecarThumbnailPaths(publishDir, htmlRelativePath)) {
const ref = path.basename(sidecarPath);
if (seen.has(ref)) continue;
seen.add(ref);
resolved.push({ ref, absPath: sidecarPath });
}
}
return resolved;
}
function countBodyImages(html) {
return collectPageImageCandidates(html).length;
}
function extractBodyHtml(source) {
const match = String(source ?? '').match(/<body[^>]*>([\s\S]*?)<\/body>/i);
return match ? match[1] : String(source ?? '');
}
function stripMindspaceDeliveryChrome(source) {
let body = extractBodyHtml(source);
body = body.replace(/<style id="mindspace-public-share-style">[\s\S]*?<\/script>\s*/i, '');
body = body.replace(/<script[\s\S]*?<\/script>/gi, '');
body = body.replace(/<style[\s\S]*?<\/style>/gi, '');
body = body.replace(/<link[^>]*>/gi, '');
body = body.replace(/<div[^>]*data-mindspace-wechat-dialog[\s\S]*?<\/div>\s*/gi, '');
body = body.replace(/<div[^>]*data-mindspace-public-share[\s\S]*?<\/div>\s*/gi, '');
body = body.replace(/<small[^>]*aria-live="polite"[^>]*>[\s\S]*?<\/small>/gi, '');
return body.trim();
}
function isMindspaceBoilerplateText(text) {
const normalized = stripHtml(text);
if (!normalized) return true;
if (/^(TKMind · 智趣|AI 配图服务暂时不可用)$/u.test(normalized)) return true;
if (/^(取消|确认推送|知道了|关闭|保存长图|公开分享|公众号|发布 Plaza)$/u.test(normalized)) return true;
return false;
}
function resolveMindspaceMainContentHtml(body) {
const main = body.match(/<main[^>]*>([\s\S]*?)<\/main>/i)?.[1];
if (main) return main;
const article = body.match(/<article[^>]*>([\s\S]*?)<\/article>/i)?.[1];
if (article) return article;
const page = body.match(/<div class="page"[^>]*>([\s\S]*?)<\/div>/i)?.[1];
if (page) return page;
const content = body.match(/<div class="content"[^>]*>([\s\S]*?)<\/div>/i)?.[1];
if (content) return content;
return body;
}
function appendParagraphsFromHtml(html, blocks, seen) {
for (const match of String(html ?? '').matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
const text = stripHtml(match[1]);
if (!text || isMindspaceBoilerplateText(text) || seen.has(text)) continue;
seen.add(text);
blocks.push(
`<p style="margin:0 0 16px;font-size:16px;line-height:1.9;color:#333;text-align:justify;">${escapeHtml(text)}</p>`,
);
}
}
function appendListsFromHtml(html, blocks, seen) {
for (const match of String(html ?? '').matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)) {
const text = stripHtml(match[1]);
if (!text || isMindspaceBoilerplateText(text) || seen.has(text)) continue;
seen.add(text);
blocks.push(
`<p style="margin:0 0 10px;font-size:15px;line-height:1.8;color:#444;padding-left:12px;">• ${escapeHtml(text)}</p>`,
);
}
}
function appendBlockquotesFromHtml(html, blocks, seen) {
for (const match of String(html ?? '').matchAll(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi)) {
const text = stripHtml(match[1]);
if (!text || isMindspaceBoilerplateText(text) || seen.has(text)) continue;
seen.add(text);
blocks.push(
`<blockquote style="margin:0 0 16px;padding:12px 16px;border-left:3px solid #d98f4a;background:#faf7f1;color:#2a1a12;font-size:15px;line-height:1.8;">${escapeHtml(text)}</blockquote>`,
);
}
}
function appendFallbackMindspaceArticleSections(sections, source, { skipTexts = new Set() } = {}) {
let body = stripMindspaceDeliveryChrome(source);
body = body
.replace(/<header class="hero"[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<div class="hero"[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div class="title-block"[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div class="divider"[^>]*>\s*<\/div>/gi, '')
.replace(/<div class="tags"[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '');
const contentHtml = resolveMindspaceMainContentHtml(body);
const seen = new Set([...skipTexts].map((text) => stripHtml(text)).filter(Boolean));
const blocks = [];
for (const match of contentHtml.matchAll(/<h2[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2|<h3|$)/gi)) {
const heading = stripHtml(match[1]);
if (!heading || seen.has(heading)) continue;
seen.add(heading);
blocks.push(
`<p style="margin:0 0 10px;font-size:17px;font-weight:700;color:#2a1a12;">${escapeHtml(heading)}</p>`,
);
appendParagraphsFromHtml(match[2], blocks, seen);
appendListsFromHtml(match[2], blocks, seen);
}
for (const match of contentHtml.matchAll(/<h3[^>]*>([\s\S]*?)<\/h3>([\s\S]*?)(?=<h[23]|$)/gi)) {
const heading = stripHtml(match[1]);
if (!heading || seen.has(heading)) continue;
seen.add(heading);
blocks.push(
`<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#444;">${escapeHtml(heading)}</p>`,
);
appendParagraphsFromHtml(match[2], blocks, seen);
appendListsFromHtml(match[2], blocks, seen);
}
appendParagraphsFromHtml(contentHtml, blocks, seen);
appendListsFromHtml(contentHtml, blocks, seen);
appendBlockquotesFromHtml(contentHtml, blocks, seen);
if (blocks.length === 0) return;
sections.push('<section style="margin:0 0 20px;">');
sections.push(...blocks);
sections.push('</section>');
}
export function convertRichMindSpacePageHtmlToWechatArticle(
html,
{ pageTitle = '', pageSummary = '', imageUrlMap = new Map() } = {},
) {
const source = String(html ?? '');
const title = (extractPageTitle(source) || pageTitle || 'TKMind 页面').slice(0, 64);
const digest = (extractMetaDescription(source) || pageSummary || title).slice(0, 120);
const sections = [];
const heroTitle = stripHtml(
source.match(/<header class="hero"[^>]*>[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? source.match(/<article[^>]*>[\s\S]*?<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? '',
);
const heroLead = inlineHtmlToWechatText(
source.match(/<header class="hero"[^>]*>[\s\S]*?<p class="lead"[^>]*>([\s\S]*?)<\/p>/i)?.[1]
?? source.match(/<p class="pitch-sub"[^>]*>([\s\S]*?)<\/p>/i)?.[1]
?? '',
);
const titleBlockTitle = stripHtml(
source.match(/<div class="title-block"[^>]*>[\s\S]*?<h1[^>]*class="title"[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? source.match(/<h1[^>]*class="title"[^>]*>([\s\S]*?)<\/h1>/i)?.[1]
?? '',
);
const titleBlockSubtitle = stripHtml(
source.match(/<div class="title-block"[^>]*>[\s\S]*?<p[^>]*class="subtitle"[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '',
);
const displayTitle = heroTitle || titleBlockTitle || title;
const displayLead = heroLead || titleBlockSubtitle;
const coverRef = extractMindspaceCoverPath(source);
const sidecarThumbRef = [...imageUrlMap.keys()].find((ref) => /\.thumbnail\.(png|svg)$/i.test(ref)) ?? '';
const heroImageUrl = normalizeWechatImageUrl(
imageUrlMap.get(coverRef)
?? (sidecarThumbRef ? imageUrlMap.get(sidecarThumbRef) : undefined)
?? imageUrlMap.values().next().value
?? '',
);
if (heroImageUrl) {
sections.push(renderWechatImage(heroImageUrl, displayTitle));
}
sections.push([
'<section style="text-align:center;padding:20px 14px 16px;background:linear-gradient(135deg,#2a1a12,#4a3020);color:#fff;border-radius:12px;margin:0 0 16px;">',
`<p style="margin:0 0 8px;font-size:22px;font-weight:700;line-height:1.35;">${escapeHtml(displayTitle)}</p>`,
displayLead
? `<p style="margin:0;font-size:14px;opacity:.88;line-height:1.8;">${displayLead.replace(/\n/g, '<br/>')}</p>`
: '',
'</section>',
].filter(Boolean).join(''));
const summary = stripHtml(pageSummary);
if (summary && summary !== stripHtml(displayLead) && summary !== displayTitle) {
sections.push(
`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${escapeHtml(summary)}</p>`,
);
}
const proseBlocks = [...source.matchAll(/<div class="prose"[^>]*>([\s\S]*?)<\/div>/gi)];
if (proseBlocks.length > 0) {
sections.push('<section style="margin:0 0 20px;">');
for (const [, block] of proseBlocks) {
for (const [, paragraph] of block.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
const text = stripHtml(paragraph);
if (!text) continue;
sections.push(
`<p style="margin:0 0 16px;font-size:16px;line-height:2;color:#5c3a1e;text-align:justify;">${escapeHtml(text)}</p>`,
);
}
}
sections.push('</section>');
}
const tagBlocks = [...source.matchAll(/<div class="tags"[^>]*>([\s\S]*?)<\/div>/gi)];
if (tagBlocks.length > 0) {
const tags = tagBlocks.flatMap(([, block]) =>
[...block.matchAll(/<span class="tag"[^>]*>([\s\S]*?)<\/span>/gi)]
.map(([, tag]) => stripHtml(tag))
.filter(Boolean),
);
if (tags.length > 0) {
sections.push(
`<p style="margin:0 0 20px;font-size:13px;line-height:1.8;color:#8b6b4a;text-align:center;">${tags.map((tag) => escapeHtml(tag)).join(' · ')}</p>`,
);
}
}
const stanzaBlocks = [...source.matchAll(/<div class="stanza"[^>]*>([\s\S]*?)<\/div>/gi)];
if (stanzaBlocks.length > 0) {
sections.push('<section style="margin:0 0 20px;">');
sections.push('<p style="margin:0 0 12px;font-size:13px;font-weight:600;color:#888;letter-spacing:.2em;">正文</p>');
for (const [, block] of stanzaBlocks) {
const num = stripHtml(block.match(/<span class="num"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const poem = inlineHtmlToWechatText(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
if (!poem) continue;
sections.push([
'<div style="margin:0 0 18px;padding:14px 12px;background:#faf7f1;border-radius:10px;">',
num ? `<p style="margin:0 0 8px;font-size:11px;color:#d98f4a;letter-spacing:.24em;">${escapeHtml(num)}</p>` : '',
`<p style="margin:0;font-size:16px;line-height:2;color:#2a1a12;white-space:pre-wrap;">${poem.replace(/\n/g, '<br/>')}</p>`,
'</div>',
].filter(Boolean).join(''));
}
sections.push('</section>');
}
const finalText = inlineHtmlToWechatText(
source.match(/<div class="final"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '',
);
if (finalText) {
sections.push(
`<p style="margin:0 0 20px;font-size:15px;line-height:1.9;color:#666;text-align:center;">${finalText.replace(/\n/g, '<br/>')}</p>`,
);
}
const cardBlocks = [...source.matchAll(/<div class="card"[^>]*>([\s\S]*?)<\/div>/gi)];
const imageryCards = cardBlocks.filter(([, block]) =>
/<span class="glyph"/.test(block) || /<h3[^>]*>/.test(block),
);
if (imageryCards.length > 0) {
sections.push('<section style="margin:0 0 20px;">');
sections.push('<p style="margin:0 0 12px;font-size:13px;font-weight:600;color:#888;letter-spacing:.2em;">意象</p>');
for (const [, block] of imageryCards) {
const glyph = stripHtml(block.match(/<span class="glyph"[^>]*>([\s\S]*?)<\/span>/i)?.[1] ?? '');
const cardTitle = stripHtml(block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i)?.[1] ?? '');
const cardBody = stripHtml(block.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1] ?? '');
sections.push([
'<div style="margin:0 0 14px;padding:16px;background:#fff;border:1px solid #eee;border-radius:10px;">',
glyph ? `<p style="margin:0 0 8px;font-size:24px;color:#d98f4a;">${escapeHtml(glyph)}</p>` : '',
cardTitle ? `<p style="margin:0 0 8px;font-size:15px;font-weight:700;color:#2a1a12;">${escapeHtml(cardTitle)}</p>` : '',
cardBody ? `<p style="margin:0;font-size:14px;line-height:1.8;color:#666;">${escapeHtml(cardBody)}</p>` : '',
'</div>',
].filter(Boolean).join(''));
}
sections.push('</section>');
}
const noteBlock = source.match(/<section class="note"[^>]*>([\s\S]*?)<\/section>/i)?.[1] ?? '';
if (noteBlock) {
sections.push('<section style="margin:0 0 8px;">');
sections.push('<p style="margin:0 0 12px;font-size:13px;font-weight:600;color:#888;letter-spacing:.2em;">创作手记</p>');
const noteTitle = stripHtml(noteBlock.match(/<h2[^>]*>([\s\S]*?)<\/h2>/i)?.[1] ?? '');
if (noteTitle) {
sections.push(`<p style="margin:0 0 12px;font-size:16px;font-weight:700;color:#2a1a12;">${escapeHtml(noteTitle)}</p>`);
}
for (const [, paragraph] of noteBlock.matchAll(/<p[^>]*>([\s\S]*?)<\/p>/gi)) {
const text = stripHtml(paragraph);
if (!text) continue;
sections.push(`<p style="margin:0 0 12px;font-size:14px;line-height:1.9;color:#555;">${escapeHtml(text)}</p>`);
}
const quote = stripHtml(noteBlock.match(/<div class="quote"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
if (quote) {
sections.push(
`<blockquote style="margin:0;padding:12px 16px;border-left:3px solid #d98f4a;background:#faf7f1;color:#2a1a12;font-size:15px;line-height:1.8;">${escapeHtml(quote)}</blockquote>`,
);
}
sections.push('</section>');
}
const specializedContentExtracted =
proseBlocks.length > 0
|| stanzaBlocks.length > 0
|| Boolean(finalText)
|| imageryCards.length > 0
|| Boolean(noteBlock);
if (!specializedContentExtracted) {
appendFallbackMindspaceArticleSections(sections, source, {
skipTexts: new Set([displayTitle, displayLead, summary].filter(Boolean)),
});
}
for (const match of source.matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
const ref = normalizeImageRef(match[1]);
const uploaded = normalizeWechatImageUrl(imageUrlMap.get(ref));
if (uploaded && uploaded !== heroImageUrl) {
sections.push(renderWechatImage(uploaded));
}
}
return {
title: displayTitle.slice(0, 64),
digest,
content: sections.join('\n'),
imageCount: imageUrlMap.size,
};
}
export function resolveWechatDraftProfile(html) {
const source = String(html ?? '');
if (isDailyNewsFormat(source)) return 'daily-news';
if (isNewsHotspotFormat(source)) return 'news-hotspot';
if (/name=["']mindspace-cover["']/i.test(source)) return 'mindspace-page';
if (/data-mindspace-page-tag=["']platform-brand["']/i.test(source)) return 'mindspace-page';
if (/class="stanza"/.test(source) || /class="hero"/.test(source)) return 'mindspace-page';
return 'generic';
}
export function validatePageForWechatDraft(
html,
{ pageTitle = '', pageSummary = '', publicUrl = '' } = {},
) {
const source = String(html ?? '').trim();
const profile = resolveWechatDraftProfile(source);
const warnings = [];
const blockers = [];
if (!source) {
blockers.push('页面 HTML 为空,无法推送草稿。');
}
if (!stripHtml(pageTitle || extractPageTitle(source))) {
blockers.push('缺少页面标题。');
}
if (!publicUrl) {
warnings.push('页面尚未公开或缺少阅读原文链接,草稿中将省略「阅读原文」。');
}
if (countBodyImages(source) === 0 && profile === 'mindspace-page') {
warnings.push('页面未检测到可上传图片,草稿将以文字为主。');
}
if (profile === 'mindspace-page') {
warnings.push('MindSpace 页面结构转换后视觉可能与原页略有差异。');
}
return {
ok: blockers.length === 0,
profile,
warnings,
blockers,
};
}
async function uploadPageImagesForWechat(
accessToken,
html,
{ publishDir = '', htmlRelativePath = '', wechatFetch = undiciFetch } = {},
) {
const imageUrlMap = new Map();
const candidates = collectPageImageCandidates(html, { publishDir, htmlRelativePath });
for (const { ref, absPath } of candidates) {
if (!absPath || imageUrlMap.has(ref)) continue;
if (/^https?:\/\//i.test(absPath)) {
imageUrlMap.set(ref, absPath);
continue;
}
let buffer = fs.readFileSync(absPath);
if (/\.(webp|svg)$/i.test(absPath)) {
buffer = await sharp(buffer).png().toBuffer();
}
const uploaded = normalizeWechatImageUrl(
await uploadWechatArticleContentImage(
accessToken,
buffer,
{ wechatFetch, filename: `${path.basename(absPath).replace(/\.(webp|svg)$/i, '')}.png` },
),
);
imageUrlMap.set(ref, uploaded);
}
return imageUrlMap;
}
export async function buildMindSpacePageWechatDraftArticleForPush({
html,
publicUrl = '',
pageTitle = '',
pageSummary = '',
accessToken = null,
publishDir = '',
htmlRelativePath = '',
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
const imageUrlMap = accessToken
? await uploadPageImagesForWechat(accessToken, html, { publishDir, htmlRelativePath, wechatFetch })
: new Map();
const profile = resolveWechatDraftProfile(html);
let article;
if (profile === 'news-hotspot') {
article = convertNewsPageHtmlToWechatArticle(html, { publicUrl });
article.content = stripLegacyReadOriginalFooter(article.content);
} else if (profile === 'mindspace-page') {
article = convertRichMindSpacePageHtmlToWechatArticle(html, {
pageTitle,
pageSummary,
imageUrlMap,
});
} else {
article = convertGenericPageHtmlToWechatArticle(html, {
pageTitle,
pageSummary,
});
article.content = stripLegacyReadOriginalFooter(article.content);
}
const finalized = await finalizeWechatDraftArticleForPush({
content: article.content,
publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
});
return {
...article,
content: finalized.content,
contentSourceUrl: publicUrl || undefined,
uploadedImageCount: imageUrlMap.size,
};
}
export async function buildWechatDraftPublicationBundleForPush({
html,
publicUrl = '',
pageTitle = '',
pageSummary = '',
accessToken = null,
publishDir = '',
htmlRelativePath = '',
h5Root = process.cwd(),
userId = '',
workspaceRelativePath = htmlRelativePath,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
const profile = resolveWechatDraftProfile(html);
const article = isDailyNewsFormat(html)
? await buildDailyNewsWechatDraftArticleForPush({
html,
publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
publishDir,
})
: await buildMindSpacePageWechatDraftArticleForPush({
html,
publicUrl,
pageTitle,
pageSummary,
accessToken,
publishDir,
htmlRelativePath,
wechatFetch,
memindLibRoot,
});
const thumbResolved = resolveWechatDraftThumbPath(html, {
h5Root,
userId,
workspaceRelativePath,
publishDir,
});
const contentCheck = verifyWechatDraftPublicationContent(article.content, { publicUrl });
const coverCheck = verifyWechatDraftCoverResolution(thumbResolved);
if (!contentCheck.ok || !coverCheck.ok) {
throw Object.assign(
new Error(`草稿不符合推送标准 ${WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION}`),
{
code: 'wechat_draft_standard_violation',
contentIssues: contentCheck.issues,
coverIssues: coverCheck.issues,
},
);
}
return {
article,
thumbResolved,
profile,
standardVersion: WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION,
};
}
export function convertGenericPageHtmlToWechatArticle(
html,
{ publicUrl = '', pageTitle = '', pageSummary = '' } = {},
) {
const title = (extractPageTitle(html) || pageTitle || 'TKMind 页面').slice(0, 64);
const digest = (extractMetaDescription(html) || pageSummary || title).slice(0, 120);
const bodyMatch = String(html ?? '').match(/<body[^>]*>([\s\S]*?)<\/body>/i);
let body = bodyMatch ? bodyMatch[1] : String(html ?? '');
body = body
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<link[^>]*>/gi, '')
.trim();
const sections = [];
const summary = stripHtml(pageSummary);
if (summary) {
@@ -66,18 +675,14 @@ export function convertGenericPageHtmlToWechatArticle(
`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${escapeHtml(summary)}</p>`,
);
}
if (body) {
sections.push(`<div style="font-size:15px;line-height:1.8;color:#333;">${body}</div>`);
} else {
appendFallbackMindspaceArticleSections(sections, html, {
skipTexts: new Set([title, summary].filter(Boolean)),
});
if (sections.length === (summary ? 1 : 0)) {
sections.push(
`<p style="margin:0;font-size:15px;line-height:1.8;color:#333;">${escapeHtml(title)}</p>`,
);
}
if (publicUrl) {
sections.push(
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${escapeHtml(publicUrl)}</a></p>`,
);
}
return {
title,
digest,
@@ -93,9 +698,11 @@ export function convertPageHtmlToWechatArticle(
if (isDailyNewsFormat(html)) {
return convertNewsPageHtmlToWechatArticle(html, { publicUrl });
}
const structured = convertNewsPageHtmlToWechatArticle(html, { publicUrl });
if ((structured.cardCount ?? 0) > 0) {
return structured;
if (isNewsHotspotFormat(html)) {
return convertNewsPageHtmlToWechatArticle(html, { publicUrl });
}
if (resolveWechatDraftProfile(html) === 'mindspace-page') {
return convertRichMindSpacePageHtmlToWechatArticle(html, { pageTitle, pageSummary });
}
return convertGenericPageHtmlToWechatArticle(html, { publicUrl, pageTitle, pageSummary });
}
@@ -125,12 +732,18 @@ function cryptoRandomId() {
function resolveThumbPath(h5Root, userId, workspaceRelativePath) {
if (!h5Root || !userId || !workspaceRelativePath) return null;
const parts = String(workspaceRelativePath)
.replace(/^\/+/, '')
.split('/')
.filter(Boolean);
const baseParts = parts[0]?.toLowerCase() === PUBLIC_ZONE_DIR
? parts
: [PUBLIC_ZONE_DIR, ...parts];
const htmlPath = path.join(
h5Root,
PUBLISH_ROOT_DIR,
String(userId),
PUBLIC_ZONE_DIR,
...workspaceRelativePath.split('/'),
...baseParts,
);
const pngPath = htmlPath.replace(/\.html$/i, '.thumbnail.png');
if (fs.existsSync(pngPath)) return pngPath;
@@ -139,23 +752,114 @@ function resolveThumbPath(h5Root, userId, workspaceRelativePath) {
return null;
}
async function loadThumbBuffer(thumbPath) {
function isWechatDraftDecorativeImageRef(ref = '') {
return /mp-follow-qrcode|tkmind-icon|\/brand\//i.test(String(ref ?? ''));
}
export function resolveHeroImagePath(html, { publishDir = '', htmlRelativePath = '' } = {}) {
const coverRef = extractMindspaceCoverPath(html);
if (coverRef && !/\.svg$/i.test(coverRef)) {
const coverPath = resolveImageFilePath(coverRef, publishDir, htmlRelativePath);
if (coverPath && !/\.svg$/i.test(coverPath)) {
return coverPath;
}
}
const candidates = collectPageImageCandidatesFromHtml(html, { publishDir, htmlRelativePath });
for (const { ref, absPath } of candidates) {
if (!absPath || /\.svg$/i.test(absPath) || isWechatDraftDecorativeImageRef(ref)) {
continue;
}
return absPath;
}
return null;
}
function resolveRasterThumbSidecarPath(h5Root, userId, workspaceRelativePath) {
const sidecarPath = resolveThumbPath(h5Root, userId, workspaceRelativePath);
if (sidecarPath && /\.(png|svg)$/i.test(sidecarPath)) {
return sidecarPath;
}
return null;
}
export function resolveWechatDraftThumbPath(
html,
{ h5Root, userId, workspaceRelativePath, publishDir } = {},
) {
const heroPath = resolveHeroImagePath(html, {
publishDir,
htmlRelativePath: workspaceRelativePath,
});
if (heroPath) {
return { source: 'hero', path: heroPath };
}
const sidecarPath = resolveRasterThumbSidecarPath(h5Root, userId, workspaceRelativePath);
if (sidecarPath) {
return { source: 'thumbnail-sidecar', path: sidecarPath };
}
return { source: 'placeholder', path: null };
}
async function loadThumbBuffer(thumbPath, { wechatFetch = undiciFetch } = {}) {
if (!thumbPath) {
return Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="42" text-anchor="middle" dominant-baseline="middle">TKMind</text></svg>',
);
return sharp(
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="42" text-anchor="middle" dominant-baseline="middle">TKMind</text></svg>',
),
)
.png()
.toBuffer();
}
const raw = fs.readFileSync(thumbPath);
if (/\.svg$/i.test(thumbPath)) {
return sharp(raw).png().toBuffer();
let raw;
if (/^https?:\/\//i.test(thumbPath)) {
const response = await wechatFetch(thumbPath);
if (!response.ok) {
throw new Error(`封面图片下载失败: ${thumbPath}`);
}
raw = Buffer.from(await response.arrayBuffer());
} else {
raw = fs.readFileSync(thumbPath);
}
return raw;
return sharp(raw).resize(900, 900, { fit: 'cover' }).png().toBuffer();
}
export function resolvePagePublicUrl(
page,
{
userId = '',
workspaceRelativePath = '',
publication = null,
publicBaseUrl = '',
} = {},
) {
const resolved = String(
publication?.publicUrl
?? publication?.public_url
?? page?.publication?.publicUrl
?? page?.publicationUrl
?? page?.workspacePublicUrl
?? '',
).trim();
if (resolved) return resolved;
const relativePath = String(
workspaceRelativePath
?? page?.workspaceRelativePath
?? resolvePageWorkspaceRelativePath(page)
?? '',
).trim();
if (relativePath && userId && publicBaseUrl) {
return buildPublicUrl(publicBaseUrl, userId, relativePath);
}
return '';
}
export function createMindSpaceWechatPageDraftService(
pool,
{
getMindSpacePages = () => null,
getMindSpacePublications = () => null,
getWechatMpConfig = () => null,
h5Root = process.cwd(),
memindLibRoot = h5Root,
@@ -237,12 +941,20 @@ export function createMindSpaceWechatPageDraftService(
}
const page = await pages.getPage(userId, pageId);
const { html } = await pages.renderPreview(userId, pageId);
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page)
?? page.workspaceRelativePath
?? '';
const publications = getMindSpacePublications?.();
const publication = publications?.getCurrent
? await publications.getCurrent(userId, pageId).catch(() => null)
: null;
const publicBaseUrl = resolvePublicBaseUrl(env);
const publicUrl = page.publication?.publicUrl
?? (workspaceRelativePath
? buildPublicUrl(publicBaseUrl, userId, workspaceRelativePath)
: '');
const publicUrl = resolvePagePublicUrl(page, {
userId,
workspaceRelativePath,
publication,
publicBaseUrl,
});
let sourceHtml = html;
if (workspaceRelativePath) {
const workspaceHtml = await readWorkspacePublishHtml(h5Root, userId, workspaceRelativePath);
@@ -264,25 +976,23 @@ export function createMindSpaceWechatPageDraftService(
try {
preview = await resolvePageHtml(userId, pageId);
const { accessToken, credentials } = await getAccessToken(userId);
const article = isDailyNewsFormat(preview.html)
? await buildDailyNewsWechatDraftArticleForPush({
html: preview.html,
publicUrl: preview.publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
})
: convertPageHtmlToWechatArticle(preview.html, {
publicUrl: preview.publicUrl,
pageTitle: preview.page.title,
pageSummary: preview.page.summary,
});
const thumbPath = resolveThumbPath(
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId));
const bundle = await buildWechatDraftPublicationBundleForPush({
html: preview.html,
publicUrl: preview.publicUrl,
pageTitle: preview.page.title,
pageSummary: preview.page.summary,
accessToken,
publishDir,
htmlRelativePath: preview.workspaceRelativePath ?? '',
h5Root,
userId,
preview.workspaceRelativePath,
);
const thumbBuffer = await loadThumbBuffer(thumbPath);
workspaceRelativePath: preview.workspaceRelativePath,
wechatFetch,
memindLibRoot,
});
const { article, thumbResolved } = bundle;
const thumbBuffer = await loadThumbBuffer(thumbResolved.path, { wechatFetch });
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
const draft = await addWechatDraftArticle(
accessToken,
@@ -312,6 +1022,8 @@ export function createMindSpaceWechatPageDraftService(
draftMediaId: draft.draftMediaId,
pageTitle: preview.page.title,
pageUrl: preview.publicUrl,
standardVersion: bundle.standardVersion,
coverSource: thumbResolved.source,
run,
};
} catch (error) {
@@ -360,4 +1072,9 @@ export function createMindSpaceWechatPageDraftService(
export const mindspaceWechatPageDraftInternals = {
convertGenericPageHtmlToWechatArticle,
convertPageHtmlToWechatArticle,
convertRichMindSpacePageHtmlToWechatArticle,
collectPageImageCandidates,
isNewsHotspotFormat,
resolveWechatDraftThumbPath,
resolveHeroImagePath,
};
+184 -7
View File
@@ -1,36 +1,187 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
convertGenericPageHtmlToWechatArticle,
convertPageHtmlToWechatArticle,
convertRichMindSpacePageHtmlToWechatArticle,
isNewsHotspotFormat,
mindspaceWechatPageDraftInternals,
resolvePagePublicUrl,
resolveWechatDraftProfile,
resolveWechatDraftThumbPath,
validatePageForWechatDraft,
} from './mindspace-wechat-page-draft.mjs';
import { buildDailyNewsWechatDraftArticle } from './wechat-news-morning-draft.mjs';
import { applyWechatDraftArticleLayout } from './wechat-draft-article-layout.mjs';
const SAMPLE_HTML = `<!DOCTYPE html>
<html><head><title>测试页面</title>
<meta name="description" content="页面摘要"></head>
<body><h1>标题</h1><p>正文内容</p></body></html>`;
const POEM_HTML = `<!DOCTYPE html><html><head><title>八月午后</title></head><body>
<header class="hero"><h1>八月午后</h1><p class="lead">甜是慢的</p></header>
<main><div class="stanza"><span class="num">壹</span><p>蝉声把正午拉得很长</p></div>
<div class="card"><span class="glyph">蝉</span><h3>声音的长度</h3><p>蝉鸣没有起伏</p></div>
<section class="note"><p>八月的午后很难写。</p><div class="quote">可正是这种什么都没有</div></section>
</body></html>`;
const PROSE_HTML = `<!DOCTYPE html><html><head><title>秋庭小憩 · 散文</title>
<meta name="description" content="午后庭院,桂花飘香">
<meta name="mindspace-cover" content='{"tag":"旅行","subtitle":"桂花"}'></head><body>
<div class="page"><div class="hero"></div><div class="content"><div class="title-block">
<h1 class="title">秋庭小憩</h1><p class="subtitle">一篇百字散文 · 秋日慢时光</p></div>
<div class="prose"><p>午后阳光斜洒庭院石阶,桂花香气随微风四处飘散。</p>
<p>墙外梧桐叶纷纷飘落,金黄铺满青石板路,沙沙作响。</p></div>
<div class="tags"><span class="tag">🍂 秋日</span><span class="tag">🌼 桂花</span></div>
</div></div><footer><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></footer></body></html>`;
const BLOG_HTML = `<!DOCTYPE html><html><head><title>博客文章</title>
<meta name="mindspace-cover" content='{"tag":"博客","subtitle":"产品观察"}'></head><body>
<article><p class="meta">2026-09-11 · John</p><h1>AI 与创作</h1>
<div class="content"><h2>背景</h2><p>工具正在改变内容生产。</p><p>创作者需要新的工作流。</p></div>
</article><footer><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></footer></body></html>`;
const STARTUP_HTML = `<!DOCTYPE html><html><head><title>创业路演</title>
<meta name="mindspace-cover" content='{"tag":"创业","subtitle":"AI 助手"}'></head><body>
<header class="hero"><h1>让 AI 更懂业务</h1><p class="pitch-sub">面向团队的知识助手</p></header>
<main><div class="section"><h2>愿景</h2><p>帮助每个团队拥有自己的 AI 大脑。</p></div></main>
<footer><p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p></footer></body></html>`;
test('convertGenericPageHtmlToWechatArticle extracts title and body', () => {
const article = convertGenericPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/u/demo/pages/test.html',
pageTitle: '测试页面',
pageSummary: '页面摘要',
});
assert.equal(article.title, '测试页面');
assert.equal(article.digest, '页面摘要');
assert.match(article.content, /正文内容/);
assert.match(article.content, /阅读原文/);
});
test('convertPageHtmlToWechatArticle falls back to generic converter', () => {
const article = convertPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://example.com/page.html',
test('resolvePagePublicUrl prefers publication slug url over empty workspace path', () => {
const url = resolvePagePublicUrl(
{ publicationUrl: null, workspaceRelativePath: null },
{
userId: 'user-1',
publication: { publicUrl: 'https://m.tkmind.cn/u/john/pages/tkmind-upgrade-sep2026' },
publicBaseUrl: 'https://m.tkmind.cn',
},
);
assert.equal(url, 'https://m.tkmind.cn/u/john/pages/tkmind-upgrade-sep2026');
});
test('daily-news draft includes read original when publicUrl is present', () => {
const html = fs.readFileSync('public/dev/tkmind-upgrade-sep2026.html', 'utf8');
const article = buildDailyNewsWechatDraftArticle({
html,
publicUrl: 'https://m.tkmind.cn/u/john/pages/tkmind-upgrade-sep2026',
qrcodeImageUrl: 'https://mmbiz.qpic.cn/qrcode.png',
});
const readIdx = article.content.indexOf('阅读原文');
const followIdx = article.content.indexOf('欢迎关注 TKMind 智趣');
assert.ok(readIdx >= 0, '缺少阅读原文');
assert.ok(readIdx < followIdx, '阅读原文应在关注区之前');
assert.match(article.content, /href="https:\/\/m\.tkmind\.cn\/u\/john\/pages\/tkmind-upgrade-sep2026"/);
assert.equal(article.contentSourceUrl, 'https://m.tkmind.cn/u/john/pages/tkmind-upgrade-sep2026');
});
test('applyWechatDraftArticleLayout adds read original and follow footer', () => {
const content = applyWechatDraftArticleLayout('<p>正文</p>', {
publicUrl: 'https://m.tkmind.cn/u/demo/pages/test.html',
qrcodeImageUrl: 'https://mmbiz.qpic.cn/qrcode.png',
});
assert.match(content, /阅读原文/);
assert.match(content, /欢迎关注 TKMind 智趣/);
assert.match(content, /qrcode\.png/);
assert.doesNotMatch(content, /点击阅读原文/);
assert.ok(content.indexOf('正文') < content.indexOf('阅读原文'));
assert.ok(content.indexOf('阅读原文') < content.indexOf('欢迎关注'));
});
test('convertPageHtmlToWechatArticle uses rich mindspace converter', () => {
const article = convertPageHtmlToWechatArticle(POEM_HTML, {
pageTitle: '测试页面',
pageSummary: '页面摘要',
});
assert.equal(article.title, '测试页面');
assert.match(article.content, /正文内容/);
assert.equal(article.title, '八月午后');
assert.match(article.content, /蝉声把正午拉得很长/);
assert.match(article.content, /创作手记/);
assert.match(article.content, /意象/);
});
test('isNewsHotspotFormat ignores mindspace imagery cards', () => {
assert.equal(isNewsHotspotFormat(POEM_HTML), false);
assert.equal(resolveWechatDraftProfile(POEM_HTML), 'mindspace-page');
});
test('validatePageForWechatDraft warns for mindspace pages', () => {
const result = validatePageForWechatDraft(SAMPLE_HTML, {
pageTitle: '测试页面',
pageSummary: '页面摘要',
publicUrl: 'https://example.com/page.html',
});
assert.equal(result.ok, true);
assert.equal(result.profile, 'generic');
});
test('convertRichMindSpacePageHtmlToWechatArticle preserves poem sections', () => {
const article = convertRichMindSpacePageHtmlToWechatArticle(POEM_HTML, {
pageTitle: '八月午后',
imageUrlMap: new Map([['hero.webp', 'https://mmbiz.qpic.cn/hero.png']]),
});
assert.match(article.content, /蝉声把正午拉得很长/);
assert.match(article.content, /声音的长度/);
assert.match(article.content, /https:\/\/mmbiz\.qpic\.cn\/hero\.png/);
});
test('convertRichMindSpacePageHtmlToWechatArticle preserves prose essay sections', () => {
const article = convertRichMindSpacePageHtmlToWechatArticle(PROSE_HTML, {
pageTitle: '秋庭小憩 · 散文',
pageSummary: '午后庭院,桂花飘香',
imageUrlMap: new Map([['qiuting-xiaoxi.thumbnail.svg', 'https://mmbiz.qpic.cn/thumb.png']]),
});
assert.equal(article.title, '秋庭小憩');
assert.match(article.content, /一篇百字散文 · 秋日慢时光/);
assert.match(article.content, /午后阳光斜洒庭院石阶/);
assert.match(article.content, /墙外梧桐叶纷纷飘落/);
assert.match(article.content, /🍂 秋日/);
assert.match(article.content, /thumb\.png/);
assert.doesNotMatch(article.content, /data-mindspace-public-share/);
});
test('resolveWechatDraftProfile treats mindspace-cover pages as mindspace-page', () => {
assert.equal(resolveWechatDraftProfile(BLOG_HTML), 'mindspace-page');
assert.equal(resolveWechatDraftProfile(STARTUP_HTML), 'mindspace-page');
});
test('convertRichMindSpacePageHtmlToWechatArticle extracts blog and startup templates via fallback', () => {
const blog = convertRichMindSpacePageHtmlToWechatArticle(BLOG_HTML, {
pageTitle: 'AI 与创作',
pageSummary: '产品观察',
});
assert.equal(blog.title, 'AI 与创作');
assert.match(blog.content, /工具正在改变内容生产/);
assert.match(blog.content, /创作者需要新的工作流/);
assert.doesNotMatch(blog.content, /data-mindspace-public-share/);
const startup = convertRichMindSpacePageHtmlToWechatArticle(STARTUP_HTML, {
pageTitle: '创业路演',
});
assert.equal(startup.title, '让 AI 更懂业务');
assert.match(startup.content, /面向团队的知识助手/);
assert.match(startup.content, /帮助每个团队拥有自己的 AI 大脑/);
});
test('collectPageImageCandidates adds sidecar thumbnail when hero image missing', () => {
const publishDir = path.join(process.cwd(), 'MindSpace', '1c99b83b-0454-474f-a5d2-129d34506a32');
const html = fs.readFileSync(path.join(publishDir, 'public/qiuting-xiaoxi.html'), 'utf8');
const candidates = mindspaceWechatPageDraftInternals.collectPageImageCandidates(html, {
publishDir,
htmlRelativePath: 'public/qiuting-xiaoxi.html',
});
assert.ok(candidates.some((item) => /\.thumbnail\.(png|svg)$/i.test(item.ref ?? '')));
});
test('convertGenericPageHtmlToWechatArticle truncates long content', () => {
@@ -41,3 +192,29 @@ test('convertGenericPageHtmlToWechatArticle truncates long content', () => {
);
assert.ok(article.content.length <= 20000);
});
test('resolveWechatDraftThumbPath prefers hero image over thumbnail sidecar', () => {
const userId = '1c99b83b-0454-474f-a5d2-129d34506a32';
const html = fs.readFileSync(`MindSpace/${userId}/public/august-afternoon.html`, 'utf8');
const resolved = resolveWechatDraftThumbPath(html, {
h5Root: process.cwd(),
userId,
workspaceRelativePath: 'public/august-afternoon.html',
publishDir: path.join(process.cwd(), 'MindSpace', userId),
});
assert.equal(resolved.source, 'hero');
assert.match(resolved.path ?? '', /hero-ca5a45b835d8\.webp$/);
});
test('resolveWechatDraftThumbPath falls back to raster sidecar when hero missing', () => {
const userId = '1c99b83b-0454-474f-a5d2-129d34506a32';
const html = `<!DOCTYPE html><html><head><title>无 hero</title></head><body><h1>标题</h1></body></html>`;
const resolved = resolveWechatDraftThumbPath(html, {
h5Root: process.cwd(),
userId,
workspaceRelativePath: 'public/august-afternoon.html',
publishDir: path.join(process.cwd(), 'MindSpace', userId),
});
assert.equal(resolved.source, 'thumbnail-sidecar');
assert.match(resolved.path ?? '', /august-afternoon\.thumbnail\.png$/);
});
+1 -1
View File
@@ -114,7 +114,7 @@
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
"verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime",
"verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs",
"verify:mindspace-wechat-mp": "node --test mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs server/portal-mindspace-wechat-routes.test.mjs",
"verify:mindspace-wechat-mp": "node --test mindspace-wechat-mp-config.test.mjs mindspace-wechat-page-draft.test.mjs wechat-draft-publication-standard.test.mjs mindspace-chat-wechat-draft.test.mjs mindspace-public-share-widget.test.mjs server/portal-mindspace-wechat-routes.test.mjs server/portal-mindspace-chat-share-routes.test.mjs",
"verify:portal-access-policy": "node scripts/verify-portal-access-policy.mjs",
"verify:seo-discovery": "node scripts/verify-seo-discovery.mjs",
"verify:seo-geo": "node --test mindspace-index-policy.test.mjs mindspace-seo-tags.test.mjs mindspace-geo-tags.test.mjs mindspace-seo-geo-delivery.test.mjs mindspace-seo-discovery-service.test.mjs mindspace-seo-notify.test.mjs mindspace-config.test.mjs server/portal-seo-discovery-routes.test.mjs",
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
RUNTIME_BASE="${DEEP_SEARCH_RUNTIME_BASE:-${REMOTE_ROOT}/deep-search-runtime}"
INCOMING="${REMOTE_ROOT}/incoming/deep-search-runtime"
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
REMOTE_USER_ROOT="${STUDIO_USER_ROOT:-/Users/john}"
SHARED_MEMIND_ROOT="${STUDIO_SHARED_MEMIND_ROOT:-/Users/john/Project/Memind}"
APP_DIR="${REMOTE_USER_ROOT}/MindSpace"
+9 -9
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
STABLE_DIR="${REMOTE_ROOT}/Memind"
CANDIDATE_BASE="${REMOTE_ROOT}/Memind-candidates"
@@ -216,8 +216,8 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
&& ssh -o BatchMode=yes -o ConnectTimeout=10 '${EDGE_HOST}' \
\"test -f '${EDGE_MOBILE_CONFIG}' \
&& test -f '${EDGE_WECHAT_CONFIG}' \
&& grep -q 'proxy_pass http://180.159.29.143:8081;' '${EDGE_MOBILE_CONFIG}' \
&& grep -q 'proxy_pass http://180.159.29.143:8081;' '${EDGE_WECHAT_CONFIG}' \
&& grep -q 'proxy_pass http://10.10.0.2:8081;' '${EDGE_MOBILE_CONFIG}' \
&& grep -q 'proxy_pass http://10.10.0.2:8081;' '${EDGE_WECHAT_CONFIG}' \
&& ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_MOBILE_CONFIG}' \
&& ! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_WECHAT_CONFIG}' \
&& nginx -t >/dev/null 2>&1 \
@@ -426,8 +426,8 @@ restore_edge_to_stable() {
cp '${EDGE_MOBILE_BACKUP}' '${EDGE_MOBILE_CONFIG}'
cp '${EDGE_WECHAT_BACKUP}' '${EDGE_WECHAT_CONFIG}'
fi
grep -q 'proxy_pass http://180.159.29.143:8081;' '${EDGE_MOBILE_CONFIG}'
grep -q 'proxy_pass http://180.159.29.143:8081;' '${EDGE_WECHAT_CONFIG}'
grep -q 'proxy_pass http://10.10.0.2:8081;' '${EDGE_MOBILE_CONFIG}'
grep -q 'proxy_pass http://10.10.0.2:8081;' '${EDGE_WECHAT_CONFIG}'
! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_MOBILE_CONFIG}'
! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_WECHAT_CONFIG}'
nginx -t >/dev/null
@@ -486,8 +486,8 @@ edge_ssh \
"set -euo pipefail
test -f '${EDGE_MOBILE_CONFIG}'
test -f '${EDGE_WECHAT_CONFIG}'
grep -q 'proxy_pass http://180.159.29.143:8081;' '${EDGE_MOBILE_CONFIG}'
grep -q 'proxy_pass http://180.159.29.143:8081;' '${EDGE_WECHAT_CONFIG}'
grep -q 'proxy_pass http://10.10.0.2:8081;' '${EDGE_MOBILE_CONFIG}'
grep -q 'proxy_pass http://10.10.0.2:8081;' '${EDGE_WECHAT_CONFIG}'
! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_MOBILE_CONFIG}'
! grep -q 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' '${EDGE_WECHAT_CONFIG}'
cp '${EDGE_MOBILE_CONFIG}' '${EDGE_MOBILE_BACKUP}'
@@ -713,10 +713,10 @@ say "Switch the committed 105 nginx upstreams to the isolated canary tunnel"
ssh -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" /bin/bash <<EDGE_SWITCH
set -euo pipefail
for config in '${EDGE_MOBILE_CONFIG}' '${EDGE_WECHAT_CONFIG}'; do
before_count="\$(grep -cF 'proxy_pass http://180.159.29.143:8081;' "\${config}")"
before_count="\$(grep -cF 'proxy_pass http://10.10.0.2:8081;' "\${config}")"
[[ "\${before_count}" -gt 0 ]]
temp_config="\$(mktemp "\${config}.canary.XXXXXX")"
sed 's#proxy_pass http://58\.38\.22\.103:8081;#proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};#g' \
sed 's#proxy_pass http://10\.10\.0\.2:8081;#proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};#g' \
"\${config}" > "\${temp_config}"
after_count="\$(grep -cF 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' "\${temp_config}")"
[[ "\${after_count}" == "\${before_count}" ]]
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
APP_DIR="${REMOTE_ROOT}/Memind"
INCOMING_DIR="${REMOTE_ROOT}/incoming/memind-portal-runtime"
+1 -1
View File
@@ -18,7 +18,7 @@ EOF
exit 1
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
APP_NAME="${STUDIO_APP_NAME:-Memind}"
APP_DIR="${REMOTE_ROOT}/${APP_NAME}"
+5 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
STABLE_DIR="${STUDIO_REMOTE_ROOT:-/Users/john/Project}/Memind"
PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate"
CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy"
@@ -44,15 +44,15 @@ ssh -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" /bin/bash <<EDGE_ROLLBA
set -euo pipefail
for config in '${EDGE_MOBILE_CONFIG}' '${EDGE_WECHAT_CONFIG}'; do
canary_count="\$(grep -cF 'proxy_pass http://127.0.0.1:${CANARY_TUNNEL_REMOTE_PORT};' "\${config}" || true)"
stable_count_before="\$(grep -cF 'proxy_pass http://180.159.29.143:8081;' "\${config}" || true)"
stable_count_before="\$(grep -cF 'proxy_pass http://10.10.0.2:8081;' "\${config}" || true)"
if [[ "\${canary_count}" -eq 0 ]]; then
[[ "\${stable_count_before}" -gt 0 ]]
continue
fi
temp_config="\$(mktemp "\${config}.stable.XXXXXX")"
sed 's#proxy_pass http://127\.0\.0\.1:${CANARY_TUNNEL_REMOTE_PORT};#proxy_pass http://180.159.29.143:8081;#g' \
sed 's#proxy_pass http://127\.0\.0\.1:${CANARY_TUNNEL_REMOTE_PORT};#proxy_pass http://10.10.0.2:8081;#g' \
"\${config}" > "\${temp_config}"
stable_count="\$(grep -cF 'proxy_pass http://180.159.29.143:8081;' "\${temp_config}")"
stable_count="\$(grep -cF 'proxy_pass http://10.10.0.2:8081;' "\${temp_config}")"
[[ "\${stable_count}" -eq \$(( stable_count_before + canary_count )) ]]
chown root:root "\${temp_config}"
chmod 0644 "\${temp_config}"
@@ -76,7 +76,7 @@ rm -f "${STABLE_DIR}/.release-drain"
curl -fsS http://127.0.0.1:8081/api/status >/dev/null
printf 'stable_port=8081\n'
printf 'edge_upstream=180.159.29.143:8081\n'
printf 'edge_upstream=10.10.0.2:8081\n'
REMOTE
echo "103 canary rollback completed; stable Portal remains active on 8081."
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-180.159.29.143}"
HOST="${STUDIO_HOST:-john@180.159.29.143}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
APP_DIR="${REMOTE_ROOT}/Memind"
BACKUP_DIR="${REMOTE_ROOT}/backups/memind"
+1 -1
View File
@@ -46,7 +46,7 @@ const REMOTE_SCRIPT = [
function parseArgs(argv) {
const options = {
host: process.env.STUDIO_HOST ?? '180.159.29.143',
host: process.env.STUDIO_HOST ?? 'john@180.159.29.143',
commit: null,
artifact: path.join(ROOT, '.runtime', 'portal'),
};
+3
View File
@@ -465,6 +465,7 @@ async function bootstrapUserAuth() {
});
mindSpaceWechatPageDraft = createMindSpaceWechatPageDraftService(pool, {
getMindSpacePages: () => mindSpacePages,
getMindSpacePublications: () => mindSpacePublications,
getWechatMpConfig: () => mindSpaceWechatMpConfig,
h5Root: H5_ROOT,
memindLibRoot: __dirname,
@@ -1449,6 +1450,8 @@ attachPortalMindSpaceChatShareRoutes(api, {
getMindSpaceChatSave: () => mindSpaceChatSave,
getMindSpacePublications: () => mindSpacePublications,
getPlazaPosts: () => plazaPosts,
getWechatMpConfig: () => mindSpaceWechatMpConfig,
getWechatPageDraft: () => mindSpaceWechatPageDraft,
resolveChatSaveBundle,
resolvePlazaPostUrl: resolvePlazaPostUrlForRequest,
mapPlazaError,
@@ -2,6 +2,8 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { createAgentRunGateway } from '../agent-run-gateway.mjs';
import { releaseMaterializedPageDeliveryContracts } from '../mindspace-delivery-contract.mjs';
import { resolvePublishDir } from '../user-publish.mjs';
import { createGoalRunService } from '../goal-run-service.mjs';
import { isDirectChatSessionId } from '../direct-chat-service.mjs';
import {
@@ -288,6 +290,16 @@ export function bootstrapPortalGatewayServices({
sessionId,
sinceMs: runStartedAtMs,
}),
releaseUserPageDeliveryContractsOnSuccess: async ({
userId,
relativePaths,
}) =>
releaseMaterializedPageDeliveryContracts({
pool,
userId,
relativePaths,
publishDir: resolvePublishDir(h5Root, { id: userId }),
}),
isSessionExternallyBusy: ({ sessionId }) =>
isSessionPageDeliveryActive(sessionId),
validateRunDeliverables,
@@ -3,6 +3,10 @@ import {
quickPlazaFromChat,
quickPlazaFromPublicHtml,
} from '../mindspace-chat-plaza.mjs';
import {
getQuickWechatDraftFromPublicHtmlStatus,
quickWechatDraftFromPublicHtml,
} from '../mindspace-chat-wechat-draft.mjs';
function assertRouter(api) {
if (
@@ -41,6 +45,11 @@ export function attachPortalMindSpaceChatShareRoutes(
getQuickPlazaFromPublicHtmlStatusFn =
getQuickPlazaFromPublicHtmlStatus,
quickPlazaFromPublicHtmlFn = quickPlazaFromPublicHtml,
getWechatMpConfig = () => null,
getWechatPageDraft = () => null,
getQuickWechatDraftFromPublicHtmlStatusFn =
getQuickWechatDraftFromPublicHtmlStatus,
quickWechatDraftFromPublicHtmlFn = quickWechatDraftFromPublicHtml,
now = Date.now,
logger = console,
} = {},
@@ -268,4 +277,90 @@ export function attachPortalMindSpaceChatShareRoutes(
}
},
);
api.get(
'/mindspace/v1/pages/quick-wechat-draft-from-public-html/status',
async (req, res) => {
const pages = getMindSpacePages();
const publications = getMindSpacePublications();
const chatSave = getMindSpaceChatSave();
const draftService = getWechatPageDraft?.();
if (!pages || !chatSave || !draftService) {
return sendError(
res,
req,
503,
'wechat_draft_unavailable',
'公众号草稿推送未启用',
);
}
try {
const relativePath = String(
req.query?.relative_path ??
req.query?.relativePath ??
'',
).trim();
const result = await getQuickWechatDraftFromPublicHtmlStatusFn({
user: req.currentUser,
relativePath,
mindSpacePages: pages,
mindSpacePublications: publications,
readWorkspaceHtml: (input) => chatSave.readWorkspaceHtml(input),
getWechatMpConfig,
getWechatPageDraft: () => draftService,
});
return sendData(res, req, result);
} catch (error) {
return handleMindSpaceError(res, req, error);
}
},
);
api.post(
'/mindspace/v1/pages/quick-wechat-draft-from-public-html',
async (req, res) => {
const pages = getMindSpacePages();
const publications = getMindSpacePublications();
const chatSave = getMindSpaceChatSave();
const draftService = getWechatPageDraft?.();
if (!pages || !chatSave || !draftService) {
return sendError(
res,
req,
503,
'wechat_draft_unavailable',
'公众号草稿推送未启用',
);
}
const startedAt = now();
try {
const relativePath = String(
req.body?.relative_path ??
req.body?.relativePath ??
'',
).trim();
const result = await quickWechatDraftFromPublicHtmlFn({
user: req.currentUser,
relativePath,
mindSpacePages: pages,
mindSpacePublications: publications,
readWorkspaceHtml: (input) => chatSave.readWorkspaceHtml(input),
getWechatPageDraft: () => draftService,
});
logger.info('[quick-wechat-draft-public-html] ok', {
ms: now() - startedAt,
pageId: result.pageId ?? result.run?.pageId,
draftMediaId: result.draftMediaId,
relativePath,
});
return sendData(res, req, result, 201);
} catch (error) {
logger.error('[quick-wechat-draft-public-html] error:', {
ms: now() - startedAt,
error,
});
return handleMindSpaceError(res, req, error);
}
},
);
}
@@ -121,6 +121,8 @@ test('chat share module preserves route inventory and order', () => {
'POST /mindspace/v1/pages/quick-plaza-from-chat',
'GET /mindspace/v1/pages/quick-plaza-from-public-html/status',
'POST /mindspace/v1/pages/quick-plaza-from-public-html',
'GET /mindspace/v1/pages/quick-wechat-draft-from-public-html/status',
'POST /mindspace/v1/pages/quick-wechat-draft-from-public-html',
]);
});
@@ -350,6 +352,16 @@ test('chat share routes preserve availability gates and MindSpace fallback error
assert.equal(res.body.error.code, 'plaza_unavailable');
}
for (const route of [
'GET /mindspace/v1/pages/quick-wechat-draft-from-public-html/status',
'POST /mindspace/v1/pages/quick-wechat-draft-from-public-html',
]) {
const res = createResponseRecorder();
await unavailableApi.routes.get(route)(createRequest(), res);
assert.equal(res.statusCode, 503, route);
assert.equal(res.body.error.code, 'wechat_draft_unavailable');
}
const failureApi = createRouterRecorder();
const failure = new Error('share failed');
const dependencies = createDependencies({
+92 -26
View File
@@ -1,3 +1,4 @@
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
const MAX_WECHAT_CONTENT_CHARS = 20000;
@@ -124,26 +125,36 @@ function renderSectionTitle(titleHtml) {
return `<p style="margin:0 0 12px;padding:12px 0 6px;border-bottom:3px solid #0d47a1;font-size:18px;font-weight:700;color:#b71c1c;line-height:1.4;">${text}</p>`;
}
function renderHero(html) {
function renderHero(html, imageUrlMap = new Map()) {
const title = sanitizeInlineHtml(String(html).match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '') || '📰 每日新闻早报';
const dateBadge = sanitizeInlineHtml(String(html).match(/<div class="date-badge"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
const subtitle = sanitizeInlineHtml(String(html).match(/<div class="subtitle"[^>]*>([\s\S]*?)<\/div>/i)?.[1] ?? '');
return [
const coverRef = extractMindspaceCoverPath(html);
const heroImageUrl = resolveMappedImageUrl(coverRef, imageUrlMap);
const parts = [];
if (heroImageUrl) {
parts.push([
'<section style="margin:0 0 12px;text-align:center;">',
`<img src="${heroImageUrl}" alt="${title}" style="width:100%;max-width:100%;border-radius:12px;display:block;margin:0 auto;" />`,
'</section>',
].join(''));
}
parts.push([
'<section style="text-align:center;padding:24px 14px;background:linear-gradient(135deg,#b71c1c,#d32f2f 50%,#0d47a1);color:#fff;margin:0 0 12px;border-radius:12px;">',
`<p style="margin:0 0 10px;font-size:24px;font-weight:700;line-height:1.35;">${title}</p>`,
dateBadge ? `<p style="margin:0 0 8px;font-size:14px;opacity:.92;">${dateBadge}</p>` : '',
subtitle ? `<p style="margin:0;font-size:14px;opacity:.88;line-height:1.7;">${subtitle}</p>` : '',
'</section>',
].filter(Boolean).join('');
].filter(Boolean).join(''));
return parts.join('');
}
function renderReadOriginalTop(publicUrl) {
function renderReadOriginalLink(publicUrl) {
if (!publicUrl) return '';
return [
'<section style="margin:0 0 16px;text-align:center;">',
`<a href="${publicUrl}" style="display:inline-block;padding:11px 22px;background:linear-gradient(135deg,#b71c1c,#d32f2f);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:999px;letter-spacing:.02em;box-shadow:0 3px 10px rgba(183,28,28,.22);">📖 点击阅读原文</a>`,
'<p style="margin:8px 0 0;font-size:12px;color:#a0aec0;line-height:1.6;">查看完整排版、图片与更多栏目</p>',
'</section>',
'<p style="margin:18px 0 10px;text-align:center;line-height:1.6;">',
`<a href="${publicUrl}" style="display:inline-block;padding:5px 14px;border:1px solid #e2e8f0;border-radius:999px;color:#576b95;font-size:12px;text-decoration:none;background:#fafafa;">📖 阅读原文</a>`,
'</p>',
].join('');
}
@@ -155,16 +166,15 @@ function renderFollowMpSection(qrcodeImageUrl = '') {
if (qrcodeImageUrl) {
return [
'<section style="text-align:center;padding:22px 16px 18px;margin:18px 0 8px;background:linear-gradient(180deg,#fff8f8,#fff);border:1px solid #ffcdd2;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">📣 关注 TKMind 服务号</p>',
'<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.7;">长按扫描关注,每日早报不错过</p>',
`<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:168px;height:168px;display:block;margin:0 auto;border-radius:10px;border:1px solid #eee;" />`,
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">欢迎关注 TKMind 智趣</p>',
`<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:168px;height:168px;display:block;margin:14px auto 0;border-radius:10px;border:1px solid #eee;" />`,
'<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>',
'</section>',
].join('');
}
return [
'<section style="text-align:center;padding:20px 16px;margin:18px 0 8px;background:#fafafa;border:1px dashed #e0e0e0;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:15px;font-weight:600;color:#333;">📣 关注 TKMind 服务号</p>',
'<p style="margin:0 0 6px;font-size:15px;font-weight:600;color:#333;">欢迎关注 TKMind 智趣</p>',
'<p style="margin:0;font-size:13px;color:#888;line-height:1.7;">推送草稿时将嵌入公众号二维码</p>',
'</section>',
].join('');
@@ -294,10 +304,14 @@ function estimateQrcodeUrlForLayout(qrcodeImageUrl = '') {
return `${url}${'x'.repeat(180 - url.length)}`;
}
function estimateFixedLayoutLength({ publicUrl = '', qrcodeImageUrl = '', portalUrl = DEFAULT_PORTAL_URL } = {}) {
function estimateFixedLayoutLength({
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
} = {}) {
const qrcodeForEstimate = estimateQrcodeUrlForLayout(qrcodeImageUrl);
return (
(publicUrl ? renderReadOriginalTop(publicUrl).length : 0)
(publicUrl ? renderReadOriginalLink(publicUrl).length : 0)
+ renderFollowMpSection(qrcodeForEstimate).length
+ renderCreatePortalCTA(portalUrl).length
);
@@ -309,9 +323,10 @@ function buildWechatInlineContent(html, {
portalUrl = DEFAULT_PORTAL_URL,
trimProfile = TRIM_PROFILES[0],
includeFixedLayout = true,
imageUrlMap = new Map(),
} = {}) {
const sections = splitSections(html);
const bodyParts = [renderHero(html)];
const bodyParts = [renderHero(html, imageUrlMap)];
for (const section of sections) {
if (trimProfile.skipHistory && shouldSkipSection(section, trimProfile)) {
@@ -329,6 +344,7 @@ function buildWechatInlineContent(html, {
bodyParts.push(renderGenericSection(section.html, {
maxCards: resolveSectionCardLimit(section, trimProfile.sectionCardLimits),
maxBodyChars: trimProfile.maxBodyChars,
imageUrlMap,
}));
}
@@ -336,7 +352,7 @@ function buildWechatInlineContent(html, {
const ordered = [...bodyParts];
if (includeFixedLayout) {
if (publicUrl) ordered.splice(1, 0, renderReadOriginalTop(publicUrl));
if (publicUrl) ordered.push(renderReadOriginalLink(publicUrl));
ordered.push(renderFollowMpSection(qrcodeImageUrl));
ordered.push(renderCreatePortalCTA(portalUrl));
}
@@ -365,22 +381,63 @@ function shouldSkipSection(section, trimProfile) {
return /历史上的今天|近7天新闻早报/u.test(section.title);
}
function renderGenericSection(sectionHtml, { maxCards = null, maxBodyChars = null } = {}) {
function resolveMappedImageUrl(src, imageUrlMap = new Map()) {
const ref = String(src ?? '').trim();
if (!ref) return '';
return imageUrlMap.get(ref) ?? imageUrlMap.get(ref.replace(/^\.\//, '')) ?? '';
}
function renderSectionFigure(figureHtml, imageUrlMap = new Map()) {
const src = String(figureHtml).match(/<img[^>]+src=["']([^"']+)["']/i)?.[1] ?? '';
const mapped = resolveMappedImageUrl(src, imageUrlMap);
if (!mapped) return '';
const alt = stripInlineText(String(figureHtml).match(/\balt=["']([^"']*)["']/i)?.[1] ?? '');
const caption = stripInlineText(
extractInnerHtml(figureHtml, /<figcaption[^>]*>([\s\S]*?)<\/figcaption>/i),
);
return [
'<section style="margin:0 0 14px;text-align:center;">',
`<img src="${mapped}" alt="${alt}" style="width:100%;max-width:100%;border-radius:10px;display:block;margin:0 auto;" />`,
caption
? `<p style="margin:8px 0 0;font-size:12px;color:#a0aec0;line-height:1.6;">${caption}</p>`
: '',
'</section>',
].filter(Boolean).join('');
}
function renderGenericSection(
sectionHtml,
{ maxCards = null, maxBodyChars = null, imageUrlMap = new Map() } = {},
) {
const title = extractInnerHtml(sectionHtml, /<div class="section-title"[^>]*>([\s\S]*?)<\/div>/i);
if (/近7天新闻早报/u.test(stripInlineText(title))) return '';
const cards = sectionHtml.split(/<div class="card(?:\s|")/i).slice(1);
const body = String(sectionHtml).replace(
/^[\s\S]*?<div class="section-title"[^>]*>[\s\S]*?<\/div>/i,
'',
);
const tokens = body.split(/(?=<figure class="section-figure"|<div class="card)/i).filter(Boolean);
const parts = [renderSectionTitle(title)];
const limit = maxCards == null ? cards.length : Math.min(maxCards, cards.length);
for (let index = 0; index < limit; index += 1) {
let cardChunk = cards[index];
let cardCount = 0;
for (const token of tokens) {
if (/^<figure class="section-figure"/i.test(token)) {
const rendered = renderSectionFigure(token, imageUrlMap);
if (rendered) parts.push(rendered);
continue;
}
if (!/^<div class="card/i.test(token)) continue;
if (maxCards != null && cardCount >= maxCards) continue;
let cardChunk = token.replace(/^<div class="card(?:\s|")/i, '');
if (maxBodyChars) {
cardChunk = cardChunk.replace(/<p[^>]*>([\s\S]*?)<\/p>/i, (match, body) => {
const plain = body.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
return match.replace(body, truncatePlainText(plain, maxBodyChars));
cardChunk = cardChunk.replace(/<p[^>]*>([\s\S]*?)<\/p>/i, (match, bodyText) => {
const plain = bodyText.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
return match.replace(bodyText, truncatePlainText(plain, maxBodyChars));
});
}
const rendered = renderCard(`<div class="card ${cardChunk}`);
if (rendered) parts.push(rendered);
if (rendered) {
parts.push(rendered);
cardCount += 1;
}
}
return parts.join('');
}
@@ -404,6 +461,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl = '',
qrcodeImageUrl = '',
portalUrl = DEFAULT_PORTAL_URL,
imageUrlMap = new Map(),
} = {}) {
const title = (extractPageTitle(html) || '每日新闻早报').slice(0, 32);
const digest = (extractMetaDescription(html) || title).slice(0, 120);
@@ -412,7 +470,11 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
let usedFallback = false;
const allProfiles = [...TRIM_PROFILES, ...buildFallbackTrimProfiles()];
const fixedLayoutLength = estimateFixedLayoutLength({ publicUrl, qrcodeImageUrl, portalUrl });
const fixedLayoutLength = estimateFixedLayoutLength({
publicUrl,
qrcodeImageUrl,
portalUrl,
});
const trimTargetChars = Math.max(12000, MAX_WECHAT_CONTENT_CHARS - fixedLayoutLength - 800);
for (const [index, trimProfile] of allProfiles.entries()) {
@@ -422,6 +484,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
portalUrl,
trimProfile,
includeFixedLayout: false,
imageUrlMap,
});
trimLevel = index;
usedFallback = index >= TRIM_PROFILES.length;
@@ -438,6 +501,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
portalUrl,
trimProfile: selectedProfile,
includeFixedLayout: true,
imageUrlMap,
});
let tightenGuard = 0;
while (built.content.length > MAX_WECHAT_CONTENT_CHARS && tightenGuard < 24) {
@@ -476,6 +540,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
portalUrl,
trimProfile: selectedProfile,
includeFixedLayout: true,
imageUrlMap,
});
usedFallback = true;
}
@@ -505,6 +570,7 @@ export function convertDailyNewsHtmlToWechatInlineArticle(html, {
export const wechatDailyNewsInlineInternals = {
renderCard,
renderHero,
renderSectionFigure,
buildWechatInlineContent,
TRIM_PROFILES,
MAX_WECHAT_CONTENT_CHARS,
+105
View File
@@ -0,0 +1,105 @@
/**
* 公众号草稿统一 layout:阅读原文 + 底部关注区。
* 标准定义见 wechat-draft-publication-standard.mjs 与 docs/wechat-draft-publication-standard.md
*/
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import { PLATFORM_BRAND_ICON_PATH } from './mindspace-og-tags.mjs';
import {
resolveMpFollowQrcodePath,
uploadWechatArticleContentImage,
} from './wechat-news-morning-draft.mjs';
export function renderReadOriginalLink(publicUrl) {
if (!publicUrl) return '';
return [
'<p style="margin:18px 0 10px;text-align:center;line-height:1.6;">',
`<a href="${publicUrl}" style="display:inline-block;padding:5px 14px;border:1px solid #e2e8f0;border-radius:999px;color:#576b95;font-size:12px;text-decoration:none;background:#fafafa;">📖 阅读原文</a>`,
'</p>',
].join('');
}
/** @deprecated 保留别名,避免外部引用断裂 */
export const renderReadOriginalTop = renderReadOriginalLink;
export function renderFollowTkMindSection({
qrcodeImageUrl = '',
} = {}) {
const qrcodeBlock = qrcodeImageUrl
? `<img src="${qrcodeImageUrl}" alt="TKMind 服务号二维码" style="width:168px;height:168px;display:block;margin:14px auto 0;border-radius:10px;border:1px solid #eee;" />`
: '';
return [
'<section style="text-align:center;padding:22px 16px 18px;margin:18px 0 8px;background:linear-gradient(180deg,#fff8f8,#fff);border:1px solid #ffcdd2;border-radius:12px;">',
'<p style="margin:0 0 6px;font-size:16px;font-weight:700;color:#b71c1c;line-height:1.5;">欢迎关注 TKMind 智趣</p>',
qrcodeBlock,
qrcodeImageUrl
? '<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">长按扫描关注</p>'
: '<p style="margin:12px 0 0;font-size:12px;color:#a0aec0;">关注 TKMind 服务号</p>',
'</section>',
].filter(Boolean).join('');
}
export function applyWechatDraftArticleLayout(
content,
{ publicUrl = '', qrcodeImageUrl = '' } = {},
) {
const parts = [String(content ?? '').trim()];
if (publicUrl) {
parts.push(renderReadOriginalLink(publicUrl));
}
parts.push(renderFollowTkMindSection({ qrcodeImageUrl }));
return parts.filter(Boolean).join('\n').slice(0, 20000);
}
export function resolveTkMindBrandIconPath(memindLibRoot = process.cwd()) {
const candidates = [
path.join(memindLibRoot, 'public', PLATFORM_BRAND_ICON_PATH.replace(/^\//, '')),
path.join(memindLibRoot, 'dist', PLATFORM_BRAND_ICON_PATH.replace(/^\//, '')),
path.join(process.cwd(), 'public', PLATFORM_BRAND_ICON_PATH.replace(/^\//, '')),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
export async function uploadWechatDraftBrandAssets({
accessToken,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
let qrcodeImageUrl = '';
if (!accessToken) {
return { qrcodeImageUrl };
}
const qrcodePath = resolveMpFollowQrcodePath(memindLibRoot);
if (qrcodePath) {
qrcodeImageUrl = await uploadWechatArticleContentImage(
accessToken,
fs.readFileSync(qrcodePath),
{ wechatFetch, filename: 'mp-follow-qrcode.png' },
);
}
return { qrcodeImageUrl };
}
export async function finalizeWechatDraftArticleForPush({
content,
publicUrl = '',
accessToken = null,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {}) {
const brandAssets = await uploadWechatDraftBrandAssets({
accessToken,
wechatFetch,
memindLibRoot,
});
return {
content: applyWechatDraftArticleLayout(content, {
publicUrl,
...brandAssets,
}),
...brandAssets,
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* MindSpace / 公众号草稿推送格式标准(2026-09-11)。
* 所有页面推送到微信草稿箱时必须经过同一套 layout + 封面解析。
* 实现入口:mindspace-wechat-page-draft.mjs → buildWechatDraftPublicationBundleForPush
* 布局模块:wechat-draft-article-layout.mjs
*/
export const WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION = '2026-09-11';
export const WECHAT_DRAFT_PUBLICATION_STANDARD = Object.freeze({
version: WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION,
/** 正文区块顺序(不含 daily-news 专有栏目,其仍复用底部 layout) */
bodyLayout: Object.freeze([
'hero-image-if-present',
'page-content',
'read-original-link',
'follow-footer',
]),
readOriginal: Object.freeze({
position: 'after-content-before-footer',
label: '阅读原文',
style: 'compact-link-12px',
forbidLegacyLargeButton: true,
}),
followFooter: Object.freeze({
title: '欢迎关注 TKMind 智趣',
includeLogo: false,
includeSubtitle: false,
includeQrcode: true,
}),
cover: Object.freeze({
priority: Object.freeze(['hero-raster', 'thumbnail-png-sidecar', 'thumbnail-svg-sidecar', 'placeholder']),
allowSvgSidecarAsRasterFallback: true,
heroSources: Object.freeze([
'mindspace-cover.cover',
'css-hero-background-image',
'hero-img',
]),
mustMatchBodyHeroWhenPresent: true,
}),
images: Object.freeze({
bodyUpload: 'wechat-uploadimg',
coverUpload: 'wechat-permanent-thumb',
preferHttps: true,
}),
limits: Object.freeze({
maxContentChars: 20000,
}),
});
export function verifyWechatDraftPublicationContent(
content,
{ publicUrl = '', requireReadOriginal = Boolean(publicUrl) } = {},
) {
const issues = [];
const text = String(content ?? '');
if (requireReadOriginal && !text.includes('阅读原文')) {
issues.push('缺少「阅读原文」链接');
}
if (!text.includes(WECHAT_DRAFT_PUBLICATION_STANDARD.followFooter.title)) {
issues.push('缺少底部关注区标题');
}
if (text.includes('点击阅读原文')) {
issues.push('不得使用旧版大按钮「点击阅读原文」');
}
if (/alt="TKMind 智趣"/.test(text)) {
issues.push('底部关注区不得包含 logo 图片');
}
if (/获取更多精彩内容|每日早报不错过/.test(text)) {
issues.push('底部关注区不得包含额外导语');
}
const readIdx = text.indexOf('阅读原文');
const followIdx = text.indexOf(WECHAT_DRAFT_PUBLICATION_STANDARD.followFooter.title);
if (readIdx >= 0 && followIdx >= 0 && readIdx > followIdx) {
issues.push('「阅读原文」须位于正文之后、关注区之前');
}
return { ok: issues.length === 0, issues };
}
export function verifyWechatDraftCoverResolution(resolved) {
const issues = [];
if (!resolved?.source) {
issues.push('缺少封面来源');
}
return { ok: issues.length === 0, issues };
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { applyWechatDraftArticleLayout } from './wechat-draft-article-layout.mjs';
import {
buildMindSpacePageWechatDraftArticleForPush,
resolveWechatDraftThumbPath,
} from './mindspace-wechat-page-draft.mjs';
import {
verifyWechatDraftCoverResolution,
verifyWechatDraftPublicationContent,
WECHAT_DRAFT_PUBLICATION_STANDARD,
WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION,
} from './wechat-draft-publication-standard.mjs';
const userId = '1c99b83b-0454-474f-a5d2-129d34506a32';
const AUGUST_HTML = fs.readFileSync(
`MindSpace/${userId}/public/august-afternoon.html`,
'utf8',
);
const PUBLIC_URL = `http://127.0.0.1:8081/MindSpace/${userId}/public/august-afternoon.html`;
test('standard version is pinned', () => {
assert.equal(WECHAT_DRAFT_PUBLICATION_STANDARD.version, WECHAT_DRAFT_PUBLICATION_STANDARD_VERSION);
assert.equal(WECHAT_DRAFT_PUBLICATION_STANDARD.cover.priority[0], 'hero-raster');
});
test('verifyWechatDraftPublicationContent rejects legacy large read button', () => {
const bad = applyWechatDraftArticleLayout('<p>正文</p>', {
publicUrl: PUBLIC_URL,
qrcodeImageUrl: 'https://mmbiz.qpic.cn/qrcode.png',
}).replace('阅读原文', '点击阅读原文');
const result = verifyWechatDraftPublicationContent(bad, { publicUrl: PUBLIC_URL });
assert.equal(result.ok, false);
assert.ok(result.issues.some((item) => /大按钮/.test(item)));
});
test('august-afternoon cover resolves to hero raster', () => {
const resolved = resolveWechatDraftThumbPath(AUGUST_HTML, {
h5Root: process.cwd(),
userId,
workspaceRelativePath: 'public/august-afternoon.html',
publishDir: path.join(process.cwd(), 'MindSpace', userId),
});
assert.equal(resolved.source, 'hero');
assert.match(resolved.path ?? '', /\.webp$/);
const coverCheck = verifyWechatDraftCoverResolution(resolved);
assert.equal(coverCheck.ok, true);
});
test('august-afternoon article layout matches publication standard', async () => {
const article = await buildMindSpacePageWechatDraftArticleForPush({
html: AUGUST_HTML,
publicUrl: PUBLIC_URL,
pageTitle: '八月午后 · 一首短诗',
pageSummary: '一首关于八月午后的短诗',
publishDir: path.join(process.cwd(), 'MindSpace', userId),
htmlRelativePath: 'public/august-afternoon.html',
memindLibRoot: process.cwd(),
});
const check = verifyWechatDraftPublicationContent(article.content, { publicUrl: PUBLIC_URL });
assert.equal(check.ok, true, check.issues.join('; '));
assert.match(article.content, /蝉声把正午拉得很长/);
const readIdx = article.content.indexOf('阅读原文');
const followIdx = article.content.indexOf('欢迎关注 TKMind 智趣');
const heroIdx = article.content.indexOf('<img');
assert.ok(heroIdx < readIdx, 'hero 应在阅读原文之前');
assert.ok(readIdx < followIdx, '阅读原文应在关注区之前');
});
+110 -18
View File
@@ -8,8 +8,10 @@ import {
PUBLIC_ZONE_DIR,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import { extractMindspaceCoverPath } from './wechat/verify/generated-thumbnail.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { convertDailyNewsHtmlToWechatInlineArticle } from './wechat-daily-news-inline.mjs';
import { uploadWechatDraftBrandAssets } from './wechat-draft-article-layout.mjs';
import { getLocalParts, localDateKey, startOfLocalDay } from './schedule-time.mjs';
const CONFIG_TABLE = 'h5_wechat_admin_config';
@@ -311,16 +313,106 @@ export async function uploadWechatArticleContentImage(
return String(payload.url);
}
function normalizeWechatBodyImageUrl(imageUrl) {
return String(imageUrl ?? '').trim().replace(/^http:\/\//i, 'https://');
}
function collectHtmlBodyImageRefs(html) {
const refs = [];
const seen = new Set();
const coverRef = extractMindspaceCoverPath(html);
if (coverRef && !/^data:/i.test(coverRef)) {
seen.add(coverRef);
refs.push(coverRef);
}
for (const match of String(html ?? '').matchAll(/<img[^>]+src=["']([^"']+)["']/gi)) {
const ref = String(match[1] ?? '').trim();
if (!ref || /^data:/i.test(ref) || seen.has(ref)) continue;
seen.add(ref);
refs.push(ref);
}
return refs;
}
function resolveHtmlBodyImagePath(ref, { publishDir = '', memindLibRoot = process.cwd() } = {}) {
if (/^https?:\/\//i.test(ref)) return ref;
const normalized = ref.replace(/^\.\//, '').replace(/^\/+/, '');
const candidates = [
path.resolve(publishDir, normalized),
path.resolve(publishDir, PUBLIC_ZONE_DIR, normalized),
path.join(memindLibRoot, 'public', normalized),
path.join(memindLibRoot, 'wechat/assets', normalized),
];
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
}
export async function uploadDailyNewsBodyImagesForWechat(
accessToken,
html,
{
publishDir = '',
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
} = {},
) {
const imageUrlMap = new Map();
if (!accessToken) return imageUrlMap;
for (const ref of collectHtmlBodyImageRefs(html)) {
const resolved = resolveHtmlBodyImagePath(ref, { publishDir, memindLibRoot });
if (!resolved || imageUrlMap.has(ref)) continue;
let buffer;
try {
if (/^https?:\/\//i.test(resolved)) {
const response = await wechatFetch(resolved);
if (!response.ok) {
console.warn(`[wechat-draft] skip image upload, download failed: ${ref} (${response.status})`);
continue;
}
buffer = Buffer.from(await response.arrayBuffer());
} else {
buffer = fs.readFileSync(resolved);
}
if (/\.svg$/i.test(ref) || /\.svg$/i.test(resolved)) {
buffer = await sharp(buffer).png().toBuffer();
} else if (/\.(webp|jpe?g)$/i.test(ref) || /\.(webp|jpe?g)$/i.test(resolved)) {
buffer = await sharp(buffer).png().toBuffer();
}
} catch (error) {
console.warn(`[wechat-draft] skip image upload for ${ref}:`, error instanceof Error ? error.message : error);
continue;
}
const uploaded = normalizeWechatBodyImageUrl(
await uploadWechatArticleContentImage(accessToken, buffer, {
wechatFetch,
filename: `${path.basename(ref).replace(/\.(webp|svg|jpe?g)$/i, '') || 'body'}.png`,
}),
);
imageUrlMap.set(ref, uploaded);
}
return imageUrlMap;
}
export function buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl = '',
imageUrlMap = new Map(),
} = {}) {
const article = convertDailyNewsHtmlToWechatInlineArticle(html, { publicUrl, qrcodeImageUrl });
const article = convertDailyNewsHtmlToWechatInlineArticle(html, {
publicUrl,
qrcodeImageUrl,
imageUrlMap,
});
return {
...article,
cardCount: extractCardArticles(html).length,
statsCount: extractStats(html).length,
uploadedImageCount: imageUrlMap.size,
};
}
@@ -330,19 +422,24 @@ export async function buildDailyNewsWechatDraftArticleForPush({
accessToken = null,
wechatFetch = undiciFetch,
memindLibRoot = process.cwd(),
publishDir = '',
} = {}) {
let qrcodeImageUrl = '';
if (accessToken) {
const qrcodePath = resolveMpFollowQrcodePath(memindLibRoot);
if (qrcodePath) {
qrcodeImageUrl = await uploadWechatArticleContentImage(
accessToken,
fs.readFileSync(qrcodePath),
{ wechatFetch, filename: 'mp-follow-qrcode.png' },
);
}
}
return buildDailyNewsWechatDraftArticle({ html, publicUrl, qrcodeImageUrl });
const { qrcodeImageUrl } = await uploadWechatDraftBrandAssets({
accessToken,
wechatFetch,
memindLibRoot,
});
const imageUrlMap = await uploadDailyNewsBodyImagesForWechat(accessToken, html, {
publishDir,
wechatFetch,
memindLibRoot,
});
return buildDailyNewsWechatDraftArticle({
html,
publicUrl,
qrcodeImageUrl,
imageUrlMap,
});
}
export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '' } = {}) {
@@ -403,11 +500,6 @@ export function convertNewsPageHtmlToWechatArticle(html, { publicUrl = '' } = {}
}
sections.push('</section>');
}
if (publicUrl) {
sections.push(
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${publicUrl}</a></p>`,
);
}
return {
title: title.slice(0, 64),