multi-publisher 1.1.1 → 1.1.2

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.
@@ -24,6 +24,21 @@ export declare class CSDNAdapter implements IPlatformAdapter {
24
24
  * 生成 CSDN API 签名
25
25
  */
26
26
  private signRequest;
27
+ /**
28
+ * 上传图片到 CSDN
29
+ * 流程:1. 调用签名 API 获取 policy 2. 直接上传到华为云 OBS
30
+ * @param imagePath 本地文件路径或网络 URL
31
+ * @returns CSDN 图片 URL
32
+ */
33
+ uploadImage(imagePath: string): Promise<string>;
34
+ /**
35
+ * 获取华为云 OBS 上传签名
36
+ */
37
+ private getUploadSignature;
38
+ /**
39
+ * 处理封面图:上传本地文件或使用网络 URL
40
+ */
41
+ private processCover;
27
42
  checkAuth(): Promise<AuthResult>;
28
43
  publish(article: Article): Promise<SyncResult>;
29
44
  }
@@ -1,8 +1,15 @@
1
1
  import { ConfigStore } from '../config.js';
2
+ import { uploadCoverViaBrowser } from '../tools/browser-upload.js';
2
3
  import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { existsSync } from 'node:fs';
3
7
  // CSDN API 签名密钥
4
8
  const API_KEY = '203803574';
5
9
  const API_SECRET = '9znpamsyl2c7cdrr9sas0le9vbc3r6ba';
