multi-publisher 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/dist/adapters/baijiahao.d.ts +14 -0
- package/dist/adapters/baijiahao.js +77 -0
- package/dist/adapters/base-adapter.d.ts +41 -0
- package/dist/adapters/base-adapter.js +84 -0
- package/dist/adapters/bilibili.d.ts +14 -0
- package/dist/adapters/bilibili.js +86 -0
- package/dist/adapters/cnblogs.d.ts +14 -0
- package/dist/adapters/cnblogs.js +90 -0
- package/dist/adapters/csdn.d.ts +29 -0
- package/dist/adapters/csdn.js +174 -0
- package/dist/adapters/cto51.d.ts +14 -0
- package/dist/adapters/cto51.js +72 -0
- package/dist/adapters/douban.d.ts +14 -0
- package/dist/adapters/douban.js +49 -0
- package/dist/adapters/eastmoney.d.ts +14 -0
- package/dist/adapters/eastmoney.js +47 -0
- package/dist/adapters/imooc.d.ts +14 -0
- package/dist/adapters/imooc.js +78 -0
- package/dist/adapters/index.d.ts +26 -0
- package/dist/adapters/index.js +22 -0
- package/dist/adapters/interface.d.ts +44 -0
- package/dist/adapters/interface.js +1 -0
- package/dist/adapters/juejin.d.ts +23 -0
- package/dist/adapters/juejin.js +166 -0
- package/dist/adapters/oschina.d.ts +14 -0
- package/dist/adapters/oschina.js +77 -0
- package/dist/adapters/registry.d.ts +61 -0
- package/dist/adapters/registry.js +146 -0
- package/dist/adapters/segmentfault.d.ts +14 -0
- package/dist/adapters/segmentfault.js +73 -0
- package/dist/adapters/sohu.d.ts +14 -0
- package/dist/adapters/sohu.js +72 -0
- package/dist/adapters/toutiao.d.ts +9 -0
- package/dist/adapters/toutiao.js +199 -0
- package/dist/adapters/wechat-publisher.d.ts +55 -0
- package/dist/adapters/wechat-publisher.js +210 -0
- package/dist/adapters/weibo.d.ts +14 -0
- package/dist/adapters/weibo.js +99 -0
- package/dist/adapters/weixin.d.ts +14 -0
- package/dist/adapters/weixin.js +63 -0
- package/dist/adapters/woshipm.d.ts +14 -0
- package/dist/adapters/woshipm.js +72 -0
- package/dist/adapters/xiaohongshu.d.ts +9 -0
- package/dist/adapters/xiaohongshu.js +199 -0
- package/dist/adapters/xueqiu.d.ts +14 -0
- package/dist/adapters/xueqiu.js +74 -0
- package/dist/adapters/yuque.d.ts +13 -0
- package/dist/adapters/yuque.js +25 -0
- package/dist/adapters/zhihu.d.ts +23 -0
- package/dist/adapters/zhihu.js +142 -0
- package/dist/cli/cookie.d.ts +5 -0
- package/dist/cli/cookie.js +80 -0
- package/dist/cli/credential.d.ts +6 -0
- package/dist/cli/credential.js +32 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.js +72 -0
- package/dist/cli/login.d.ts +3 -0
- package/dist/cli/login.js +101 -0
- package/dist/cli/platforms.d.ts +1 -0
- package/dist/cli/platforms.js +35 -0
- package/dist/cli/publish-all.d.ts +6 -0
- package/dist/cli/publish-all.js +101 -0
- package/dist/cli/publish.d.ts +7 -0
- package/dist/cli/publish.js +78 -0
- package/dist/cli/render.d.ts +9 -0
- package/dist/cli/render.js +40 -0
- package/dist/commands/login.d.ts +43 -0
- package/dist/commands/login.js +248 -0
- package/dist/commands/publish.d.ts +19 -0
- package/dist/commands/publish.js +275 -0
- package/dist/config.d.ts +177 -0
- package/dist/config.js +333 -0
- package/dist/core/mathjax.d.ts +4 -0
- package/dist/core/mathjax.js +33 -0
- package/dist/core/parser.d.ts +17 -0
- package/dist/core/parser.js +42 -0
- package/dist/core/renderer.d.ts +18 -0
- package/dist/core/renderer.js +30 -0
- package/dist/core/styler.d.ts +8 -0
- package/dist/core/styler.js +11 -0
- package/dist/core/theme.d.ts +9 -0
- package/dist/core/theme.js +420 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/runtime/browser-runtime.d.ts +79 -0
- package/dist/runtime/browser-runtime.js +501 -0
- package/dist/runtime/index.d.ts +12 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/node-runtime.d.ts +5 -0
- package/dist/runtime/node-runtime.js +25 -0
- package/dist/tools/xiaohongshu-sniffer.d.ts +1 -0
- package/dist/tools/xiaohongshu-sniffer.js +54 -0
- package/package.json +62 -0
- package/themes/all-themes-preview.html +522 -0
- package/themes/previews/cyberpunk.png +0 -0
- package/themes/previews/minimal.png +0 -0
- package/themes/previews/nord.png +0 -0
- package/themes/previews/wechat.png +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 思否 (SegmentFault) 适配器
|
|
3
|
+
* 认证方式: Cookie
|
|
4
|
+
*/
|
|
5
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
6
|
+
export class SegmentfaultAdapter extends BaseAdapter {
|
|
7
|
+
meta = {
|
|
8
|
+
id: 'segmentfault',
|
|
9
|
+
name: '思否',
|
|
10
|
+
icon: 'https://segmentfault.com/favicon.ico',
|
|
11
|
+
homepage: 'https://segmentfault.com/',
|
|
12
|
+
capabilities: ['article', 'draft'],
|
|
13
|
+
};
|
|
14
|
+
cookieData = null;
|
|
15
|
+
async init() {
|
|
16
|
+
this.cookieData = await this.loadCookies();
|
|
17
|
+
}
|
|
18
|
+
getCookieConfigKey() {
|
|
19
|
+
return 'segmentfault';
|
|
20
|
+
}
|
|
21
|
+
async checkAuth() {
|
|
22
|
+
if (!this.cookieData) {
|
|
23
|
+
return { isAuthenticated: false, error: '未配置思否 Cookie' };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const cookieStr = this.buildCookieString(this.cookieData);
|
|
27
|
+
const res = await fetch('https://segmentfault.com/user/profile', {
|
|
28
|
+
headers: {
|
|
29
|
+
'Cookie': cookieStr,
|
|
30
|
+
...this.getCommonHeaders(),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const text = await res.text();
|
|
34
|
+
if (res.ok && text.includes('个人主页')) {
|
|
35
|
+
return { isAuthenticated: true };
|
|
36
|
+
}
|
|
37
|
+
return { isAuthenticated: false, error: '未登录或登录已过期' };
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return { isAuthenticated: false, error: err.message };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async publish(article) {
|
|
44
|
+
const start = Date.now();
|
|
45
|
+
if (!this.cookieData) {
|
|
46
|
+
return this.createErrorResult('未配置思否 Cookie', start);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const cookieStr = this.buildCookieString(this.cookieData);
|
|
50
|
+
const res = await fetch('https://segmentfault.com/article/save_draft', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
'Cookie': cookieStr,
|
|
55
|
+
...this.getCommonHeaders(),
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
title: article.title,
|
|
59
|
+
content: article.markdown || '',
|
|
60
|
+
draft: true,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
const data = await res.json();
|
|
64
|
+
if (data.code === 0 && data.id) {
|
|
65
|
+
return this.createSuccessResult(data.id, `https://segmentfault.com/article/edit?id=${data.id}`, true, start);
|
|
66
|
+
}
|
|
67
|
+
return this.createErrorResult('发布失败', start);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return this.createErrorResult(err.message, start);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 搜狐号适配器
|
|
3
|
+
* 认证方式: Cookie
|
|
4
|
+
*/
|
|
5
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
6
|
+
import type { Article, AuthResult, PlatformMeta } from './interface.js';
|
|
7
|
+
export declare class SohuAdapter extends BaseAdapter {
|
|
8
|
+
readonly meta: PlatformMeta;
|
|
9
|
+
private cookieData;
|
|
10
|
+
init(): Promise<void>;
|
|
11
|
+
protected getCookieConfigKey(): string;
|
|
12
|
+
checkAuth(): Promise<AuthResult>;
|
|
13
|
+
publish(article: Article): Promise<import('./interface.js').SyncResult>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 搜狐号适配器
|
|
3
|
+
* 认证方式: Cookie
|
|
4
|
+
*/
|
|
5
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
6
|
+
export class SohuAdapter extends BaseAdapter {
|
|
7
|
+
meta = {
|
|
8
|
+
id: 'sohu',
|
|
9
|
+
name: '搜狐号',
|
|
10
|
+
icon: 'https://mp.sohu.com/favicon.ico',
|
|
11
|
+
homepage: 'https://mp.sohu.com/',
|
|
12
|
+
capabilities: ['article', 'draft', 'image_upload'],
|
|
13
|
+
};
|
|
14
|
+
cookieData = null;
|
|
15
|
+
async init() {
|
|
16
|
+
this.cookieData = await this.loadCookies();
|
|
17
|
+
}
|
|
18
|
+
getCookieConfigKey() {
|
|
19
|
+
return 'sohu';
|
|
20
|
+
}
|
|
21
|
+
async checkAuth() {
|
|
22
|
+
if (!this.cookieData) {
|
|
23
|
+
return { isAuthenticated: false, error: '未配置搜狐 Cookie' };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const cookieStr = this.buildCookieString(this.cookieData);
|
|
27
|
+
const res = await fetch('https://mp.sohu.com/article/list', {
|
|
28
|
+
headers: {
|
|
29
|
+
'Cookie': cookieStr,
|
|
30
|
+
...this.getCommonHeaders(),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (res.ok && !res.url.includes('login')) {
|
|
34
|
+
return { isAuthenticated: true };
|
|
35
|
+
}
|
|
36
|
+
return { isAuthenticated: false, error: '未登录或登录已过期' };
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return { isAuthenticated: false, error: err.message };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async publish(article) {
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
if (!this.cookieData) {
|
|
45
|
+
return this.createErrorResult('未配置搜狐 Cookie', start);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const cookieStr = this.buildCookieString(this.cookieData);
|
|
49
|
+
const res = await fetch('https://mp.sohu.com/api/article/save_draft', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'Cookie': cookieStr,
|
|
54
|
+
...this.getCommonHeaders(),
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
title: article.title,
|
|
58
|
+
content: article.html || article.markdown || '',
|
|
59
|
+
draft: 1,
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
if (data.code === 0 && data.data?.id) {
|
|
64
|
+
return this.createSuccessResult(data.data.id, `https://mp.sohu.com/article/edit?id=${data.data.id}`, true, start);
|
|
65
|
+
}
|
|
66
|
+
return this.createErrorResult('发布失败', start);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return this.createErrorResult(err.message, start);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Article, AuthResult, PlatformMeta, SyncResult } from './interface.js';
|
|
2
|
+
export declare class ToutiaoAdapter implements IPlatformAdapter {
|
|
3
|
+
readonly meta: PlatformMeta;
|
|
4
|
+
private cookieData;
|
|
5
|
+
init(): Promise<void>;
|
|
6
|
+
checkAuth(): Promise<AuthResult>;
|
|
7
|
+
publish(article: Article): Promise<SyncResult>;
|
|
8
|
+
}
|
|
9
|
+
import type { IPlatformAdapter } from './interface.js';
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 头条号 (Toutiao) 适配器
|
|
3
|
+
* 使用 Playwright 直接操作页面发布
|
|
4
|
+
*/
|
|
5
|
+
import { chromium } from 'playwright';
|
|
6
|
+
import { ConfigStore } from '../config.js';
|
|
7
|
+
export class ToutiaoAdapter {
|
|
8
|
+
meta = {
|
|
9
|
+
id: 'toutiao',
|
|
10
|
+
name: '头条号',
|
|
11
|
+
icon: 'https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/bytedoctor/1.0.14/favicon.ico',
|
|
12
|
+
homepage: 'https://mp.toutiao.com/',
|
|
13
|
+
capabilities: ['article', 'draft', 'image_upload'],
|
|
14
|
+
};
|
|
15
|
+
cookieData = null;
|
|
16
|
+
async init() {
|
|
17
|
+
this.cookieData = await ConfigStore.getToutiaoCookies();
|
|
18
|
+
}
|
|
19
|
+
async checkAuth() {
|
|
20
|
+
if (!this.cookieData) {
|
|
21
|
+
return { isAuthenticated: false, error: '未配置头条号 Cookie' };
|
|
22
|
+
}
|
|
23
|
+
const hasSession = this.cookieData['sessionid'] || this.cookieData['sid_tt'];
|
|
24
|
+
if (!hasSession) {
|
|
25
|
+
return { isAuthenticated: false, error: '未登录或登录已过期' };
|
|
26
|
+
}
|
|
27
|
+
return { isAuthenticated: true };
|
|
28
|
+
}
|
|
29
|
+
async publish(article) {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
if (!this.cookieData) {
|
|
32
|
+
return {
|
|
33
|
+
platform: this.meta.id,
|
|
34
|
+
success: false,
|
|
35
|
+
error: '未配置头条号 Cookie',
|
|
36
|
+
timestamp: Date.now() - start,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const browser = await chromium.launch({
|
|
40
|
+
headless: false,
|
|
41
|
+
args: [
|
|
42
|
+
'--disable-blink-features=AutomationControlled',
|
|
43
|
+
'--disable-devtools-shm-usage',
|
|
44
|
+
'--no-sandbox',
|
|
45
|
+
]
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
const context = await browser.newContext({
|
|
49
|
+
viewport: { width: 1280, height: 900 },
|
|
50
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
51
|
+
});
|
|
52
|
+
const page = await context.newPage();
|
|
53
|
+
// 使用 CDP 隐藏自动化特征
|
|
54
|
+
const cdp = await page.context().newCDPSession(page);
|
|
55
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
|
|
56
|
+
source: `
|
|
57
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
58
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
59
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
|
|
60
|
+
window.chrome = { runtime: {}, loadTimes: () => {}, csi: () => {} };
|
|
61
|
+
`
|
|
62
|
+
});
|
|
63
|
+
// 设置 cookies
|
|
64
|
+
const pageCookies = Object.entries(this.cookieData).map(([name, value]) => ({
|
|
65
|
+
name,
|
|
66
|
+
value,
|
|
67
|
+
domain: '.toutiao.com',
|
|
68
|
+
path: '/',
|
|
69
|
+
}));
|
|
70
|
+
await context.addCookies(pageCookies);
|
|
71
|
+
console.log('[toutiao] 正在打开头条号编辑器...');
|
|
72
|
+
await page.goto('https://mp.toutiao.com/profile_v4/graphic/publish', {
|
|
73
|
+
waitUntil: 'networkidle',
|
|
74
|
+
timeout: 60000
|
|
75
|
+
});
|
|
76
|
+
await page.waitForTimeout(3000);
|
|
77
|
+
if (page.url().includes('login')) {
|
|
78
|
+
return {
|
|
79
|
+
platform: this.meta.id,
|
|
80
|
+
success: false,
|
|
81
|
+
error: '未登录或登录已过期,请重新登录',
|
|
82
|
+
timestamp: Date.now() - start,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// 关闭弹窗
|
|
86
|
+
try {
|
|
87
|
+
const mask = page.locator('.byte-drawer-mask').first();
|
|
88
|
+
if (await mask.isVisible({ timeout: 1000 })) {
|
|
89
|
+
await mask.click({ force: true });
|
|
90
|
+
await page.waitForTimeout(500);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
// 填写标题
|
|
95
|
+
const titleTextarea = page.locator('textarea').first();
|
|
96
|
+
await titleTextarea.fill(article.title);
|
|
97
|
+
console.log('[toutiao] 已填写标题');
|
|
98
|
+
// 填写内容
|
|
99
|
+
const contentEl = page.locator('div[contenteditable="true"]').first();
|
|
100
|
+
if (await contentEl.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
101
|
+
await contentEl.click();
|
|
102
|
+
// 使用 Ctrl+A 全选,然后输入内容(模拟用户输入)
|
|
103
|
+
await page.keyboard.press('Control+a');
|
|
104
|
+
await page.waitForTimeout(200);
|
|
105
|
+
const htmlContent = article.html || article.markdown || '';
|
|
106
|
+
await page.evaluate((el) => {
|
|
107
|
+
const div = document.querySelector('[contenteditable="true"]');
|
|
108
|
+
if (div) {
|
|
109
|
+
div.innerHTML = el;
|
|
110
|
+
// 触发 input 事件
|
|
111
|
+
div.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
112
|
+
}
|
|
113
|
+
}, htmlContent);
|
|
114
|
+
console.log('[toutiao] 已填写内容');
|
|
115
|
+
await page.waitForTimeout(1000);
|
|
116
|
+
}
|
|
117
|
+
// 等待编辑器自动保存(最多5分钟)
|
|
118
|
+
console.log('[toutiao] 等待编辑器自动保存(最多5分钟)...');
|
|
119
|
+
for (let i = 0; i < 300; i++) {
|
|
120
|
+
const url = page.url();
|
|
121
|
+
if (url.includes('id=')) {
|
|
122
|
+
console.log('[toutiao] 检测到文章 ID,保存完成');
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
await page.waitForTimeout(1000);
|
|
126
|
+
if (i % 30 === 0 && i > 0) {
|
|
127
|
+
console.log(`[toutiao] 已等待 ${i} 秒...`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// 检查 URL 是否包含文章 ID
|
|
131
|
+
let currentUrl = page.url();
|
|
132
|
+
let idMatch = currentUrl.match(/[?&]id=(\d+)/);
|
|
133
|
+
let savedId = idMatch ? idMatch[1] : null;
|
|
134
|
+
if (idMatch) {
|
|
135
|
+
console.log('[toutiao] 检测到文章 ID:', idMatch[1]);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
console.log('[toutiao] URL 仍未变化:', currentUrl);
|
|
139
|
+
}
|
|
140
|
+
// 尝试点击"发布"按钮触发保存
|
|
141
|
+
console.log('[toutiao] 尝试点击发布按钮...');
|
|
142
|
+
try {
|
|
143
|
+
// 查找发布按钮(可能在右上角或底部)
|
|
144
|
+
const publishBtn = page.locator('button').filter({ hasText: /^发布$/ }).first();
|
|
145
|
+
if (await publishBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
146
|
+
await publishBtn.click({ force: true });
|
|
147
|
+
console.log('[toutiao] 已点击发布按钮');
|
|
148
|
+
// 等待保存完成
|
|
149
|
+
await page.waitForTimeout(10000);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// 如果没找到发布按钮,尝试保存按钮
|
|
153
|
+
const saveBtn = page.locator('button').filter({ hasText: /保存草稿|保存/ }).first();
|
|
154
|
+
if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
155
|
+
await saveBtn.click({ force: true });
|
|
156
|
+
console.log('[toutiao] 已点击保存按钮');
|
|
157
|
+
await page.waitForTimeout(10000);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
console.log('[toutiao] 按钮点击失败:', e.message);
|
|
163
|
+
}
|
|
164
|
+
const finalUrl = page.url();
|
|
165
|
+
idMatch = finalUrl.match(/[?&]id=(\d+)/);
|
|
166
|
+
if (idMatch) {
|
|
167
|
+
console.log('[toutiao] 保存成功,文章 ID:', idMatch[1]);
|
|
168
|
+
return {
|
|
169
|
+
platform: this.meta.id,
|
|
170
|
+
success: true,
|
|
171
|
+
postId: idMatch[1],
|
|
172
|
+
postUrl: `https://mp.toutiao.com/profile_v4/graphic/edit?id=${idMatch[1]}`,
|
|
173
|
+
draftOnly: true,
|
|
174
|
+
timestamp: Date.now() - start,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
console.log('[toutiao] 草稿已保存');
|
|
178
|
+
return {
|
|
179
|
+
platform: this.meta.id,
|
|
180
|
+
success: true,
|
|
181
|
+
postUrl: finalUrl,
|
|
182
|
+
draftOnly: true,
|
|
183
|
+
timestamp: Date.now() - start,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.error('[toutiao] 错误:', err);
|
|
188
|
+
return {
|
|
189
|
+
platform: this.meta.id,
|
|
190
|
+
success: false,
|
|
191
|
+
error: err.message,
|
|
192
|
+
timestamp: Date.now() - start,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
await browser.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信公众号发布核心逻辑
|
|
3
|
+
* - AppID + AppSecret 获取 access_token
|
|
4
|
+
* - 图片上传到微信 CDN(使用 form-data + axios,兼容 Node 24)
|
|
5
|
+
* - 创建草稿图文消息
|
|
6
|
+
*/
|
|
7
|
+
import type { RuntimeInterface } from '../runtime/index.js';
|
|
8
|
+
export declare class WechatPublisher {
|
|
9
|
+
private runtime;
|
|
10
|
+
private uploadCache;
|
|
11
|
+
constructor(runtime: RuntimeInterface);
|
|
12
|
+
private loadCredential;
|
|
13
|
+
getAccessToken(): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* 下载图片并以指定文件名上传到微信 CDN
|
|
16
|
+
*/
|
|
17
|
+
uploadImageFromUrl(imageUrl: string): Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* 上传本地图片到微信 CDN
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* 上传本地图片到微信 CDN(临时素材,用于正文嵌入)
|
|
23
|
+
*/
|
|
24
|
+
uploadImageFromPath(filePath: string, filename?: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* 上传永久素材图片(用于封面 thumb_media_id,draft/add 必须用永久素材)
|
|
27
|
+
* 使用 material/add_material 接口
|
|
28
|
+
*/
|
|
29
|
+
uploadPermanentImageFromPath(filePath: string, filename?: string): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* 从 URL 下载图片并上传为永久素材(用于封面)
|
|
32
|
+
*/
|
|
33
|
+
uploadPermanentImageFromUrl(imageUrl: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* 处理 HTML 中的图片:本地图片上传到微信 CDN,外部图片保留原 URL
|
|
36
|
+
* 返回处理后的 HTML 和第一张可用的 media_id(用作封面)
|
|
37
|
+
*/
|
|
38
|
+
processImages(html: string): Promise<{
|
|
39
|
+
html: string;
|
|
40
|
+
firstMediaId?: string;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* 发布到微信公众号草稿箱
|
|
44
|
+
*/
|
|
45
|
+
publishToDraft(options: {
|
|
46
|
+
title: string;
|
|
47
|
+
content: string;
|
|
48
|
+
cover: string;
|
|
49
|
+
author: string;
|
|
50
|
+
source_url: string;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
media_id: string;
|
|
53
|
+
}>;
|
|
54
|
+
private uploadCover;
|
|
55
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import FormData from 'form-data';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import { ConfigStore } from '../config.js';
|
|
8
|
+
function extFromUrl(url) {
|
|
9
|
+
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
|
10
|
+
return ext || '.jpg';
|
|
11
|
+
}
|
|
12
|
+
function nameFromUrl(url) {
|
|
13
|
+
return path.basename(new URL(url).pathname) || `image${extFromUrl(url)}`;
|
|
14
|
+
}
|
|
15
|
+
export class WechatPublisher {
|
|
16
|
+
runtime;
|
|
17
|
+
uploadCache = {};
|
|
18
|
+
constructor(runtime) {
|
|
19
|
+
this.runtime = runtime;
|
|
20
|
+
}
|
|
21
|
+
async loadCredential() {
|
|
22
|
+
const cred = await ConfigStore.getWeixin();
|
|
23
|
+
const appId = cred.appId;
|
|
24
|
+
const appSecret = cred.appSecret;
|
|
25
|
+
if (!appId || !appSecret) {
|
|
26
|
+
throw new Error('未配置微信公众号凭据。请运行: mpub credential --set');
|
|
27
|
+
}
|
|
28
|
+
return { appId, appSecret };
|
|
29
|
+
}
|
|
30
|
+
async getAccessToken() {
|
|
31
|
+
const cred = await ConfigStore.getWeixin();
|
|
32
|
+
const tokenExpiresAt = cred.token_expires_at;
|
|
33
|
+
if (cred.access_token && tokenExpiresAt && Date.now() < tokenExpiresAt) {
|
|
34
|
+
return cred.access_token;
|
|
35
|
+
}
|
|
36
|
+
const { appId, appSecret } = await this.loadCredential();
|
|
37
|
+
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
38
|
+
const res = await axios.get(url);
|
|
39
|
+
const data = res.data;
|
|
40
|
+
if (!data.access_token) {
|
|
41
|
+
throw new Error(`获取 access_token 失败: ${data.errmsg || JSON.stringify(data)}`);
|
|
42
|
+
}
|
|
43
|
+
const newToken = data.access_token;
|
|
44
|
+
const expiresAt = Date.now() + ((data.expires_in || 7200) - 300) * 1000;
|
|
45
|
+
await ConfigStore.setWeixin({ access_token: newToken, token_expires_at: expiresAt });
|
|
46
|
+
return newToken;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 下载图片并以指定文件名上传到微信 CDN
|
|
50
|
+
*/
|
|
51
|
+
async uploadImageFromUrl(imageUrl) {
|
|
52
|
+
if (this.uploadCache[imageUrl])
|
|
53
|
+
return this.uploadCache[imageUrl];
|
|
54
|
+
// 下载图片到临时文件
|
|
55
|
+
const res = await this.runtime.fetch(imageUrl);
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
throw new Error(`图片下载失败: ${imageUrl}`);
|
|
58
|
+
const buf = await res.arrayBuffer();
|
|
59
|
+
const ext = extFromUrl(imageUrl);
|
|
60
|
+
const tmp = path.join(os.tmpdir(), `mpub-img-${Date.now()}${ext}`);
|
|
61
|
+
await fs.writeFile(tmp, Buffer.from(buf));
|
|
62
|
+
const filename = nameFromUrl(imageUrl);
|
|
63
|
+
const mediaId = await this.uploadImageFromPath(tmp, filename);
|
|
64
|
+
await fs.unlink(tmp).catch(() => { });
|
|
65
|
+
this.uploadCache[imageUrl] = mediaId;
|
|
66
|
+
return mediaId;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 上传本地图片到微信 CDN
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* 上传本地图片到微信 CDN(临时素材,用于正文嵌入)
|
|
73
|
+
*/
|
|
74
|
+
async uploadImageFromPath(filePath, filename) {
|
|
75
|
+
const token = await this.getAccessToken();
|
|
76
|
+
const name = filename ?? path.basename(filePath);
|
|
77
|
+
const form = new FormData();
|
|
78
|
+
form.append('media', createReadStream(filePath), name);
|
|
79
|
+
const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${token}&type=image`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
|
|
80
|
+
const data = res.data;
|
|
81
|
+
if (!data.media_id) {
|
|
82
|
+
throw new Error(`图片上传失败: ${data.errmsg || JSON.stringify(data)}`);
|
|
83
|
+
}
|
|
84
|
+
return data.media_id;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 上传永久素材图片(用于封面 thumb_media_id,draft/add 必须用永久素材)
|
|
88
|
+
* 使用 material/add_material 接口
|
|
89
|
+
*/
|
|
90
|
+
async uploadPermanentImageFromPath(filePath, filename) {
|
|
91
|
+
const token = await this.getAccessToken();
|
|
92
|
+
const name = filename ?? path.basename(filePath);
|
|
93
|
+
const form = new FormData();
|
|
94
|
+
form.append('media', createReadStream(filePath), name);
|
|
95
|
+
form.append('type', 'image');
|
|
96
|
+
const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${token}&type=image`, form, { headers: form.getHeaders(), maxBodyLength: Infinity, maxContentLength: Infinity });
|
|
97
|
+
const data = res.data;
|
|
98
|
+
if (!data.media_id) {
|
|
99
|
+
throw new Error(`永久素材上传失败: ${data.errmsg || JSON.stringify(data)}`);
|
|
100
|
+
}
|
|
101
|
+
return data.media_id;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 从 URL 下载图片并上传为永久素材(用于封面)
|
|
105
|
+
*/
|
|
106
|
+
async uploadPermanentImageFromUrl(imageUrl) {
|
|
107
|
+
if (this.uploadCache[imageUrl])
|
|
108
|
+
return this.uploadCache[imageUrl];
|
|
109
|
+
const res = await this.runtime.fetch(imageUrl);
|
|
110
|
+
if (!res.ok)
|
|
111
|
+
throw new Error(`封面图片下载失败: ${imageUrl}`);
|
|
112
|
+
const buf = await res.arrayBuffer();
|
|
113
|
+
const ext = extFromUrl(imageUrl);
|
|
114
|
+
const tmp = path.join(os.tmpdir(), `mpub-perm-${Date.now()}${ext}`);
|
|
115
|
+
await fs.writeFile(tmp, Buffer.from(buf));
|
|
116
|
+
try {
|
|
117
|
+
const mediaId = await this.uploadPermanentImageFromPath(tmp, nameFromUrl(imageUrl));
|
|
118
|
+
this.uploadCache[imageUrl] = mediaId;
|
|
119
|
+
return mediaId;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await fs.unlink(tmp).catch(() => { });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 处理 HTML 中的图片:本地图片上传到微信 CDN,外部图片保留原 URL
|
|
127
|
+
* 返回处理后的 HTML 和第一张可用的 media_id(用作封面)
|
|
128
|
+
*/
|
|
129
|
+
async processImages(html) {
|
|
130
|
+
const imgPattern = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
|
|
131
|
+
let firstMediaId;
|
|
132
|
+
let replaced = html;
|
|
133
|
+
for (const match of [...html.matchAll(imgPattern)]) {
|
|
134
|
+
const original = match[0];
|
|
135
|
+
const src = match[1];
|
|
136
|
+
// 跳过微信已有域名
|
|
137
|
+
if (src.includes('mmbiz.qpic.cn') || src.includes('mmbiz.qlogo.cn'))
|
|
138
|
+
continue;
|
|
139
|
+
// 外部 URL 图片保留原样(微信支持显示外部图片)
|
|
140
|
+
if (src.startsWith('http://') || src.startsWith('https://')) {
|
|
141
|
+
console.warn(`[WechatPublisher] 外部图片保留原 URL: ${src}`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const mediaId = await this.uploadImageFromUrl(src);
|
|
146
|
+
if (!firstMediaId)
|
|
147
|
+
firstMediaId = mediaId;
|
|
148
|
+
const cdnUrl = `https://mmbiz.qpic.cn/mmbiz_png/${mediaId}/0`;
|
|
149
|
+
replaced = replaced.replace(original, original.replace(src, cdnUrl));
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
console.warn(`[WechatPublisher] 上传图片失败 ${src}: ${err.message},保留原 URL`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { html: replaced, firstMediaId };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* 发布到微信公众号草稿箱
|
|
159
|
+
*/
|
|
160
|
+
async publishToDraft(options) {
|
|
161
|
+
const token = await this.getAccessToken();
|
|
162
|
+
const { html: processedHtml, firstMediaId } = await this.processImages(options.content);
|
|
163
|
+
// 上传封面图
|
|
164
|
+
let thumbMediaId;
|
|
165
|
+
try {
|
|
166
|
+
thumbMediaId = await this.uploadCover(options.cover);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
console.warn(`[WechatPublisher] 封面上传失败,遍历正文图片: ${err.message}`);
|
|
170
|
+
const imgMatches = [...options.content.matchAll(/<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi)];
|
|
171
|
+
for (const m of imgMatches) {
|
|
172
|
+
const src = m[1];
|
|
173
|
+
if (src.includes('mmbiz.qpic.cn') || src.includes('mmbiz.qlogo.cn'))
|
|
174
|
+
continue;
|
|
175
|
+
try {
|
|
176
|
+
thumbMediaId = await this.uploadImageFromUrl(src);
|
|
177
|
+
console.log(`[WechatPublisher] 用正文图片作封面: ${src}`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
catch { /* continue */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!thumbMediaId) {
|
|
184
|
+
console.warn('[WechatPublisher] 没有可用封面,将使用微信默认封面');
|
|
185
|
+
}
|
|
186
|
+
const article = {
|
|
187
|
+
title: options.title,
|
|
188
|
+
author: options.author,
|
|
189
|
+
content: processedHtml,
|
|
190
|
+
digest: options.title,
|
|
191
|
+
content_source_url: options.source_url,
|
|
192
|
+
};
|
|
193
|
+
if (thumbMediaId)
|
|
194
|
+
article.thumb_media_id = thumbMediaId;
|
|
195
|
+
const payload = { articles: [article] };
|
|
196
|
+
const res = await axios.post(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`, payload, { headers: { 'Content-Type': 'application/json' }, responseType: 'json' });
|
|
197
|
+
const data = res.data;
|
|
198
|
+
if (!data.media_id) {
|
|
199
|
+
throw new Error(`发布草稿失败: ${data.errmsg || JSON.stringify(data)}`);
|
|
200
|
+
}
|
|
201
|
+
return { media_id: data.media_id };
|
|
202
|
+
}
|
|
203
|
+
async uploadCover(coverUrl) {
|
|
204
|
+
if (!coverUrl)
|
|
205
|
+
throw new Error('封面图 URL 为空');
|
|
206
|
+
if (!coverUrl.startsWith('http'))
|
|
207
|
+
return this.uploadPermanentImageFromPath(coverUrl);
|
|
208
|
+
return this.uploadPermanentImageFromUrl(coverUrl);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微博适配器
|
|
3
|
+
* 认证方式: Cookie
|
|
4
|
+
*/
|
|
5
|
+
import { BaseAdapter } from './base-adapter.js';
|
|
6
|
+
import type { Article, AuthResult, PlatformMeta } from './interface.js';
|
|
7
|
+
export declare class WeiboAdapter extends BaseAdapter {
|
|
8
|
+
readonly meta: PlatformMeta;
|
|
9
|
+
private cookieData;
|
|
10
|
+
init(): Promise<void>;
|
|
11
|
+
protected getCookieConfigKey(): string;
|
|
12
|
+
checkAuth(): Promise<AuthResult>;
|
|
13
|
+
publish(article: Article): Promise<import('./interface.js').SyncResult>;
|
|
14
|
+
}
|