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:
+12
-1
@@ -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 decision(docs/h5-session-architecture-20260706.md Patch 3)
|
||||
# 默认 0/off:gateway 仍读 legacy route
|
||||
# shadow:只打 [router-shadow] 日志,行为不变
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(阅读原文 + 关注区),栏目正文保持早报专用结构。
|
||||
|
||||
## 正文 layout(MindSpace 页面)
|
||||
|
||||
自上而下:
|
||||
|
||||
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`) |
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}" ]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user