@jackwener/opencli 1.7.6 → 1.7.8
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/README.md +17 -8
- package/README.zh-CN.md +14 -8
- package/cli-manifest.json +469 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/bilibili/video.js +11 -4
- package/clis/bilibili/video.test.js +51 -0
- package/clis/chatgpt/image.js +1 -1
- package/clis/chatgpt-app/ask.js +3 -19
- package/clis/chatgpt-app/ax.js +132 -1
- package/clis/chatgpt-app/ax.test.js +23 -0
- package/clis/chatgpt-app/send.js +2 -21
- package/clis/deepseek/ask.js +50 -18
- package/clis/deepseek/ask.test.js +195 -2
- package/clis/deepseek/utils.js +113 -29
- package/clis/deepseek/utils.test.js +109 -1
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/sinafinance/stock.js +5 -2
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/toutiao/articles.js +81 -0
- package/clis/toutiao/articles.test.js +23 -0
- package/clis/twitter/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +28 -0
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -0
- package/clis/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -0
- package/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +333 -43
- package/dist/src/cli.test.js +257 -1
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- package/package.json +5 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { __test__ } from './shared.js';
|
|
3
|
+
|
|
4
|
+
const { extractMedia } = __test__;
|
|
5
|
+
|
|
6
|
+
describe('twitter extractMedia', () => {
|
|
7
|
+
it('returns false + empty list when legacy has no media', () => {
|
|
8
|
+
expect(extractMedia({})).toEqual({ has_media: false, media_urls: [] });
|
|
9
|
+
expect(extractMedia(undefined)).toEqual({ has_media: false, media_urls: [] });
|
|
10
|
+
expect(extractMedia({ extended_entities: { media: [] } })).toEqual({
|
|
11
|
+
has_media: false,
|
|
12
|
+
media_urls: [],
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('extracts photo urls from extended_entities', () => {
|
|
17
|
+
const result = extractMedia({
|
|
18
|
+
extended_entities: {
|
|
19
|
+
media: [
|
|
20
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/a.jpg' },
|
|
21
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/b.jpg' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
expect(result.has_media).toBe(true);
|
|
26
|
+
expect(result.media_urls).toEqual([
|
|
27
|
+
'https://pbs.twimg.com/media/a.jpg',
|
|
28
|
+
'https://pbs.twimg.com/media/b.jpg',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('prefers mp4 variant for video and animated_gif', () => {
|
|
33
|
+
const result = extractMedia({
|
|
34
|
+
extended_entities: {
|
|
35
|
+
media: [
|
|
36
|
+
{
|
|
37
|
+
type: 'video',
|
|
38
|
+
media_url_https: 'https://pbs.twimg.com/media/thumb.jpg',
|
|
39
|
+
video_info: {
|
|
40
|
+
variants: [
|
|
41
|
+
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/x.m3u8' },
|
|
42
|
+
{ content_type: 'video/mp4', url: 'https://video.twimg.com/x.mp4' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'animated_gif',
|
|
48
|
+
media_url_https: 'https://pbs.twimg.com/tweet_video_thumb/g.jpg',
|
|
49
|
+
video_info: {
|
|
50
|
+
variants: [
|
|
51
|
+
{ content_type: 'video/mp4', url: 'https://video.twimg.com/g.mp4' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
expect(result.has_media).toBe(true);
|
|
59
|
+
expect(result.media_urls).toEqual([
|
|
60
|
+
'https://video.twimg.com/x.mp4',
|
|
61
|
+
'https://video.twimg.com/g.mp4',
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('falls back to media_url_https when no mp4 variant is available', () => {
|
|
66
|
+
const result = extractMedia({
|
|
67
|
+
extended_entities: {
|
|
68
|
+
media: [
|
|
69
|
+
{
|
|
70
|
+
type: 'video',
|
|
71
|
+
media_url_https: 'https://pbs.twimg.com/media/thumb.jpg',
|
|
72
|
+
video_info: { variants: [] },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
has_media: true,
|
|
79
|
+
media_urls: ['https://pbs.twimg.com/media/thumb.jpg'],
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('falls back to entities.media when extended_entities is missing', () => {
|
|
84
|
+
const result = extractMedia({
|
|
85
|
+
entities: {
|
|
86
|
+
media: [
|
|
87
|
+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/c.jpg' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
expect(result).toEqual({
|
|
92
|
+
has_media: true,
|
|
93
|
+
media_urls: ['https://pbs.twimg.com/media/c.jpg'],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
package/clis/twitter/thread.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { extractMedia } from './shared.js';
|
|
3
4
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
4
5
|
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
6
|
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
|
@@ -54,6 +55,7 @@ function extractTweet(r, seen) {
|
|
|
54
55
|
in_reply_to: l.in_reply_to_status_id_str || undefined,
|
|
55
56
|
created_at: l.created_at,
|
|
56
57
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
58
|
+
...extractMedia(l),
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
function parseTweetDetail(data, seen) {
|
|
@@ -101,7 +103,7 @@ cli({
|
|
|
101
103
|
{ name: 'tweet-id', positional: true, type: 'string', required: true },
|
|
102
104
|
{ name: 'limit', type: 'int', default: 50 },
|
|
103
105
|
],
|
|
104
|
-
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url'],
|
|
106
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls'],
|
|
105
107
|
func: async (page, kwargs) => {
|
|
106
108
|
let tweetId = kwargs['tweet-id'];
|
|
107
109
|
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
package/clis/twitter/timeline.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { resolveTwitterQueryId } from './shared.js';
|
|
3
|
+
import { resolveTwitterQueryId, extractMedia } from './shared.js';
|
|
4
4
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
5
5
|
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
6
|
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
|
@@ -85,6 +85,7 @@ function extractTweet(result, seen) {
|
|
|
85
85
|
views,
|
|
86
86
|
created_at: l.created_at || '',
|
|
87
87
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
88
|
+
...extractMedia(l),
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
function parseHomeTimeline(data, seen) {
|
|
@@ -148,7 +149,7 @@ cli({
|
|
|
148
149
|
},
|
|
149
150
|
{ name: 'limit', type: 'int', default: 20 },
|
|
150
151
|
],
|
|
151
|
-
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
|
|
152
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
152
153
|
func: async (page, kwargs) => {
|
|
153
154
|
const limit = kwargs.limit || 20;
|
|
154
155
|
const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
|
package/clis/twitter/tweets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
|
|
3
|
+
import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
|
|
4
4
|
|
|
5
5
|
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
6
|
const USER_TWEETS_QUERY_ID = '6fWQaBPK51aGyC_VC7t9GQ';
|
|
@@ -105,6 +105,7 @@ function extractTweet(result, seen) {
|
|
|
105
105
|
is_retweet: isRetweet,
|
|
106
106
|
created_at: legacy.created_at || '',
|
|
107
107
|
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
108
|
+
...extractMedia(legacy),
|
|
108
109
|
};
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -151,7 +152,7 @@ cli({
|
|
|
151
152
|
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
|
|
152
153
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
153
154
|
],
|
|
154
|
-
columns: ['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url'],
|
|
155
|
+
columns: ['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls'],
|
|
155
156
|
func: async (page, kwargs) => {
|
|
156
157
|
const limit = Math.max(1, Math.min(200, kwargs.limit || 20));
|
|
157
158
|
const username = String(kwargs.username || '').replace(/^@/, '').trim();
|
|
@@ -5,7 +5,7 @@ import { __test__ } from './tweets.js';
|
|
|
5
5
|
describe('twitter tweets helpers', () => {
|
|
6
6
|
it('registers is_retweet in the default columns', () => {
|
|
7
7
|
const cmd = getRegistry().get('twitter/tweets');
|
|
8
|
-
expect(cmd?.columns).toEqual(['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url']);
|
|
8
|
+
expect(cmd?.columns).toEqual(['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls']);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('falls back when queryId contains unsafe characters', () => {
|
package/clis/web/read.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
17
17
|
import { downloadArticle } from '@jackwener/opencli/download/article-download';
|
|
18
|
-
cli({
|
|
18
|
+
const command = cli({
|
|
19
19
|
site: 'web',
|
|
20
20
|
name: 'read',
|
|
21
21
|
description: 'Fetch any web page and export as Markdown',
|
|
@@ -26,6 +26,7 @@ cli({
|
|
|
26
26
|
{ name: 'output', default: './web-articles', help: 'Output directory' },
|
|
27
27
|
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
|
|
28
28
|
{ name: 'wait', type: 'int', default: 3, help: 'Seconds to wait after page load' },
|
|
29
|
+
{ name: 'stdout', type: 'boolean', default: false, help: 'Print markdown to stdout instead of saving to a file' },
|
|
29
30
|
],
|
|
30
31
|
columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved'],
|
|
31
32
|
func: async (page, kwargs) => {
|
|
@@ -162,14 +163,26 @@ cli({
|
|
|
162
163
|
if (el.children && el.children.length > 2) dedup(el);
|
|
163
164
|
});
|
|
164
165
|
|
|
166
|
+
// --- Lazy-load image src rewrite ---
|
|
167
|
+
// Many sites render <img src="placeholder.gif" data-src="real.jpg">.
|
|
168
|
+
// Promote the real URL onto src so both the markdown body and the
|
|
169
|
+
// image download list reference the same URL.
|
|
170
|
+
clone.querySelectorAll('img').forEach(img => {
|
|
171
|
+
const srcset = img.getAttribute('data-srcset') || '';
|
|
172
|
+
const srcsetFirst = srcset.split(',')[0]?.trim().split(' ')[0] || '';
|
|
173
|
+
const real = img.getAttribute('data-src')
|
|
174
|
+
|| img.getAttribute('data-original')
|
|
175
|
+
|| img.getAttribute('data-lazy-src')
|
|
176
|
+
|| srcsetFirst;
|
|
177
|
+
if (real) img.setAttribute('src', real);
|
|
178
|
+
});
|
|
179
|
+
|
|
165
180
|
result.contentHtml = clone.innerHTML;
|
|
166
181
|
|
|
167
182
|
// --- Image extraction ---
|
|
168
183
|
const seen = new Set();
|
|
169
184
|
clone.querySelectorAll('img').forEach(img => {
|
|
170
|
-
const src = img.getAttribute('
|
|
171
|
-
|| img.getAttribute('data-original')
|
|
172
|
-
|| img.getAttribute('src');
|
|
185
|
+
const src = img.getAttribute('src') || '';
|
|
173
186
|
if (src && !src.startsWith('data:') && !seen.has(src)) {
|
|
174
187
|
seen.add(src);
|
|
175
188
|
result.imageUrls.push(src);
|
|
@@ -186,7 +199,7 @@ cli({
|
|
|
186
199
|
referer = parsed.origin + '/';
|
|
187
200
|
}
|
|
188
201
|
catch { /* ignore */ }
|
|
189
|
-
|
|
202
|
+
const result = await downloadArticle({
|
|
190
203
|
title: data?.title || 'untitled',
|
|
191
204
|
author: data?.author,
|
|
192
205
|
publishTime: data?.publishTime,
|
|
@@ -197,6 +210,13 @@ cli({
|
|
|
197
210
|
output: kwargs.output,
|
|
198
211
|
downloadImages: kwargs['download-images'],
|
|
199
212
|
imageHeaders: referer ? { Referer: referer } : undefined,
|
|
213
|
+
stdout: kwargs.stdout,
|
|
200
214
|
});
|
|
215
|
+
// `--stdout` is a content-streaming mode. The markdown body already went
|
|
216
|
+
// to process.stdout inside downloadArticle(), so returning rows here
|
|
217
|
+
// would make Commander append table/JSON output to the same stdout
|
|
218
|
+
// stream and break piping.
|
|
219
|
+
return kwargs.stdout ? null : result;
|
|
201
220
|
},
|
|
202
221
|
});
|
|
222
|
+
export const __test__ = { command };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockDownloadArticle } = vi.hoisted(() => ({
|
|
4
|
+
mockDownloadArticle: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('@jackwener/opencli/download/article-download', () => ({
|
|
8
|
+
downloadArticle: mockDownloadArticle,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const { __test__ } = await import('./read.js');
|
|
12
|
+
|
|
13
|
+
describe('web/read stdout behavior', () => {
|
|
14
|
+
const read = __test__.command;
|
|
15
|
+
const page = {
|
|
16
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
19
|
+
title: 'Example Article',
|
|
20
|
+
author: 'Author',
|
|
21
|
+
publishTime: '2026-04-22',
|
|
22
|
+
contentHtml: '<p>hello</p>',
|
|
23
|
+
imageUrls: ['https://example.com/a.jpg'],
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
mockDownloadArticle.mockReset();
|
|
29
|
+
mockDownloadArticle.mockResolvedValue([{
|
|
30
|
+
title: 'Example Article',
|
|
31
|
+
author: 'Author',
|
|
32
|
+
publish_time: '2026-04-22',
|
|
33
|
+
status: 'success',
|
|
34
|
+
size: '1 KB',
|
|
35
|
+
saved: '-',
|
|
36
|
+
}]);
|
|
37
|
+
page.goto.mockClear();
|
|
38
|
+
page.wait.mockClear();
|
|
39
|
+
page.evaluate.mockClear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null in --stdout mode so the CLI does not append result rows to stdout', async () => {
|
|
43
|
+
const result = await read.func(page, {
|
|
44
|
+
url: 'https://example.com/article',
|
|
45
|
+
output: '/tmp/out',
|
|
46
|
+
'download-images': false,
|
|
47
|
+
stdout: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
expect(mockDownloadArticle).toHaveBeenCalledWith(
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
title: 'Example Article',
|
|
54
|
+
sourceUrl: 'https://example.com/article',
|
|
55
|
+
}),
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
output: '/tmp/out',
|
|
58
|
+
stdout: true,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('still returns the saved-row payload when writing to disk', async () => {
|
|
64
|
+
const rows = [{ title: 'Example Article', saved: '/tmp/out/Example Article/example.md' }];
|
|
65
|
+
mockDownloadArticle.mockResolvedValue(rows);
|
|
66
|
+
|
|
67
|
+
const result = await read.func(page, {
|
|
68
|
+
url: 'https://example.com/article',
|
|
69
|
+
output: '/tmp/out',
|
|
70
|
+
'download-images': false,
|
|
71
|
+
stdout: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result).toBe(rows);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
|
|
5
|
+
const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
|
|
6
|
+
|
|
7
|
+
async function getToken(page) {
|
|
8
|
+
return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function navigateToEditor(page) {
|
|
12
|
+
await page.goto(WEIXIN_HOME);
|
|
13
|
+
await page.wait(3);
|
|
14
|
+
const token = await getToken(page);
|
|
15
|
+
if (!token) {
|
|
16
|
+
throw new CommandExecutionError('Could not extract session token. Please log in to mp.weixin.qq.com');
|
|
17
|
+
}
|
|
18
|
+
await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&token=${token}&lang=zh_CN`);
|
|
19
|
+
await page.wait(4);
|
|
20
|
+
const hasTitle = await page.evaluate('!!document.querySelector("textarea#title")');
|
|
21
|
+
if (!hasTitle) {
|
|
22
|
+
throw new CommandExecutionError('Article editor did not load. Session may have expired');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function fillField(page, selector, value) {
|
|
27
|
+
return page.evaluate(`(() => {
|
|
28
|
+
var el = document.querySelector('${selector}');
|
|
29
|
+
if (!el) return { ok: false, reason: 'not found: ${selector}' };
|
|
30
|
+
el.focus();
|
|
31
|
+
var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
32
|
+
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
33
|
+
if (setter && setter.set) setter.set.call(el, ${JSON.stringify(value)});
|
|
34
|
+
else el.value = ${JSON.stringify(value)};
|
|
35
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${JSON.stringify(value)} }));
|
|
36
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
37
|
+
el.blur();
|
|
38
|
+
return { ok: true };
|
|
39
|
+
})()`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fillContent(page, text) {
|
|
43
|
+
return page.evaluate(`(() => {
|
|
44
|
+
var editors = document.querySelectorAll('div[contenteditable="true"]');
|
|
45
|
+
var editor = editors[editors.length - 1];
|
|
46
|
+
if (!editor) return { ok: false, reason: 'content editor not found' };
|
|
47
|
+
editor.focus();
|
|
48
|
+
if (editor.querySelector('[contenteditable="false"]')) editor.innerHTML = '';
|
|
49
|
+
document.execCommand('selectAll', false, null);
|
|
50
|
+
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
51
|
+
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
52
|
+
return { ok: true };
|
|
53
|
+
})()`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function uploadContentImage(page, imagePath) {
|
|
57
|
+
const fs = await import('node:fs');
|
|
58
|
+
const path = await import('node:path');
|
|
59
|
+
const absPath = path.default.resolve(imagePath);
|
|
60
|
+
if (!fs.default.existsSync(absPath)) {
|
|
61
|
+
throw new CommandExecutionError(`Image not found: ${absPath}`);
|
|
62
|
+
}
|
|
63
|
+
if (!page.setFileInput) {
|
|
64
|
+
throw new CommandExecutionError('Image upload requires Browser Bridge with CDP support');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await page.evaluate(`(() => {
|
|
68
|
+
var li = document.querySelector('#js_editor_insertimage');
|
|
69
|
+
if (li) li.click();
|
|
70
|
+
})()`);
|
|
71
|
+
await page.wait(1);
|
|
72
|
+
await page.evaluate(`(() => {
|
|
73
|
+
var items = document.querySelectorAll('.js_img_dropdown_menu .tpl_dropdown_menu_item');
|
|
74
|
+
if (items[0]) items[0].click();
|
|
75
|
+
})()`);
|
|
76
|
+
await page.wait(1);
|
|
77
|
+
|
|
78
|
+
await page.setFileInput([absPath], 'input[type="file"][name="file"]');
|
|
79
|
+
await page.wait(8);
|
|
80
|
+
|
|
81
|
+
const cdnCount = await page.evaluate(`(() => {
|
|
82
|
+
var editor = document.querySelector('#ueditor_0');
|
|
83
|
+
return editor ? editor.querySelectorAll('img[src*="mmbiz"]').length : 0;
|
|
84
|
+
})()`);
|
|
85
|
+
if (cdnCount === 0) {
|
|
86
|
+
throw new CommandExecutionError('Image did not upload to WeChat CDN');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function selectCoverFromContent(page) {
|
|
91
|
+
await page.evaluate('document.querySelector("#js_cover_description_area")?.scrollIntoView()');
|
|
92
|
+
await page.wait(1);
|
|
93
|
+
|
|
94
|
+
await page.evaluate('document.querySelector(".js_cover_btn_area")?.click()');
|
|
95
|
+
await page.wait(1);
|
|
96
|
+
|
|
97
|
+
await page.evaluate(`(() => {
|
|
98
|
+
var links = document.querySelectorAll('a.pop-opr__button');
|
|
99
|
+
for (var i = 0; i < links.length; i++) {
|
|
100
|
+
if (links[i].textContent.trim() === '从正文选择') { links[i].click(); return; }
|
|
101
|
+
}
|
|
102
|
+
})()`);
|
|
103
|
+
await page.wait(2);
|
|
104
|
+
|
|
105
|
+
await page.evaluate(`(() => {
|
|
106
|
+
var img = document.querySelector('.weui-desktop-dialog_img-picker .appmsg_content_img');
|
|
107
|
+
if (img) img.click();
|
|
108
|
+
})()`);
|
|
109
|
+
await page.wait(1);
|
|
110
|
+
|
|
111
|
+
await page.evaluate(`(() => {
|
|
112
|
+
var btns = document.querySelectorAll('.weui-desktop-dialog_img-picker button');
|
|
113
|
+
for (var i = 0; i < btns.length; i++) {
|
|
114
|
+
if (btns[i].textContent.trim() === '下一步' && !btns[i].disabled) { btns[i].click(); return; }
|
|
115
|
+
}
|
|
116
|
+
})()`);
|
|
117
|
+
|
|
118
|
+
// Crop dialog image rendering can be slow
|
|
119
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
120
|
+
await page.wait(2);
|
|
121
|
+
const ready = await page.evaluate(`(() => {
|
|
122
|
+
var btns = document.querySelectorAll('button');
|
|
123
|
+
for (var i = 0; i < btns.length; i++) {
|
|
124
|
+
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
})()`);
|
|
128
|
+
if (ready) break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await page.evaluate(`(() => {
|
|
132
|
+
var btns = document.querySelectorAll('button');
|
|
133
|
+
for (var i = 0; i < btns.length; i++) {
|
|
134
|
+
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) { btns[i].click(); return; }
|
|
135
|
+
}
|
|
136
|
+
})()`);
|
|
137
|
+
await page.wait(2);
|
|
138
|
+
const hasCover = await page.evaluate(`(() => {
|
|
139
|
+
var area = document.querySelector('#js_cover_area');
|
|
140
|
+
if (!area) return false;
|
|
141
|
+
var found = false;
|
|
142
|
+
area.querySelectorAll('*').forEach(function(el) {
|
|
143
|
+
var bg = window.getComputedStyle(el).backgroundImage;
|
|
144
|
+
if (bg && bg.includes('mmbiz')) found = true;
|
|
145
|
+
});
|
|
146
|
+
return found;
|
|
147
|
+
})()`);
|
|
148
|
+
return hasCover;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function clickSaveDraft(page) {
|
|
152
|
+
const result = await page.evaluate(`(() => {
|
|
153
|
+
var btns = document.querySelectorAll('span, button, a');
|
|
154
|
+
for (var i = 0; i < btns.length; i++) {
|
|
155
|
+
if ((btns[i].textContent || '').trim() === '保存为草稿') { btns[i].click(); return { ok: true }; }
|
|
156
|
+
}
|
|
157
|
+
return { ok: false };
|
|
158
|
+
})()`);
|
|
159
|
+
if (!result?.ok) throw new CommandExecutionError('Save draft button not found');
|
|
160
|
+
|
|
161
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
162
|
+
await page.wait(2);
|
|
163
|
+
const saved = await page.evaluate(`(() => {
|
|
164
|
+
var el = document.querySelector('#js_save_success');
|
|
165
|
+
if (el && window.getComputedStyle(el).display !== 'none') return true;
|
|
166
|
+
return document.body.innerText.includes('已保存');
|
|
167
|
+
})()`);
|
|
168
|
+
if (saved) return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const createDraftCommand = cli({
|
|
174
|
+
site: 'weixin',
|
|
175
|
+
name: 'create-draft',
|
|
176
|
+
description: '创建微信公众号图文草稿',
|
|
177
|
+
domain: WEIXIN_DOMAIN,
|
|
178
|
+
strategy: Strategy.COOKIE,
|
|
179
|
+
browser: true,
|
|
180
|
+
navigateBefore: false,
|
|
181
|
+
timeoutSeconds: 180,
|
|
182
|
+
args: [
|
|
183
|
+
{ name: 'title', required: true, help: '文章标题 (最长64字)' },
|
|
184
|
+
{ name: 'content', required: true, positional: true, help: '文章正文' },
|
|
185
|
+
{ name: 'author', help: '作者名 (最长8字)' },
|
|
186
|
+
{ name: 'cover-image', help: '封面图片路径 (会先上传到正文再设为封面)' },
|
|
187
|
+
{ name: 'summary', help: '文章摘要' },
|
|
188
|
+
],
|
|
189
|
+
columns: ['status', 'detail'],
|
|
190
|
+
|
|
191
|
+
func: async (page, kwargs) => {
|
|
192
|
+
await navigateToEditor(page);
|
|
193
|
+
|
|
194
|
+
const titleResult = await fillField(page, 'textarea#title', kwargs.title);
|
|
195
|
+
if (!titleResult?.ok) throw new CommandExecutionError('Failed to fill title');
|
|
196
|
+
|
|
197
|
+
if (kwargs.author) {
|
|
198
|
+
const authorResult = await fillField(page, 'input#author', kwargs.author);
|
|
199
|
+
if (!authorResult?.ok) throw new CommandExecutionError('Failed to fill author');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const contentResult = await fillContent(page, kwargs.content);
|
|
203
|
+
if (!contentResult?.ok) throw new CommandExecutionError('Failed to fill content');
|
|
204
|
+
|
|
205
|
+
if (kwargs['cover-image']) {
|
|
206
|
+
await uploadContentImage(page, kwargs['cover-image']);
|
|
207
|
+
const coverSet = await selectCoverFromContent(page);
|
|
208
|
+
if (!coverSet) {
|
|
209
|
+
// Non-fatal: draft can be saved without cover
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (kwargs.summary) {
|
|
214
|
+
await fillField(page, 'textarea#js_description', kwargs.summary);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await page.wait(1);
|
|
218
|
+
const success = await clickSaveDraft(page);
|
|
219
|
+
|
|
220
|
+
return [{
|
|
221
|
+
status: success ? 'draft saved' : 'save attempted, check browser to confirm',
|
|
222
|
+
detail: `"${kwargs.title}"${kwargs.author ? ` by ${kwargs.author}` : ''}${kwargs['cover-image'] ? ' (with cover)' : ''}`,
|
|
223
|
+
}];
|
|
224
|
+
},
|
|
225
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
|
|
5
|
+
|
|
6
|
+
export const draftsCommand = cli({
|
|
7
|
+
site: 'weixin',
|
|
8
|
+
name: 'drafts',
|
|
9
|
+
description: '列出微信公众号草稿箱',
|
|
10
|
+
domain: WEIXIN_DOMAIN,
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
browser: true,
|
|
13
|
+
navigateBefore: false,
|
|
14
|
+
timeoutSeconds: 60,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'limit', type: 'int', default: 10, help: '最多显示条数' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['Index', 'Title', 'Time'],
|
|
19
|
+
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
await page.goto('https://mp.weixin.qq.com/');
|
|
22
|
+
await page.wait(3);
|
|
23
|
+
const token = await page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
|
|
24
|
+
if (!token) {
|
|
25
|
+
throw new AuthRequiredError(WEIXIN_DOMAIN, '微信公众号草稿箱需要已登录的 mp.weixin.qq.com 会话');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?begin=0&count=${kwargs.limit}&type=77&action=list_card&token=${token}&lang=zh_CN`);
|
|
29
|
+
await page.wait(4);
|
|
30
|
+
|
|
31
|
+
const drafts = await page.evaluate(`(() => {
|
|
32
|
+
var results = [];
|
|
33
|
+
var idx = 0;
|
|
34
|
+
|
|
35
|
+
var cards = document.querySelectorAll('.weui-desktop-card');
|
|
36
|
+
for (var i = 0; i < cards.length; i++) {
|
|
37
|
+
if (cards[i].className.includes('card_new')) continue;
|
|
38
|
+
var titleEl = cards[i].querySelector('[class*=title]');
|
|
39
|
+
var timeEl = cards[i].querySelector('[class*=tips]');
|
|
40
|
+
var title = titleEl ? titleEl.textContent.trim() : '';
|
|
41
|
+
var time = timeEl ? timeEl.textContent.trim().replace(/\\s+/g, ' ') : '';
|
|
42
|
+
if (title) results.push({ Index: ++idx, Title: title, Time: time });
|
|
43
|
+
}
|
|
44
|
+
if (results.length > 0) return results;
|
|
45
|
+
|
|
46
|
+
var rows = document.querySelectorAll('tr, [class*=appmsg_item], [class*=list_item]');
|
|
47
|
+
rows.forEach(function(row) {
|
|
48
|
+
var titleEl = row.querySelector('[class*=title] a, [class*=title], h4');
|
|
49
|
+
var timeEl = row.querySelector('[class*=time], td:nth-child(2)');
|
|
50
|
+
var title = titleEl ? titleEl.textContent.trim() : '';
|
|
51
|
+
var time = timeEl ? timeEl.textContent.trim() : '';
|
|
52
|
+
if (title && title !== '内容' && title.length < 80) {
|
|
53
|
+
results.push({ Index: ++idx, Title: title, Time: time });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return results;
|
|
57
|
+
})()`);
|
|
58
|
+
|
|
59
|
+
if (!drafts || drafts.length === 0) {
|
|
60
|
+
throw new EmptyResultError('weixin drafts', 'No structured drafts found in the current Weixin Official Account backend');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return drafts.slice(0, kwargs.limit);
|
|
64
|
+
},
|
|
65
|
+
});
|