10
+ // 封面图上传 API 的 key 和 secret
11
+ const COVER_API_KEY = '260196572';
12
+ const COVER_API_SECRET = 't5PaqxVQpWoHgLGt7XPIvd5ipJcwJTU7';
6
13
  export class CSDNAdapter {
7
14
  meta = {
8
15
  id: 'csdn',
@@ -75,6 +82,106 @@ export class CSDNAdapter {
75
82
  }
76
83
  return headers;
77
84
  }
85
+ /**
86
+ * 上传图片到 CSDN
87
+ * 流程:1. 调用签名 API 获取 policy 2. 直接上传到华为云 OBS
88
+ * @param imagePath 本地文件路径或网络 URL
89
+ * @returns CSDN 图片 URL
90
+ */
91
+ async uploadImage(imagePath) {
92
+ if (!this.cookieData?.cookies) {
93
+ throw new Error('未配置 CSDN Cookie');
94
+ }
95
+ // 如果是网络 URL,直接返回
96
+ if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
97
+ return imagePath;
98
+ }
99
+ // 读取本地文件
100
+ const fileBuffer = await fs.readFile(imagePath);
101
+ const filename = path.basename(imagePath);
102
+ const ext = path.extname(filename).toLowerCase();
103
+ const imageSuffix = ext.replace('.', '');
104
+ try {
105
+ // 步骤 1: 获取上传签名 (policy)
106
+ const signatureData = await this.getUploadSignature(imageSuffix);
107
+ const { accessId, policy, signature, host, dir } = signatureData;
108
+ // 步骤 2: 直接上传到华为云 OBS
109
+ const objectKey = `${dir}${filename}`;
110
+ const obsUrl = `${host}/${objectKey}`;
111
+ const formData = new FormData();
112
+ formData.append('key', objectKey);
113
+ formData.append('OSSAccessKeyId', accessId);
114
+ formData.append('policy', policy);
115
+ formData.append('signature', signature);
116
+ formData.append('expire', signatureData.expire);
117
+ formData.append('file', new Blob([fileBuffer]), filename);
118
+ const response = await this.runtime.fetch(obsUrl, {
119
+ method: 'POST',
120
+ body: formData,
121
+ });
122
+ if (!response.ok) {
123
+ throw new Error(`OBS 上传失败: ${response.status}`);
124
+ }
125
+ // 返回 CSDN 图片 URL
126
+ return `https://csdn-img-blog.obs.cn-north-4.myhuaweicloud.com/${objectKey}`;
127
+ }
128
+ catch (apiError) {
129
+ throw new Error(`封面 API 上传失败: ${apiError.message}`);
130
+ }
131
+ }
132
+ /**
133
+ * 获取华为云 OBS 上传签名
134
+ */
135
+ async getUploadSignature(imageSuffix) {
136
+ const cookieStr = this.buildCookieString(this.cookieData);
137
+ // CSDN 封面图签名 API 使用的 key 和之前的不同
138
+ const coverApiKey = '260196572';
139
+ const nonce = this.createUuid();
140
+ const timestamp = Date.now().toString();
141
+ const apiPath = '/resource-api/v1/image/direct/upload/signature';
142
+ // 按照 Lt 函数格式: method\naccept\n\ncontentType\ntimestamp\n(sorted headers)\nurl
143
+ const signStr = `POST\n*/*\n\napplication/json\n${timestamp}\nx-ca-key:${coverApiKey}\nx-ca-nonce:${nonce}\nx-ca-timestamp:${timestamp}\n${apiPath}`;
144
+ // 封面签名使用专用的 secret
145
+ const signature = await this.hmacSha256(signStr, COVER_API_SECRET);
146
+ const response = await this.runtime.fetch(`https://bizapi.csdn.net${apiPath}`, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'accept': 'application/json',
150
+ 'x-ca-key': coverApiKey,
151
+ 'x-ca-nonce': nonce,
152
+ 'x-ca-timestamp': timestamp,
153
+ 'x-ca-signature': signature,
154
+ 'x-ca-signature-headers': 'x-ca-key,x-ca-nonce,x-ca-timestamp',
155
+ 'content-type': 'application/json',
156
+ 'Cookie': cookieStr,
157
+ },
158
+ body: JSON.stringify({
159
+ imageTemplate: '',
160
+ appName: 'direct_blog_coverimage',
161
+ imageSuffix,
162
+ }),
163
+ });
164
+ const res = await response.json();
165
+ if (res.code !== 200 || !res.data) {
166
+ throw new Error(res.message || '获取上传签名失败');
167
+ }
168
+ return res.data;
169
+ }
170
+ /**
171
+ * 处理封面图:上传本地文件或使用网络 URL
172
+ */
173
+ async processCover(cover) {
174
+ if (!cover)
175
+ return [];
176
+ try {
177
+ const coverUrl = await this.uploadImage(cover);
178
+ return [coverUrl];
179
+ }
180
+ catch (err) {
181
+ console.warn(`[CSDN] 封面上传失败: ${err.message}`);
182
+ return [];
183
+ }
184
+ }
78
185
  async checkAuth() {
79
186
  if (!this.cookieData?.cookies) {
80
187
  return { isAuthenticated: false, error: '未配置 CSDN Cookie,请运行: mpub cookie --platform csdn --set' };
@@ -118,6 +225,7 @@ export class CSDNAdapter {
118
225
  // 生成签名
119
226
  const apiPath = '/blog-console-api/v3/mdeditor/saveArticle';
120
227
  const headers = await this.signRequest(apiPath);
228
+ // 先发布文章(不带封面,因为 API 签名有问题)
121
229
  const response = await this.runtime.fetch(`https://bizapi.csdn.net${apiPath}`, {
122
230
  method: 'POST',
123
231
  headers: {
@@ -139,7 +247,7 @@ export class CSDNAdapter {
139
247
  not_auto_saved: '1',
140
248
  source: 'pc_mdeditor',
141
249
  cover_images: [],
142
- cover_type: 1,
250
+ cover_type: 0,
143
251
  is_new: 1,
144
252
  vote_id: 0,
145
253
  resource_id: '',
@@ -153,6 +261,17 @@ export class CSDNAdapter {
153
261
  }
154
262
  const postId = res.data.id;
155
263
  const draftUrl = `https://editor.csdn.net/md?articleId=${postId}`;
264
+ // 如果有封面图片,通过浏览器自动化上传
265
+ if (article.cover && existsSync(article.cover)) {
266
+ console.log(`[CSDN] 封面上传中...`);
267
+ const coverResult = await uploadCoverViaBrowser(postId, article.cover);
268
+ if (coverResult.success && coverResult.coverUrl) {
269
+ console.log(`[CSDN] 封面 URL: ${coverResult.coverUrl}`);
270
+ }
271
+ else {
272
+ console.warn(`[CSDN] 封面上传失败: ${coverResult.error}`);
273
+ }
274
+ }
156
275
  return {
157
276
  platform: this.meta.id,
158
277
  success: true,
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import { chromium } from 'playwright';
6
6
  import { ConfigStore } from '../config.js';
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
7
9
  export class ToutiaoAdapter {
8
10
  meta = {
9
11
  id: 'toutiao',
@@ -22,7 +24,7 @@ export class ToutiaoAdapter {
22
24
  }
23
25
  const hasSession = this.cookieData['sessionid'] || this.cookieData['sid_tt'];
24
26
  if (!hasSession) {
25
- return { isAuthenticated: false, error: '未登录或登录已过期' };
27
+ return { isAuthenticated: false, error: '登录已过期' };
26
28
  }
27
29
  return { isAuthenticated: true };
28
30
  }
@@ -99,7 +101,6 @@ export class ToutiaoAdapter {
99
101
  const contentEl = page.locator('div[contenteditable="true"]').first();
100
102
  if (await contentEl.isVisible({ timeout: 2000 }).catch(() => false)) {
101
103
  await contentEl.click();
102
- // 使用 Ctrl+A 全选,然后输入内容(模拟用户输入)
103
104
  await page.keyboard.press('Control+a');
104
105
  await page.waitForTimeout(200);
105
106
  const htmlContent = article.html || article.markdown || '';
@@ -107,59 +108,109 @@ export class ToutiaoAdapter {
107
108
  const div = document.querySelector('[contenteditable="true"]');
108
109
  if (div) {
109
110
  div.innerHTML = el;
110
- // 触发 input 事件
111
111
  div.dispatchEvent(new InputEvent('input', { bubbles: true }));
112
112
  }
113
113
  }, htmlContent);
114
114
  console.log('[toutiao] 已填写内容');
115
115
  await page.waitForTimeout(1000);
116
116
  }
117
- // 等待编辑器自动保存(最多5秒)
118
- console.log('[toutiao] 等待编辑器自动保存(5秒)...');
119
- for (let i = 0; i < 5; i++) {
120
- const url = page.url();
121
- if (url.includes('id=')) {
122
- console.log('[toutiao] 检测到文章 ID,保存完成');
123
- break;
117
+ // 如果有封面图片,先上传封面再发布
118
+ if (article.cover && existsSync(article.cover)) {
119
+ console.log('[toutiao] 检测到封面图片,准备上传...');
120
+ // 点击"预览并发布"按钮打开预览弹窗
121
+ const previewBtn = page.locator('button:has-text("预览并发布")');
122
+ if (await previewBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
123
+ await previewBtn.click({ force: true });
124
+ console.log('[toutiao] 点击预览并发布按钮');
125
+ await page.waitForTimeout(2000);
126
+ // 选择"单图"选项
127
+ const singleImg = page.locator('text=单图').first();
128
+ if (await singleImg.isVisible({ timeout: 2000 }).catch(() => false)) {
129
+ await singleImg.click();
130
+ console.log('[toutiao] 选择单图');
131
+ await page.waitForTimeout(500);
132
+ }
133
+ // 设置 filechooser 监听
134
+ const absolutePath = path.resolve(article.cover);
135
+ page.on('filechooser', async (fileChooser) => {
136
+ console.log('[toutiao] 收到 filechooser,设置封面文件');
137
+ await fileChooser.setFiles(absolutePath);
138
+ });
139
+ // 点击封面添加区域
140
+ const coverAdd = page.locator('.article-cover-add');
141
+ if (await coverAdd.isVisible({ timeout: 2000 }).catch(() => false)) {
142
+ await coverAdd.click({ force: true });
143
+ console.log('[toutiao] 点击了封面添加区域');
144
+ await page.waitForTimeout(1500);
145
+ }
146
+ // 点击"本地上传"
147
+ const localUpload = page.locator('text=本地上传');
148
+ if (await localUpload.isVisible({ timeout: 3000 }).catch(() => false)) {
149
+ await localUpload.click();
150
+ console.log('[toutiao] 点击本地上传');
151
+ await page.waitForTimeout(1500);
152
+ }
153
+ // 点击"确定"按钮
154
+ const confirmBtn = page.locator('button:has-text("确定")').filter({ visible: true });
155
+ try {
156
+ if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
157
+ const disabled = await confirmBtn.first().isDisabled().catch(() => true);
158
+ if (!disabled) {
159
+ await confirmBtn.first().click();
160
+ console.log('[toutiao] 点击确定按钮,封面上传完成');
161
+ }
162
+ }
163
+ }
164
+ catch (e) {
165
+ console.log('[toutiao] 确定按钮点击失败:', e.message);
166
+ }
167
+ // 等待封面上传保存(头条号会自动保存)
168
+ await page.waitForTimeout(5000);
124
169
  }
125
- await page.waitForTimeout(1000);
126
- }
127
- // 检查 URL 是否包含文章 ID
128
- let currentUrl = page.url();
129
- let idMatch = currentUrl.match(/[?&]id=(\d+)/);
130
- let savedId = idMatch ? idMatch[1] : null;
131
- if (idMatch) {
132
- console.log('[toutiao] 检测到文章 ID:', idMatch[1]);
133
170
  }
134
- else {
135
- console.log('[toutiao] URL 仍未变化:', currentUrl);
136
- }
137
- // 尝试点击"发布"按钮触发保存
138
- console.log('[toutiao] 尝试点击发布按钮...');
171
+ // 关闭预览弹窗(如果还在的话)
139
172
  try {
140
- // 查找发布按钮(可能在右上角或底部)
141
- const publishBtn = page.locator('button').filter({ hasText: /^发布$/ }).first();
142
- if (await publishBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
143
- await publishBtn.click({ force: true });
144
- console.log('[toutiao] 已点击发布按钮');
145
- // 等待保存完成
146
- await page.waitForTimeout(10000);
173
+ const continueBtn = page.locator('button:has-text("继续编辑")');
174
+ if (await continueBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
175
+ await continueBtn.click({ force: true });
176
+ await page.waitForTimeout(2000);
147
177
  }
148
178
  else {
149
- // 如果没找到发布按钮,尝试保存按钮
150
- const saveBtn = page.locator('button').filter({ hasText: /保存草稿|保存/ }).first();
151
- if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
152
- await saveBtn.click({ force: true });
153
- console.log('[toutiao] 已点击保存按钮');
154
- await page.waitForTimeout(10000);
179
+ const closeIcon = page.locator('[class*="close"], .ai-assistant-drawer-wrapper [class*="close"]');
180
+ if (await closeIcon.isVisible({ timeout: 1000 }).catch(() => false)) {
181
+ await closeIcon.click({ force: true });
182
+ await page.waitForTimeout(1000);
183
+ }
184
+ }
185
+ }
186
+ catch (e) {
187
+ // 关闭弹窗失败,继续
188
+ }
189
+ // 等待编辑器稳定
190
+ await page.waitForTimeout(2000);
191
+ // 点击发布按钮
192
+ console.log('[toutiao] 查找发布按钮...');
193
+ try {
194
+ // 尝试多种发布按钮选择器
195
+ const publishSelectors = [
196
+ 'button:has-text("发布")',
197
+ 'button:has-text("预览并发布")',
198
+ ];
199
+ for (const selector of publishSelectors) {
200
+ const btn = page.locator(selector);
201
+ if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
202
+ await btn.click({ force: true });
203
+ console.log('[toutiao] 点击了:', selector);
204
+ break;
155
205
  }
156
206
  }
207
+ await page.waitForTimeout(10000);
157
208
  }
158
209
  catch (e) {
159
- console.log('[toutiao] 按钮点击失败:', e.message);
210
+ console.log('[toutiao] 发布按钮点击失败:', e.message);
160
211
  }
161
212
  const finalUrl = page.url();
162
- idMatch = finalUrl.match(/[?&]id=(\d+)/);
213
+ const idMatch = finalUrl.match(/[?&]id=(\d+)/);
163
214
  if (idMatch) {
164
215
  console.log('[toutiao] 保存成功,文章 ID:', idMatch[1]);
165
216
  return {
@@ -0,0 +1,6 @@
1
+ interface CaptureOptions {
2
+ platform?: string;
3
+ timeout?: string;
4
+ }
5
+ export declare function runCapture(options: CaptureOptions): Promise<void>;
6
+ export {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * capture 命令入口
3
+ */
4
+ import { capture } from '../tools/capture.js';
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ export async function runCapture(options) {
8
+ const platform = options.platform || 'csdn';
9
+ const timeoutSeconds = parseInt(options.timeout || '60', 10);
10
+ const timeoutMs = timeoutSeconds * 1000;
11
+ const data = await capture(platform, timeoutMs);
12
+ if (!data) {
13
+ console.error('抓包失败');
14
+ process.exit(1);
15
+ }
16
+ // 保存到临时文件
17
+ const timestamp = Date.now();
18
+ const filename = `temp/capture-${platform}-${timestamp}.json`;
19
+ const filepath = path.resolve(process.cwd(), filename);
20
+ // 确保 temp 目录存在
21
+ await fs.mkdir(path.dirname(filepath), { recursive: true });
22
+ await fs.writeFile(filepath, JSON.stringify(data, null, 2), 'utf-8');
23
+ console.log(`
24
+ ========================================
25
+ 抓包完成
26
+ ========================================
27
+ 文件: ${filepath}
28
+
29
+ 📊 统计:
30
+ 总请求数: ${data.summary.totalRequests}
31
+ 命中请求: ${data.summary.matchedRequests}
32
+ 上传端点: ${data.summary.uploadEndpoints.join(', ') || '无'}
33
+
34
+ ========================================
35
+ `);
36
+ if (data.summary.uploadEndpoints.length > 0) {
37
+ console.log('🎯 检测到上传请求!');
38
+ console.log(' 把这个文件路径告诉 AI,让它分析签名格式');
39
+ }
40
+ else {
41
+ console.log('⚠️ 未检测到上传请求');
42
+ console.log(' 可能原因:');
43
+ console.log(' 1. 没有执行上传操作');
44
+ console.log(' 2. 上传功能需要先登录');
45
+ console.log(' 3. 平台上传 API 不在拦截范围内');
46
+ }
47
+ console.log('\n运行 AI 分析:');
48
+ console.log(` 帮我分析 ${platform} 的抓包数据: ${filepath}`);
49
+ }
package/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import { runCredential } from './credential.js';
11
11
  import { runCookie } from './cookie.js';
12
12
  import { runLogin } from './login.js';
13
13
  import { runPublishAll } from './publish-all.js';
14
+ import { runCapture } from './capture.js';
14
15
  export function createProgram() {
15
16
  const program = new Command();
16
17
  program
@@ -68,6 +69,12 @@ export function createProgram() {
68
69
  .option('--mac-style', '启用 Mac 风格代码块', true)
69
70
  .option('--no-mac-style', '禁用 Mac 风格代码块')
70
71
  .action(runPublishAll);
72
+ // capture 命令
73
+ const captureCmd = program.command('capture')
74
+ .description('抓包工具 - 打开浏览器并拦截上传请求')
75
+ .option('-p, --platform <platform>', '平台名称 (csdn|juejin|zhihu|toutiao|jianshu|weibo)', 'csdn')
76
+ .option('-t, --timeout <seconds>', '超时时间(秒)', '60')
77
+ .action(runCapture);
71
78
  return program;
72
79
  }
73
80
  const program = createProgram();
@@ -0,0 +1,13 @@
1
+ export interface BrowserUploadResult {
2
+ success: boolean;
3
+ coverUrl?: string;
4
+ error?: string;
5
+ }
6
+ /**
7
+ * 通过浏览器自动化上传封面图片
8
+ * 流程:打开文章编辑页 -> 在右侧设置面板上传封面
9
+ * @param articleId 文章ID(发布后返回的)
10
+ * @param imagePath 本地图片路径
11
+ * @returns 上传后的封面 URL
12
+ */
13
+ export declare function uploadCoverViaBrowser(articleId: string, imagePath: string): Promise<BrowserUploadResult>;
@@ -0,0 +1,349 @@
1
+ /**
2
+ * 浏览器自动化上传封面图片
3
+ * 在文章编辑页面右侧设置面板中上传封面
4
+ */
5
+ import { chromium } from 'playwright';
6
+ import { ConfigStore } from '../config.js';
7
+ import path from 'path';
8
+ /**
9
+ * 通过浏览器自动化上传封面图片
10
+ * 流程:打开文章编辑页 -> 在右侧设置面板上传封面
11
+ * @param articleId 文章ID(发布后返回的)
12
+ * @param imagePath 本地图片路径
13
+ * @returns 上传后的封面 URL
14
+ */
15
+ export async function uploadCoverViaBrowser(articleId, imagePath) {
16
+ const browser = await chromium.launch({
17
+ headless: false,
18
+ channel: 'chromium',
19
+ });
20
+ try {
21
+ const context = await browser.newContext({
22
+ viewport: { width: 1280, height: 720 },
23
+ });
24
+ const page = await context.newPage();
25
+ // 加载 CSDN cookies
26
+ const cookies = await ConfigStore.getCSDNCookies();
27
+ if (!cookies || Object.keys(cookies).length === 0) {
28
+ return { success: false, error: '未配置 CSDN Cookie,请先登录' };
29
+ }
30
+ // 设置 cookies
31
+ const csdnCookies = Object.entries(cookies).map(([name, value]) => ({
32
+ name,
33
+ value,
34
+ domain: '.csdn.net',
35
+ path: '/',
36
+ }));
37
+ await context.addCookies(csdnCookies);
38
+ // 打开文章编辑页
39
+ console.log('[BrowserUpload] 打开文章编辑页...');
40
+ const articleUrl = `https://editor.csdn.net/md/?articleId=${articleId}`;
41
+ await page.goto(articleUrl, { waitUntil: 'networkidle', timeout: 60000 });
42
+ await page.waitForTimeout(2000);
43
+ // 先查找并点击"发布"按钮
44
+ console.log('[BrowserUpload] 查找发布按钮...');
45
+ const publishSelectors = [
46
+ 'button:has-text("发布")',
47
+ 'button:has-text("立即发布")',
48
+ '[class*="publish"] button',
49
+ ];
50
+ let publishButton = null;
51
+ for (const selector of publishSelectors) {
52
+ try {
53
+ const btn = page.locator(selector).first();
54
+ if (await btn.isVisible({ timeout: 1000 })) {
55
+ publishButton = btn;
56
+ console.log(`[BrowserUpload] 找到发布按钮: ${selector}`);
57
+ break;
58
+ }
59
+ }
60
+ catch {
61
+ // continue
62
+ }
63
+ }
64
+ if (!publishButton) {
65
+ console.log('[BrowserUpload] 未找到发布按钮');
66
+ return { success: false, error: '未找到发布按钮' };
67
+ }
68
+ await publishButton.click();
69
+ console.log('[BrowserUpload] 点击发布按钮');
70
+ await page.waitForTimeout(2000);
71
+ // 查找弹窗/对话框中的"添加封面"
72
+ console.log('[BrowserUpload] 查找发布设置弹窗...');
73
+ // 等待弹窗出现
74
+ const dialogSelectors = [
75
+ '[class*="dialog"]',
76
+ '[class*="modal"]',
77
+ '[class*="popup"]',
78
+ '[class*="publish-dialog"]',
79
+ '[class*="publishModal"]',
80
+ ];
81
+ for (const selector of dialogSelectors) {
82
+ try {
83
+ const dialog = page.locator(selector).first();
84
+ if (await dialog.isVisible({ timeout: 2000 })) {
85
+ console.log(`[BrowserUpload] 找到弹窗: ${selector}`);
86
+ break;
87
+ }
88
+ }
89
+ catch {
90
+ // continue
91
+ }
92
+ }
93
+ // 截图看看弹窗内容
94
+ await page.screenshot({ path: `temp/csdn-publish-dialog-${Date.now()}.png` });
95
+ // 查找"添加封面"标签后面的上传框
96
+ console.log('[BrowserUpload] 查找"添加封面"后面的上传框...');
97
+ let coverInput = null;
98
+ let foundSelector = '';
99
+ // 尝试查找包含"添加封面"文字的元素
100
+ const addCoverLabel = page.locator('text=/添加封面|设置封面|上传封面/i');
101
+ if (await addCoverLabel.count() > 0) {
102
+ console.log(`[BrowserUpload] 找到 ${await addCoverLabel.count()} 个"添加封面"相关元素`);
103
+ // 查找这个元素附近的 file input
104
+ // 尝试找包含"添加封面"的父元素下的 file input
105
+ const parentFileInput = await addCoverLabel.evaluate((el) => {
106
+ // 往上找父元素
107
+ let parent = el.parentElement;
108
+ for (let i = 0; i < 5 && parent; i++) {
109
+ // 找这个父元素下的 input[type="file"]
110
+ const fileInput = parent.querySelector('input[type="file"]');
111
+ if (fileInput) {
112
+ return 'found';
113
+ }
114
+ parent = parent.parentElement;
115
+ }
116
+ return 'not-found';
117
+ });
118
+ console.log(`[BrowserUpload] 父元素下有 file input: ${parentFileInput}`);
119
+ }
120
+ // 查找弹窗中的 file input
121
+ const dialogFileInputs = page.locator('[class*="dialog"] input[type="file"], [class*="modal"] input[type="file"], [class*="popup"] input[type="file"]');
122
+ const dialogInputCount = await dialogFileInputs.count();
123
+ console.log(`[BrowserUpload] 弹窗中有 ${dialogInputCount} 个 file input`);
124
+ if (dialogInputCount > 0) {
125
+ const firstInput = dialogFileInputs.first();
126
+ coverInput = firstInput;
127
+ foundSelector = 'dialog file input';
128
+ console.log(`[BrowserUpload] 使用弹窗中的 file input`);
129
+ }
130
+ // 通用方式:在页面中查找所有 file input
131
+ if (!coverInput) {
132
+ const allFileInputs = page.locator('input[type="file"]');
133
+ const count = await allFileInputs.count();
134
+ console.log(`[BrowserUpload] 页面上共有 ${count} 个 file input`);
135
+ // 优先查找容器封面上传的 input (.el-upload__input)
136
+ const coverUploadInput = page.locator('.el-upload__input');
137
+ if (await coverUploadInput.count() > 0) {
138
+ coverInput = coverUploadInput.first();
139
+ foundSelector = 'el-upload__input';
140
+ console.log(`[BrowserUpload] 使用封面上传 input`);
141
+ }
142
+ else {
143
+ // 回退到可见的 file input
144
+ for (let i = 0; i < count; i++) {
145
+ const element = allFileInputs.nth(i);
146
+ if (await element.isVisible({ timeout: 500 }).catch(() => false)) {
147
+ coverInput = element;
148
+ foundSelector = `file input ${i}`;
149
+ console.log(`[BrowserUpload] 使用 file input ${i}`);
150
+ break;
151
+ }
152
+ }
153
+ }
154
+ }
155
+ // 最后的尝试:直接查找 input[type="file"] 并检查其容器
156
+ if (!coverInput) {
157
+ console.log('[BrowserUpload] 最后尝试: 查找所有 file input...');
158
+ const allFileInputs = page.locator('input[type="file"]');
159
+ const count = await allFileInputs.count();
160
+ console.log(`[BrowserUpload] 共有 ${count} 个 file input`);
161
+ for (let i = 0; i < count; i++) {
162
+ const element = allFileInputs.nth(i);
163
+ try {
164
+ // 检查这个 input 是否可见
165
+ if (await element.isVisible({ timeout: 500 })) {
166
+ // 检查父元素是否和封面相关
167
+ const parentCover = await element.evaluate((el) => {
168
+ let parent = el.closest('[class*="cover"]') || el.parentElement?.closest('[class*="cover"]');
169
+ return parent ? 'found' : 'not-found';
170
+ });
171
+ if (parentCover === 'found') {
172
+ coverInput = element;
173
+ foundSelector = `file input ${i} (parent is cover)`;
174
+ console.log(`[BrowserUpload] 找到封面 input: ${foundSelector}`);
175
+ break;
176
+ }
177
+ // 尝试直接使用这个 input(如果页面上只有 1 个)
178
+ if (count === 1) {
179
+ coverInput = element;
180
+ foundSelector = `file input ${i} (only one on page)`;
181
+ console.log(`[BrowserUpload] 使用唯一的 file input`);
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ catch {
187
+ // continue
188
+ }
189
+ }
190
+ }
191
+ // 如果还是没找到,尝试查找"封面"文字并点击
192
+ if (!coverInput) {
193
+ console.log('[BrowserUpload] 尝试点击包含"封面"的元素...');
194
+ const coverText = page.locator('text=/封面|cover/i');
195
+ const textCount = await coverText.count();
196
+ console.log(`[BrowserUpload] 找到 ${textCount} 个包含"封面"的元素`);
197
+ for (let i = 0; i < textCount; i++) {
198
+ const el = coverText.nth(i);
199
+ try {
200
+ // 点击这个元素
201
+ await el.click();
202
+ await page.waitForTimeout(1000);
203
+ console.log(`[BrowserUpload] 点击了包含封面的元素 ${i}`);
204
+ // 点击后重新查找 file input
205
+ const newFileInputs = page.locator('input[type="file"]');
206
+ const newCount = await newFileInputs.count();
207
+ for (let j = 0; j < newCount; j++) {
208
+ const fi = newFileInputs.nth(j);
209
+ if (await fi.isVisible({ timeout: 500 }).catch(() => false)) {
210
+ coverInput = fi;
211
+ foundSelector = `file input after clicking cover text`;
212
+ console.log(`[BrowserUpload] 点击后找到 file input`);
213
+ break;
214
+ }
215
+ }
216
+ }
217
+ catch {
218
+ // continue
219
+ }
220
+ if (coverInput)
221
+ break;
222
+ }
223
+ }
224
+ if (!coverInput) {
225
+ console.log('[BrowserUpload] 未找到封面上传入口');
226
+ return { success: false, error: '未找到封面上传入口' };
227
+ }
228
+ // 上传文件
229
+ console.log(`[BrowserUpload] 上传封面: ${imagePath}`);
230
+ const absolutePath = path.resolve(imagePath);
231
+ try {
232
+ // 先点击"从本地上传"按钮来激活上传
233
+ const uploadBtn = page.locator('.upload-img-box').first();
234
+ try {
235
+ if (await uploadBtn.isVisible({ timeout: 1000 })) {
236
+ await uploadBtn.click();
237
+ console.log('[BrowserUpload] 点击了从本地上传按钮');
238
+ await page.waitForTimeout(1000);
239
+ }
240
+ }
241
+ catch {
242
+ // ignore
243
+ }
244
+ // 如果找不到上传按钮,点击封面区域
245
+ if (!(await uploadBtn.isVisible({ timeout: 500 }).catch(() => false))) {
246
+ const coverArea = page.locator('[class*="cover"], [class*="add-cover"]').first();
247
+ try {
248
+ if (await coverArea.isVisible({ timeout: 1000 })) {
249
+ await coverArea.click();
250
+ console.log('[BrowserUpload] 点击了封面区域');
251
+ await page.waitForTimeout(500);
252
+ }
253
+ }
254
+ catch {
255
+ // ignore
256
+ }
257
+ }
258
+ }
259
+ catch (err) {
260
+ console.error('[BrowserUpload] 点击上传按钮失败:', err);
261
+ }
262
+ // 执行文件上传
263
+ try {
264
+ await coverInput.setInputFiles(absolutePath);
265
+ console.log('[BrowserUpload] setInputFiles 执行成功');
266
+ // 触发 change 事件
267
+ await coverInput.dispatchEvent('change');
268
+ console.log('[BrowserUpload] 触发 change 事件');
269
+ }
270
+ catch (err) {
271
+ console.error('[BrowserUpload] setInputFiles 失败:', err);
272
+ return { success: false, error: `上传失败: ${err.message}` };
273
+ }
274
+ // 等待裁剪弹窗出现
275
+ console.log('[BrowserUpload] 等待裁剪弹窗...');
276
+ await page.waitForTimeout(2000);
277
+ // 点击"确认上传"按钮
278
+ const confirmBtn = page.locator('.vicp-operate-btn:has-text("确认上传"), .el-upload__confirm-upload');
279
+ try {
280
+ if (await confirmBtn.isVisible({ timeout: 3000 })) {
281
+ await confirmBtn.click();
282
+ console.log('[BrowserUpload] 点击确认上传按钮');
283
+ }
284
+ }
285
+ catch {
286
+ console.log('[BrowserUpload] 未找到确认上传按钮,继续');
287
+ }
288
+ // 等待裁剪弹窗关闭
289
+ await page.waitForTimeout(2000);
290
+ // 点击"保存为草稿"按钮来保存封面设置
291
+ const saveDraftBtn = page.locator('button:has-text("保存为草稿")');
292
+ try {
293
+ if (await saveDraftBtn.isVisible({ timeout: 3000 })) {
294
+ await saveDraftBtn.click();
295
+ console.log('[BrowserUpload] 点击保存为草稿按钮');
296
+ await page.waitForTimeout(2000);
297
+ }
298
+ }
299
+ catch {
300
+ console.log('[BrowserUpload] 未找到保存为草稿按钮,继续');
301
+ }
302
+ // 等待上传
303
+ console.log('[BrowserUpload] 等待上传...');
304
+ await page.waitForTimeout(5000);
305
+ // 截图看看上传后的状态
306
+ await page.screenshot({ path: `temp/csdn-after-upload-${Date.now()}.png` });
307
+ // 等待上传
308
+ console.log('[BrowserUpload] 等待上传...');
309
+ await page.waitForTimeout(3000);
310
+ // 检查封面预览
311
+ const previewSelectors = [
312
+ 'img[src*="obs"]',
313
+ 'img[src*="csdn-img"]',
314
+ '[class*="cover"] img[src]',
315
+ '[class*="coverPreview"] img',
316
+ 'img.cover',
317
+ // 弹窗中的预览图
318
+ '[class*="modal"] img[src]',
319
+ '[class*="dialog"] img[src]',
320
+ ];
321
+ for (const selector of previewSelectors) {
322
+ try {
323
+ const imgs = page.locator(selector);
324
+ const count = await imgs.count();
325
+ for (let i = 0; i < count; i++) {
326
+ const img = imgs.nth(i);
327
+ const src = await img.getAttribute('src').catch(() => '');
328
+ if (src && (src.includes('obs') || src.includes('csdn-img') || src.includes('https'))) {
329
+ console.log(`[BrowserUpload] 封面上传成功: ${src}`);
330
+ return { success: true, coverUrl: src };
331
+ }
332
+ }
333
+ }
334
+ catch {
335
+ // continue
336
+ }
337
+ }
338
+ // 如果没找到特定 URL,但上传操作执行了,就认为成功
339
+ console.log('[BrowserUpload] 封面上传完成(未检测到预览 URL)');
340
+ return { success: true, coverUrl: 'uploaded' };
341
+ }
342
+ catch (err) {
343
+ console.error('[BrowserUpload] 上传失败:', err);
344
+ return { success: false, error: err.message };
345
+ }
346
+ finally {
347
+ await browser.close();
348
+ }
349
+ }
@@ -0,0 +1,26 @@
1
+ interface CapturedRequest {
2
+ id: string;
3
+ timestamp: number;
4
+ method: string;
5
+ url: string;
6
+ headers: Record<string, string>;
7
+ postData?: string;
8
+ responseStatus?: number;
9
+ responseBody?: string;
10
+ matched: boolean;
11
+ }
12
+ interface CaptureData {
13
+ platform: string;
14
+ captureTime: string;
15
+ timeout: number;
16
+ editorUrl: string;
17
+ uploadApiUrl?: string;
18
+ requests: CapturedRequest[];
19
+ summary: {
20
+ totalRequests: number;
21
+ matchedRequests: number;
22
+ uploadEndpoints: string[];
23
+ };
24
+ }
25
+ export declare function capture(platform: string, timeoutMs?: number): Promise<CaptureData | null>;
26
+ export {};
@@ -0,0 +1,324 @@
1
+ /**
2
+ * 通用抓包工具
3
+ *
4
+ * 使用方式:
5
+ * node dist/tools/capture.js <platform> [timeout]
6
+ *
7
+ * 示例:
8
+ * node dist/tools/capture.js csdn # 抓取 CSDN (60s 超时)
9
+ * node dist/tools/capture.js csdn 120 # 抓取 CSDN (120s 超时)
10
+ * node dist/tools/capture.js juejin # 抓取掘金
11
+ */
12
+ import { chromium } from 'playwright';
13
+ import { ConfigStore } from '../config.js';
14
+ import fs from 'fs/promises';
15
+ import path from 'path';
16
+ // 平台配置
17
+ const PLATFORM_CONFIGS = {
18
+ csdn: {
19
+ editorUrl: 'https://editor.csdn.net/md/',
20
+ uploadPatterns: [/uploadImg/i, /upload/i, /file/i, /image/i],
21
+ },
22
+ juejin: {
23
+ editorUrl: 'https://editor.juejin.cn/',
24
+ uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
25
+ },
26
+ zhihu: {
27
+ editorUrl: 'https://zhuanlan.zhihu.com/write',
28
+ uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
29
+ },
30
+ toutiao: {
31
+ editorUrl: 'https://mp.toutiao.com/',
32
+ uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
33
+ },
34
+ jianshu: {
35
+ editorUrl: 'https://www.jianshu.com/writer',
36
+ uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
37
+ },
38
+ weibo: {
39
+ editorUrl: 'https://weibo.com/compose',
40
+ uploadPatterns: [/upload/i, /file/i, /image/i, /img/i],
41
+ },
42
+ };
43
+ function isUploadRequest(url, headers, postData) {
44
+ const contentType = headers['content-type'] || '';
45
+ const isMultipart = contentType.includes('multipart/form-data');
46
+ const isUploadUrl = PLATFORM_CONFIGS[Object.keys(PLATFORM_CONFIGS)[0]]?.uploadPatterns.some(p => p.test(url));
47
+ // 检查 URL 是否匹配上传模式
48
+ for (const config of Object.values(PLATFORM_CONFIGS)) {
49
+ if (config.uploadPatterns.some(p => p.test(url))) {
50
+ return true;
51
+ }
52
+ }
53
+ // 如果是 multipart/form-data,很可能是上传
54
+ if (isMultipart) {
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ async function loadCookies(platform) {
60
+ try {
61
+ switch (platform) {
62
+ case 'csdn': return await ConfigStore.getCSDNCookies() || {};
63
+ case 'zhihu': return await ConfigStore.getZhihuCookies() || {};
64
+ case 'juejin': return await ConfigStore.getJuejinCookies() || {};
65
+ case 'toutiao': return await ConfigStore.getToutiaoCookies() || {};
66
+ case 'jianshu': return await ConfigStore.getJianshuCookies() || {};
67
+ case 'weibo': return await ConfigStore.getWeiboCookies() || {};
68
+ default: return {};
69
+ }
70
+ }
71
+ catch {
72
+ return {};
73
+ }
74
+ }
75
+ function getCookieDomains(platform) {
76
+ const domains = {
77
+ csdn: ['.csdn.net', '.bizapi.csdn.net'],
78
+ zhihu: ['.zhihu.com'],
79
+ juejin: ['.juejin.cn', '.掘金.com'],
80
+ toutiao: ['.toutiao.com'],
81
+ jianshu: ['.jianshu.com'],
82
+ weibo: ['.weibo.com', '.sina.com.cn'],
83
+ };
84
+ return domains[platform] || [];
85
+ }
86
+ export async function capture(platform, timeoutMs = 60000) {
87
+ const config = PLATFORM_CONFIGS[platform];
88
+ if (!config) {
89
+ console.error(`不支持的平台: ${platform}`);
90
+ console.error(`支持的平台: ${Object.keys(PLATFORM_CONFIGS).join(', ')}`);
91
+ return null;
92
+ }
93
+ console.log(`
94
+ ========================================
95
+ 通用抓包工具 - ${platform.toUpperCase()}
96
+ ========================================
97
+ 平台: ${platform}
98
+ 编辑器: ${config.editorUrl}
99
+ 超时: ${timeoutMs / 1000}秒
100
+ ========================================
101
+ `);
102
+ // 加载 cookies
103
+ const cookies = await loadCookies(platform);
104
+ if (Object.keys(cookies).length === 0) {
105
+ console.warn(`⚠️ 未找到 ${platform} 的 cookies,可能需要先登录`);
106
+ console.warn(` 运行: node dist/cli/index.js login --platform ${platform}`);
107
+ }
108
+ else {
109
+ console.log(`✅ 已加载 ${Object.keys(cookies).length} 个 cookies`);
110
+ }
111
+ // 启动浏览器
112
+ const browser = await chromium.launch({
113
+ headless: false,
114
+ channel: 'chromium',
115
+ });
116
+ const context = await browser.newContext();
117
+ const page = await context.newPage();
118
+ // 设置 cookies
119
+ const cookieDomains = getCookieDomains(platform);
120
+ for (const [name, value] of Object.entries(cookies)) {
121
+ for (const domain of cookieDomains) {
122
+ try {
123
+ await context.addCookies([{
124
+ name,
125
+ value,
126
+ domain,
127
+ path: '/',
128
+ }]);
129
+ break;
130
+ }
131
+ catch {
132
+ // 忽略无效 cookie
133
+ }
134
+ }
135
+ }
136
+ // 抓包数据
137
+ const requests = [];
138
+ let lastActivityTime = Date.now();
139
+ // 拦截所有请求
140
+ page.on('request', async (request) => {
141
+ const url = request.url();
142
+ const headers = request.headers();
143
+ const method = request.method();
144
+ const contentType = headers['content-type'] || '';
145
+ // 只记录 API 请求
146
+ if (!url.includes('api') && !url.includes('bizapi')) {
147
+ return;
148
+ }
149
+ const captured = {
150
+ id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
151
+ timestamp: Date.now(),
152
+ method,
153
+ url,
154
+ headers,
155
+ matched: isUploadRequest(url, headers),
156
+ };
157
+ // 获取 post data
158
+ try {
159
+ const postData = await request.postData();
160
+ if (postData) {
161
+ captured.postData = postData;
162
+ }
163
+ }
164
+ catch {
165
+ // ignore
166
+ }
167
+ requests.push(captured);
168
+ lastActivityTime = Date.now();
169
+ // 打印关键请求
170
+ if (captured.matched || url.includes('upload')) {
171
+ console.log(`\n📤 [${method}] ${url}`);
172
+ if (contentType) {
173
+ console.log(` Content-Type: ${contentType.substring(0, 50)}...`);
174
+ }
175
+ }
176
+ });
177
+ // 拦截响应
178
+ page.on('response', async (response) => {
179
+ const url = response.url();
180
+ const status = response.status();
181
+ // 找到对应的请求并更新
182
+ const captured = requests.find(r => r.url === url);
183
+ if (captured) {
184
+ captured.responseStatus = status;
185
+ // 尝试获取响应体
186
+ try {
187
+ const body = await response.text();
188
+ if (body && body.length < 10000) {
189
+ captured.responseBody = body;
190
+ }
191
+ }
192
+ catch {
193
+ // ignore
194
+ }
195
+ // 打印上传响应
196
+ if (captured.matched) {
197
+ console.log(` ← ${status} ${captured.responseBody?.substring(0, 100) || ''}...`);
198
+ }
199
+ }
200
+ });
201
+ // 监听浏览器关闭
202
+ const browserClosedPromise = new Promise((resolve) => {
203
+ browser.on('disconnected', () => {
204
+ console.log('\n🔌 浏览器已关闭,停止抓包...');
205
+ resolve();
206
+ });
207
+ });
208
+ // 超时 Promise
209
+ const timeoutPromise = new Promise((resolve) => {
210
+ setTimeout(() => {
211
+ console.log(`\n⏱️ 超时 (${timeoutMs / 1000}秒),停止抓包...`);
212
+ resolve();
213
+ }, timeoutMs);
214
+ });
215
+ // 无操作超时检测
216
+ const activityCheckPromise = new Promise((resolve) => {
217
+ const checkInterval = setInterval(() => {
218
+ if (Date.now() - lastActivityTime > timeoutMs) {
219
+ console.log('\n⏱️ 长时间无活动,停止抓包...');
220
+ clearInterval(checkInterval);
221
+ resolve();
222
+ }
223
+ }, 5000);
224
+ });
225
+ // 打开编辑器
226
+ console.log(`\n🌐 打开编辑器: ${config.editorUrl}`);
227
+ await page.goto(config.editorUrl, { waitUntil: 'networkidle', timeout: 30000 });
228
+ console.log('✅ 编辑器已加载');
229
+ console.log('\n📋 操作提示:');
230
+ console.log(' 1. 在编辑器中找到上传图片功能');
231
+ console.log(' 2. 上传一张图片');
232
+ console.log(' 3. 完成后关闭浏览器');
233
+ console.log('\n🕐 开始抓包,等待操作...\n');
234
+ // 等待浏览器关闭或超时
235
+ await Promise.race([
236
+ browserClosedPromise,
237
+ Promise.race([timeoutPromise, activityCheckPromise]),
238
+ ]);
239
+ // 关闭浏览器
240
+ try {
241
+ await browser.close();
242
+ }
243
+ catch {
244
+ // ignore
245
+ }
246
+ // 分析抓包数据
247
+ const uploadEndpoints = [...new Set(requests.filter(r => r.matched).map(r => {
248
+ try {
249
+ return new URL(r.url).pathname;
250
+ }
251
+ catch {
252
+ return r.url;
253
+ }
254
+ }))];
255
+ const captureData = {
256
+ platform,
257
+ captureTime: new Date().toISOString(),
258
+ timeout: timeoutMs,
259
+ editorUrl: config.editorUrl,
260
+ uploadApiUrl: uploadEndpoints[0],
261
+ requests: requests.map(r => ({
262
+ ...r,
263
+ responseBody: r.responseBody?.substring(0, 500), // 截断响应体
264
+ })),
265
+ summary: {
266
+ totalRequests: requests.length,
267
+ matchedRequests: requests.filter(r => r.matched).length,
268
+ uploadEndpoints,
269
+ },
270
+ };
271
+ return captureData;
272
+ }
273
+ async function main() {
274
+ const args = process.argv.slice(2);
275
+ const platform = args[0] || 'csdn';
276
+ const timeout = parseInt(args[1] || '60', 10) * 1000;
277
+ if (!PLATFORM_CONFIGS[platform]) {
278
+ console.error(`不支持的平台: ${platform}`);
279
+ console.error(`支持的平台: ${Object.keys(PLATFORM_CONFIGS).join(', ')}`);
280
+ process.exit(1);
281
+ }
282
+ const data = await capture(platform, timeout);
283
+ if (!data) {
284
+ process.exit(1);
285
+ }
286
+ // 保存到临时文件
287
+ const timestamp = Date.now();
288
+ const filename = `temp/capture-${platform}-${timestamp}.json`;
289
+ const filepath = path.resolve(process.cwd(), filename);
290
+ // 确保 temp 目录存在
291
+ await fs.mkdir(path.dirname(filepath), { recursive: true });
292
+ await fs.writeFile(filepath, JSON.stringify(data, null, 2), 'utf-8');
293
+ console.log(`
294
+ ========================================
295
+ 抓包完成
296
+ ========================================
297
+ 文件: ${filepath}
298
+
299
+ 📊 统计:
300
+ 总请求数: ${data.summary.totalRequests}
301
+ 命中请求: ${data.summary.matchedRequests}
302
+ 上传端点: ${data.summary.uploadEndpoints.join(', ') || '无'}
303
+
304
+ ========================================
305
+ `);
306
+ if (data.summary.uploadEndpoints.length > 0) {
307
+ console.log('🎯 检测到上传请求!');
308
+ console.log(' 把这个文件路径告诉 AI,让它分析签名格式');
309
+ }
310
+ else {
311
+ console.log('⚠️ 未检测到上传请求');
312
+ console.log(' 可能原因:');
313
+ console.log(' 1. 没有执行上传操作');
314
+ console.log(' 2. 上传功能需要先登录');
315
+ console.log(' 3. 平台上传 API 不在拦截范围内');
316
+ }
317
+ console.log('\n运行 AI 分析:');
318
+ console.log(` 帮我分析 ${platform} 的抓包数据: ${filepath}`);
319
+ }
320
+ // 仅在直接运行时执行(不在被 import 时执行)
321
+ const isMainModule = import.meta.url === `file://${process.argv[1]}`;
322
+ if (isMainModule) {
323
+ main().catch(console.error);
324
+ }
@@ -0,0 +1,10 @@
1
+ export interface ToutiaoUploadResult {
2
+ success: boolean;
3
+ coverUrl?: string;
4
+ error?: string;
5
+ }
6
+ /**
7
+ * 通过浏览器自动化上传头条号封面
8
+ * 流程:打开发布页 -> 填写内容 -> 点击预览并发布 -> 在预览弹窗中上传封面 -> 确认
9
+ */
10
+ export declare function uploadCoverViaBrowser(imagePath: string): Promise<ToutiaoUploadResult>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 头条号封面图片浏览器上传
3
+ */
4
+ import { chromium } from 'playwright';
5
+ import { ConfigStore } from '../config.js';
6
+ import path from 'path';
7
+ /**
8
+ * 通过浏览器自动化上传头条号封面
9
+ * 流程:打开发布页 -> 填写内容 -> 点击预览并发布 -> 在预览弹窗中上传封面 -> 确认
10
+ */
11
+ export async function uploadCoverViaBrowser(imagePath) {
12
+ const browser = await chromium.launch({
13
+ headless: false,
14
+ args: [
15
+ '--disable-blink-features=AutomationControlled',
16
+ '--disable-devtools-shm-usage',
17
+ '--no-sandbox',
18
+ ]
19
+ });
20
+ try {
21
+ const context = await browser.newContext({
22
+ viewport: { width: 1280, height: 900 },
23
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
24
+ });
25
+ const page = await context.newPage();
26
+ // CDP 隐藏自动化特征
27
+ const cdp = await context.newCDPSession(page);
28
+ await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
29
+ source: `
30
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
31
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
32
+ Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
33
+ window.chrome = { runtime: {}, loadTimes: () => {}, csi: () => {} };
34
+ `
35
+ });
36
+ // 加载 cookies
37
+ const cookies = await ConfigStore.getToutiaoCookies();
38
+ if (!cookies || Object.keys(cookies).length === 0) {
39
+ return { success: false, error: '未配置头条号 Cookie' };
40
+ }
41
+ const toutiaoCookies = Object.entries(cookies).map(([name, value]) => ({
42
+ name,
43
+ value,
44
+ domain: '.toutiao.com',
45
+ path: '/',
46
+ }));
47
+ await context.addCookies(toutiaoCookies);
48
+ console.log('[ToutiaoUpload] 打开发布页...');
49
+ await page.goto('https://mp.toutiao.com/profile_v4/graphic/publish', {
50
+ waitUntil: 'networkidle',
51
+ timeout: 60000
52
+ });
53
+ await page.waitForTimeout(2000);
54
+ // 关闭可能存在的遮罩层
55
+ try {
56
+ const mask = page.locator('.byte-drawer-mask').first();
57
+ if (await mask.isVisible({ timeout: 1000 })) {
58
+ await mask.click({ force: true });
59
+ await page.waitForTimeout(500);
60
+ }
61
+ }
62
+ catch { }
63
+ // 截图
64
+ await page.screenshot({ path: `temp/toutiao-upload-start-${Date.now()}.png` });
65
+ // 填写标题和内容(让用户之前已经填好了,这里只做演示)
66
+ const titleInput = page.locator('textarea').first();
67
+ if (await titleInput.isVisible({ timeout: 2000 }).catch(() => false)) {
68
+ const currentTitle = await titleInput.inputValue().catch(() => '');
69
+ if (!currentTitle) {
70
+ await titleInput.fill('临时标题-封面测试');
71
+ console.log('[ToutiaoUpload] 填写了临时标题');
72
+ }
73
+ }
74
+ // 填写内容(如果为空)
75
+ const contentEl = page.locator('div[contenteditable="true"]').first();
76
+ if (await contentEl.isVisible({ timeout: 2000 }).catch(() => false)) {
77
+ const currentContent = await contentEl.textContent().catch(() => '');
78
+ if (!currentContent || currentContent.trim() === '') {
79
+ await contentEl.click();
80
+ await page.keyboard.press('Control+a');
81
+ await page.keyboard.type('临时内容-封面测试');
82
+ console.log('[ToutiaoUpload] 填写了临时内容');
83
+ }
84
+ }
85
+ // 点击"预览并发布"按钮
86
+ console.log('[ToutiaoUpload] 点击预览并发布按钮...');
87
+ const publishBtn = page.locator('button:has-text("预览并发布")');
88
+ if (await publishBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
89
+ await publishBtn.click({ force: true });
90
+ console.log('[ToutiaoUpload] 已点击预览并发布');
91
+ await page.waitForTimeout(3000);
92
+ }
93
+ else {
94
+ return { success: false, error: '未找到预览并发布按钮' };
95
+ }
96
+ // 在预览弹窗中选择"单图"选项
97
+ console.log('[ToutiaoUpload] 选择单图选项...');
98
+ const singleImageOption = page.locator('text=单图').first();
99
+ if (await singleImageOption.isVisible({ timeout: 2000 }).catch(() => false)) {
100
+ await singleImageOption.click();
101
+ console.log('[ToutiaoUpload] 点击了单图');
102
+ await page.waitForTimeout(500);
103
+ }
104
+ // 截图
105
+ await page.screenshot({ path: `temp/toutiao-upload-select-cover-${Date.now()}.png` });
106
+ // 设置 filechooser 监听
107
+ const absolutePath = path.resolve(imagePath);
108
+ console.log(`[ToutiaoUpload] 准备上传: ${absolutePath}`);
109
+ page.on('filechooser', async (fileChooser) => {
110
+ console.log('[ToutiaoUpload] 收到 filechooser,设置文件');
111
+ await fileChooser.setFiles(absolutePath);
112
+ });
113
+ // 点击封面添加区域触发 filechooser
114
+ console.log('[ToutiaoUpload] 点击封面添加区域...');
115
+ const coverAdd = page.locator('.article-cover-add');
116
+ if (await coverAdd.isVisible({ timeout: 2000 }).catch(() => false)) {
117
+ await coverAdd.click({ force: true });
118
+ console.log('[ToutiaoUpload] 点击了 article-cover-add');
119
+ }
120
+ else {
121
+ return { success: false, error: '未找到封面添加区域' };
122
+ }
123
+ // 等待文件选择
124
+ await page.waitForTimeout(2000);
125
+ // 点击"本地上传"按钮
126
+ console.log('[ToutiaoUpload] 点击本地上传按钮...');
127
+ const localUploadBtn = page.locator('text=本地上传');
128
+ if (await localUploadBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
129
+ await localUploadBtn.click();
130
+ console.log('[ToutiaoUpload] 点击了本地上传');
131
+ await page.waitForTimeout(2000);
132
+ }
133
+ // 截图看预览
134
+ await page.screenshot({ path: `temp/toutiao-upload-preview-${Date.now()}.png` });
135
+ // 点击"确定"按钮
136
+ console.log('[ToutiaoUpload] 点击确定按钮...');
137
+ const confirmBtn = page.locator('button:has-text("确定")').filter({ visible: true });
138
+ try {
139
+ if (await confirmBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
140
+ const disabled = await confirmBtn.first().isDisabled().catch(() => true);
141
+ if (!disabled) {
142
+ await confirmBtn.first().click();
143
+ console.log('[ToutiaoUpload] 点击了确定按钮');
144
+ }
145
+ else {
146
+ console.log('[ToutiaoUpload] 确定按钮被禁用');
147
+ }
148
+ }
149
+ }
150
+ catch {
151
+ console.log('[ToutiaoUpload] 未找到确定按钮');
152
+ }
153
+ // 等待保存
154
+ await page.waitForTimeout(3000);
155
+ await page.screenshot({ path: `temp/toutiao-upload-finish-${Date.now()}.png` });
156
+ console.log('[ToutiaoUpload] 封面上传流程完成');
157
+ return { success: true, coverUrl: 'uploaded' };
158
+ }
159
+ catch (err) {
160
+ console.error('[ToutiaoUpload] 上传失败:', err);
161
+ return { success: false, error: err.message };
162
+ }
163
+ finally {
164
+ await browser.close();
165
+ }
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-publisher",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Markdown → 微信公众号 / 知乎 / 掘金等多平台 CLI 发布工具",
5
5
  "type": "module",
6
6
  "repository": {