@sovovs/bycli 2.1.23 → 2.1.25
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/cli-manifest.json +15 -8
- package/clis/weixin/_wechat/article-fallback-policy.js +46 -0
- package/clis/weixin/_wechat/article-index.js +177 -0
- package/clis/weixin/_wechat/article-link.js +88 -0
- package/clis/weixin/_wechat/auth-session.js +21 -1
- package/clis/weixin/_wechat/publish-records.js +14 -3
- package/clis/weixin/_wechat/sogou-fallback.js +223 -0
- package/clis/weixin/_wechat/sogou-search.js +143 -0
- package/clis/weixin/articles.js +40 -9
- package/clis/weixin/create-draft.js +120 -41
- package/clis/weixin/download-publish-data.js +77 -28
- package/clis/weixin/download.js +20 -48
- package/clis/weixin/save-articles.js +56 -12
- package/clis/weixin/search.js +9 -113
- package/package.json +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, resolve } from 'node:path';
|
|
4
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
2
5
|
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
6
|
import { resolveBrowserCredentials } from './_wechat/auth-session.js';
|
|
4
7
|
import { buildSecretSet, redactText } from './_wechat/redact.js';
|
|
@@ -10,9 +13,13 @@ import {
|
|
|
10
13
|
matchPublishedRecord,
|
|
11
14
|
positiveSafeInteger,
|
|
12
15
|
validatePublishDate,
|
|
16
|
+
validatePublishedQuery,
|
|
13
17
|
} from './_wechat/publish-records.js';
|
|
14
18
|
|
|
15
|
-
const COLUMNS = [
|
|
19
|
+
const COLUMNS = [
|
|
20
|
+
'title', 'publishedAt', 'url', 'status',
|
|
21
|
+
'markdownPath', 'markdownSize', 'dataPath', 'dataSize', 'error',
|
|
22
|
+
];
|
|
16
23
|
|
|
17
24
|
function sanitizedError(error, secrets, fallback) {
|
|
18
25
|
const message = error instanceof Error ? error.message : fallback;
|
|
@@ -20,19 +27,46 @@ function sanitizedError(error, secrets, fallback) {
|
|
|
20
27
|
.replace(/https?:\/\/mp\.weixin\.qq\.com\/\S*/giu, '[REDACTED]');
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
async function validateArtifact(result, { label, expectedStatus, expectedExtension }) {
|
|
31
|
+
if (!result || result.status !== expectedStatus) {
|
|
32
|
+
throw new CommandExecutionError(`${label} returned an invalid status`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof result.path !== 'string' || !result.path.trim()) {
|
|
35
|
+
throw new CommandExecutionError(`${label} returned no output path`);
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isSafeInteger(result.size) || result.size <= 0) {
|
|
38
|
+
throw new CommandExecutionError(`${label} returned an invalid size`);
|
|
39
|
+
}
|
|
40
|
+
const path = resolve(result.path);
|
|
41
|
+
if (extname(path).toLowerCase() !== expectedExtension) {
|
|
42
|
+
throw new CommandExecutionError(`${label} returned an unexpected file type`);
|
|
43
|
+
}
|
|
44
|
+
let info;
|
|
45
|
+
try {
|
|
46
|
+
await access(path, constants.R_OK);
|
|
47
|
+
info = await stat(path);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new CommandExecutionError(`${label} returned an unreadable file`);
|
|
50
|
+
}
|
|
51
|
+
if (!info.isFile() || info.size <= 0 || info.size !== result.size) {
|
|
52
|
+
throw new CommandExecutionError(`${label} returned an unreadable or mismatched file`);
|
|
53
|
+
}
|
|
54
|
+
return { ...result, path, size: info.size };
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
export const downloadPublishDataCommand = cli({
|
|
24
58
|
site: 'weixin',
|
|
25
59
|
name: 'download-publish-data',
|
|
26
60
|
access: 'write',
|
|
27
61
|
domain: 'mp.weixin.qq.com',
|
|
28
|
-
description: 'Match a Weixin published article and save its
|
|
62
|
+
description: 'Match a Weixin published article and save its Excel data and Markdown analysis',
|
|
29
63
|
strategy: Strategy.INTERCEPT,
|
|
30
64
|
browser: true,
|
|
31
65
|
navigateBefore: false,
|
|
32
66
|
args: [
|
|
33
67
|
{ name: 'query', positional: true, required: true, help: 'Exact article URL or title text' },
|
|
34
68
|
{ name: 'date', help: 'Optional publication date in YYYY-MM-DD' },
|
|
35
|
-
{ name: 'output', default: './weixin-publish-data', help: 'Directory for generated Markdown reports' },
|
|
69
|
+
{ name: 'output', default: './weixin-publish-data', help: 'Directory for generated Excel data and Markdown reports' },
|
|
36
70
|
{ name: 'max-pages', type: 'int', default: 5, help: 'Maximum published-record pages to scan' },
|
|
37
71
|
{ name: 'timeout', type: 'int', default: 60, help: 'Maximum seconds for page capture' },
|
|
38
72
|
],
|
|
@@ -40,6 +74,7 @@ export const downloadPublishDataCommand = cli({
|
|
|
40
74
|
func: async (page, args) => {
|
|
41
75
|
const query = String(args.query ?? '').trim();
|
|
42
76
|
if (!query) throw new ArgumentError('query required');
|
|
77
|
+
const validatedQuery = validatePublishedQuery(query);
|
|
43
78
|
|
|
44
79
|
const timeoutSeconds = positiveSafeInteger(args.timeout, 'timeout', 60);
|
|
45
80
|
const maxPages = positiveSafeInteger(args['max-pages'], 'max-pages', 5);
|
|
@@ -53,7 +88,7 @@ export const downloadPublishDataCommand = cli({
|
|
|
53
88
|
maxPages,
|
|
54
89
|
timeout: timeoutSeconds,
|
|
55
90
|
});
|
|
56
|
-
const record = matchPublishedRecord(rows,
|
|
91
|
+
const record = matchPublishedRecord(rows, validatedQuery, validatedDate);
|
|
57
92
|
const detailUrl = buildDetailUrl(record, token);
|
|
58
93
|
const outputDir = args.output ?? './weixin-publish-data';
|
|
59
94
|
const commonOptions = {
|
|
@@ -64,31 +99,45 @@ export const downloadPublishDataCommand = cli({
|
|
|
64
99
|
};
|
|
65
100
|
const secrets = buildSecretSet({ token, cookie });
|
|
66
101
|
|
|
102
|
+
let dataResult = null;
|
|
103
|
+
let markdownResult = null;
|
|
104
|
+
const errors = [];
|
|
67
105
|
try {
|
|
68
106
|
const result = await downloadPublishData(page, commonOptions);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
status: 'failed', markdownPath: null, dataPath: null, size: null,
|
|
90
|
-
error: `Excel download failed: ${downloadMessage}; Markdown fallback failed: ${analysisMessage}` }];
|
|
91
|
-
}
|
|
107
|
+
dataResult = await validateArtifact(result, {
|
|
108
|
+
label: 'Excel artifact',
|
|
109
|
+
expectedStatus: 'downloaded',
|
|
110
|
+
expectedExtension: '.xls',
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
errors.push(`Excel download failed: ${sanitizedError(error, secrets, 'Excel download failed')}`);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const result = await collectPublishAnalysis(page, {
|
|
117
|
+
...commonOptions,
|
|
118
|
+
publishedAt: record.publishedAt,
|
|
119
|
+
});
|
|
120
|
+
markdownResult = await validateArtifact(result, {
|
|
121
|
+
label: 'Markdown artifact',
|
|
122
|
+
expectedStatus: 'saved',
|
|
123
|
+
expectedExtension: '.md',
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
errors.push(`Markdown analysis failed: ${sanitizedError(error, secrets, 'Markdown analysis failed')}`);
|
|
92
127
|
}
|
|
128
|
+
|
|
129
|
+
const status = dataResult && markdownResult ? 'downloaded'
|
|
130
|
+
: dataResult || markdownResult ? 'partial' : 'failed';
|
|
131
|
+
return [{
|
|
132
|
+
title: record.title,
|
|
133
|
+
publishedAt: record.publishedAt,
|
|
134
|
+
url: record.url,
|
|
135
|
+
status,
|
|
136
|
+
markdownPath: markdownResult?.path ?? null,
|
|
137
|
+
markdownSize: markdownResult?.size ?? null,
|
|
138
|
+
dataPath: dataResult?.path ?? null,
|
|
139
|
+
dataSize: dataResult?.size ?? null,
|
|
140
|
+
error: errors.length > 0 ? errors.join('; ') : null,
|
|
141
|
+
}];
|
|
93
142
|
},
|
|
94
143
|
});
|
package/clis/weixin/download.js
CHANGED
|
@@ -10,45 +10,19 @@ import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
|
10
10
|
import { downloadArticle } from '@sovovs/bycli/download/article-download';
|
|
11
11
|
import { AuthRequiredError } from '@sovovs/bycli/errors';
|
|
12
12
|
import { buildExtractWechatArticleContentJs } from './_wechat/article-content.js';
|
|
13
|
+
import {
|
|
14
|
+
isTrustedSogouRedirectUrl,
|
|
15
|
+
isTrustedWechatArticleUrl,
|
|
16
|
+
normalizeWechatUrl,
|
|
17
|
+
resolveWechatArticleUrl,
|
|
18
|
+
} from './_wechat/article-link.js';
|
|
13
19
|
export { extractWechatArticleContent } from './_wechat/article-content.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export function normalizeWechatUrl(raw) {
|
|
21
|
-
let s = (raw || '').trim();
|
|
22
|
-
if (!s)
|
|
23
|
-
return s;
|
|
24
|
-
// Strip wrapping quotes / angle brackets
|
|
25
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
26
|
-
s = s.slice(1, -1).trim();
|
|
27
|
-
}
|
|
28
|
-
if (s.startsWith('<') && s.endsWith('>')) {
|
|
29
|
-
s = s.slice(1, -1).trim();
|
|
30
|
-
}
|
|
31
|
-
// Remove backslash escapes before URL-significant characters
|
|
32
|
-
s = s.replace(/\\+([:/&?=#%])/g, '$1');
|
|
33
|
-
// Decode HTML entities
|
|
34
|
-
s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
35
|
-
// Allow bare hostnames
|
|
36
|
-
if (s.startsWith('mp.weixin.qq.com/') || s.startsWith('//mp.weixin.qq.com/')) {
|
|
37
|
-
s = 'https://' + s.replace(/^\/+/, '');
|
|
38
|
-
}
|
|
39
|
-
// Force https for mp.weixin.qq.com
|
|
40
|
-
try {
|
|
41
|
-
const parsed = new URL(s);
|
|
42
|
-
if (['http:', 'https:'].includes(parsed.protocol) && parsed.hostname.toLowerCase() === 'mp.weixin.qq.com') {
|
|
43
|
-
parsed.protocol = 'https:';
|
|
44
|
-
s = parsed.toString();
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
// Ignore parse errors
|
|
49
|
-
}
|
|
50
|
-
return s;
|
|
51
|
-
}
|
|
20
|
+
export {
|
|
21
|
+
isTrustedSogouRedirectUrl,
|
|
22
|
+
isTrustedWechatArticleUrl,
|
|
23
|
+
normalizeWechatUrl,
|
|
24
|
+
resolveWechatArticleUrl as resolveWechatDownloadUrl,
|
|
25
|
+
};
|
|
52
26
|
/**
|
|
53
27
|
* Format a WeChat article timestamp as a UTC+8 datetime string.
|
|
54
28
|
* Accepts either Unix seconds or milliseconds.
|
|
@@ -183,15 +157,12 @@ cli({
|
|
|
183
157
|
{ name: 'output', default: './weixin-articles', help: 'Output directory' },
|
|
184
158
|
{ name: 'download-images', type: 'boolean', default: true, help: 'Download images locally' },
|
|
185
159
|
],
|
|
186
|
-
columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved'],
|
|
160
|
+
columns: ['title', 'author', 'publish_time', 'status', 'size', 'saved', 'source_url', 'resolved_url'],
|
|
187
161
|
func: async (page, kwargs) => {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
if (!
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
// Navigate and wait for content to load
|
|
194
|
-
await page.goto(url);
|
|
162
|
+
const { sourceUrl, resolvedUrl, alreadyNavigated } = await resolveWechatArticleUrl(page, kwargs.url);
|
|
163
|
+
// Navigate and wait for content to load. Sogou resolution already lands on the article.
|
|
164
|
+
if (!alreadyNavigated)
|
|
165
|
+
await page.goto(resolvedUrl);
|
|
195
166
|
await page.wait(5);
|
|
196
167
|
// Extract article data in browser context
|
|
197
168
|
const data = await page.evaluate(`
|
|
@@ -255,11 +226,11 @@ cli({
|
|
|
255
226
|
'WeChat article page requires environment verification. Complete it in the open browser tab and run the command again.',
|
|
256
227
|
);
|
|
257
228
|
}
|
|
258
|
-
|
|
229
|
+
const rows = await downloadArticle({
|
|
259
230
|
title: data?.title || '',
|
|
260
231
|
author: data?.author,
|
|
261
232
|
publishTime: data?.publishTime,
|
|
262
|
-
sourceUrl:
|
|
233
|
+
sourceUrl: resolvedUrl,
|
|
263
234
|
contentHtml: data?.contentHtml || '',
|
|
264
235
|
codeBlocks: data?.codeBlocks,
|
|
265
236
|
imageUrls: data?.imageUrls,
|
|
@@ -274,5 +245,6 @@ cli({
|
|
|
274
245
|
},
|
|
275
246
|
secureMarkdown: true,
|
|
276
247
|
});
|
|
248
|
+
return rows.map(row => ({ ...row, source_url: sourceUrl, resolved_url: resolvedUrl }));
|
|
277
249
|
},
|
|
278
250
|
});
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError,
|
|
3
|
+
} from '@sovovs/bycli/errors';
|
|
2
4
|
import { MAX_WECHAT_HTML_BYTES } from '@sovovs/bycli/download/wechat-article';
|
|
3
5
|
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
4
6
|
import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
|
|
5
7
|
import {
|
|
6
|
-
|
|
8
|
+
combineArticleFallbackErrors,
|
|
9
|
+
isEligibleArticleFallbackError,
|
|
10
|
+
withMissingFallbackName,
|
|
11
|
+
} from './_wechat/article-fallback-policy.js';
|
|
12
|
+
import { createArticleIndexFetcher } from './_wechat/article-index.js';
|
|
13
|
+
import {
|
|
14
|
+
callCrawler, collectArticles, isTrustedWechatArticleUrl, saveArticles,
|
|
7
15
|
} from './_wechat/crawler-runtime.js';
|
|
8
16
|
import { readAuthSource } from './_wechat/args.js';
|
|
9
17
|
import { wechatArticleToMarkdown } from './_wechat/markdown.js';
|
|
18
|
+
import { collectSogouAccountArticles } from './_wechat/sogou-fallback.js';
|
|
10
19
|
|
|
11
20
|
const DOMAIN = 'mp.weixin.qq.com';
|
|
12
21
|
const browserRequired = args => readAuthSource(args) === 'browser';
|
|
@@ -155,11 +164,11 @@ export const saveArticlesCommand = cli({
|
|
|
155
164
|
description: 'Download WeChat official-account articles as Markdown files',
|
|
156
165
|
strategy: Strategy.COOKIE, browser: browserRequired,
|
|
157
166
|
args: [
|
|
158
|
-
{ name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' }, { name: 'name', help: 'Official-account name
|
|
167
|
+
{ name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' }, { name: 'name', help: 'Official-account name; exact case-insensitive match required for browser Sogou fallback' },
|
|
159
168
|
{ name: 'output', default: './weixin-articles', help: 'Directory for saved Markdown files' }, { name: 'limit', type: 'int', help: 'Maximum number of articles to save' },
|
|
160
169
|
{ name: 'max-pages', type: 'int', help: 'Maximum number of history pages to scan' }, { name: 'auth-source', default: 'browser', choices: ['browser', 'env'], help: 'Credential source: browser session or environment variables' },
|
|
161
170
|
],
|
|
162
|
-
columns: ['title', 'status', 'stage', 'path', 'error', 'url'],
|
|
171
|
+
columns: ['title', 'status', 'stage', 'path', 'error', 'url', 'source', 'coverage'],
|
|
163
172
|
func: async (page, args) => {
|
|
164
173
|
const fakeid = String(args.fakeid ?? '').trim();
|
|
165
174
|
if (!fakeid) throw new ArgumentError('fakeid is required');
|
|
@@ -167,21 +176,56 @@ export const saveArticlesCommand = cli({
|
|
|
167
176
|
const credentials = authSource === 'env'
|
|
168
177
|
? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
|
|
169
178
|
const articleHtmlDownloader = createArticleHtmlDownloader({ authSource, page });
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
const fetchPage = createArticleIndexFetcher({ page, source: authSource, credentials });
|
|
180
|
+
let articles;
|
|
181
|
+
let resolutionFailures = [];
|
|
182
|
+
let source = 'wechat';
|
|
183
|
+
let coverage = null;
|
|
184
|
+
try {
|
|
185
|
+
const result = await callCrawler(() => collectArticles({
|
|
186
|
+
fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'],
|
|
187
|
+
}));
|
|
188
|
+
articles = result.articles;
|
|
189
|
+
if (articles.length === 0) {
|
|
190
|
+
throw new EmptyResultError('weixin save-articles', `No published articles were found for ${fakeid}.`);
|
|
191
|
+
}
|
|
192
|
+
} catch (primaryError) {
|
|
193
|
+
if (authSource !== 'browser' || !isEligibleArticleFallbackError(primaryError)) throw primaryError;
|
|
194
|
+
const accountName = String(args.name ?? '').trim();
|
|
195
|
+
if (!accountName) throw withMissingFallbackName('weixin save-articles', primaryError);
|
|
196
|
+
try {
|
|
197
|
+
const fallback = await collectSogouAccountArticles({
|
|
198
|
+
page, accountName, limit: args.limit, maxPages: args['max-pages'],
|
|
199
|
+
resolutionPolicy: 'rows',
|
|
200
|
+
});
|
|
201
|
+
articles = fallback.articles;
|
|
202
|
+
resolutionFailures = fallback.resolutionFailures;
|
|
203
|
+
source = fallback.source;
|
|
204
|
+
coverage = fallback.coverage;
|
|
205
|
+
} catch (fallbackError) {
|
|
206
|
+
throw combineArticleFallbackErrors({
|
|
207
|
+
operation: 'weixin save-articles', primaryError, fallbackError, credentials,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const savedRows = articles.length === 0 ? [] : await callCrawler(() => saveArticles({
|
|
174
213
|
articles, accountName: String(args.name ?? '').trim(),
|
|
175
214
|
outputDir: args.output ?? './weixin-articles', fetchArticleHtml: articleHtmlDownloader,
|
|
176
215
|
buildMarkdown: (article, html) => wechatArticleToMarkdown({
|
|
177
216
|
html, title: article.title, accountName: String(args.name ?? '').trim(), author: article.author,
|
|
178
217
|
publishedAt: article.publishedAt, digest: article.digest, url: article.url,
|
|
179
218
|
}), existingFilePolicy: 'suffix',
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
|
|
219
|
+
}));
|
|
220
|
+
const orderedRows = source === 'sogou'
|
|
221
|
+
? [
|
|
222
|
+
...savedRows.map((row, index) => ({ ...row, order: articles[index]?.order ?? index })),
|
|
223
|
+
...resolutionFailures,
|
|
224
|
+
].sort((left, right) => left.order - right.order)
|
|
225
|
+
: savedRows;
|
|
226
|
+
return orderedRows.map(row => ({
|
|
183
227
|
title: row.title, status: row.status, stage: row.stage || null, path: row.saved || null,
|
|
184
|
-
error: row.error || null, url: row.url,
|
|
228
|
+
error: row.error || null, url: row.url, source, coverage,
|
|
185
229
|
}));
|
|
186
230
|
},
|
|
187
231
|
});
|
package/clis/weixin/search.js
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EmptyResultError } from '@sovovs/bycli/errors';
|
|
2
2
|
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
|
+
import {
|
|
4
|
+
normalizePositiveInteger,
|
|
5
|
+
searchSogouArticlePage,
|
|
6
|
+
} from './_wechat/sogou-search.js';
|
|
3
7
|
|
|
4
8
|
const SOGOU_WEIXIN_DOMAIN = 'weixin.sogou.com';
|
|
5
9
|
const DEFAULT_PAGE = 1;
|
|
6
10
|
const DEFAULT_LIMIT = 10;
|
|
7
11
|
const MAX_LIMIT = 10;
|
|
8
12
|
|
|
9
|
-
function normalizePositiveInteger(value, name, defaultValue, maxValue) {
|
|
10
|
-
if (value === undefined || value === null) return defaultValue;
|
|
11
|
-
const text = String(value).trim();
|
|
12
|
-
if (!/^\d+$/.test(text)) {
|
|
13
|
-
throw new ArgumentError(
|
|
14
|
-
`weixin sougousearch --${name} must be a positive integer`,
|
|
15
|
-
`Pass --${name} as a whole number${maxValue ? ` from 1 to ${maxValue}` : ' greater than 0'}.`,
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
const parsed = Number(text);
|
|
19
|
-
if (!Number.isSafeInteger(parsed) || parsed < 1 || (maxValue && parsed > maxValue)) {
|
|
20
|
-
throw new ArgumentError(
|
|
21
|
-
`weixin sougousearch --${name} is out of range`,
|
|
22
|
-
`Pass --${name} as a whole number${maxValue ? ` from 1 to ${maxValue}` : ' greater than 0'}.`,
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
return parsed;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
13
|
function normalizePage(page) {
|
|
29
14
|
return normalizePositiveInteger(page, 'page', DEFAULT_PAGE);
|
|
30
15
|
}
|
|
@@ -33,63 +18,6 @@ function normalizeLimit(limit) {
|
|
|
33
18
|
return normalizePositiveInteger(limit, 'limit', DEFAULT_LIMIT, MAX_LIMIT);
|
|
34
19
|
}
|
|
35
20
|
|
|
36
|
-
function buildSearchUrl(query, pageNo) {
|
|
37
|
-
const searchUrl = new URL('https://weixin.sogou.com/weixin');
|
|
38
|
-
searchUrl.searchParams.set('query', query);
|
|
39
|
-
searchUrl.searchParams.set('type', '2');
|
|
40
|
-
searchUrl.searchParams.set('page', String(pageNo));
|
|
41
|
-
searchUrl.searchParams.set('ie', 'utf8');
|
|
42
|
-
return searchUrl.toString();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function buildExtractSearchResultsEvaluate() {
|
|
46
|
-
return String.raw`(() => {
|
|
47
|
-
const clean = (value) => {
|
|
48
|
-
return (value || '')
|
|
49
|
-
.replace(/\s+/g, ' ')
|
|
50
|
-
.replace(/<!--red_beg-->|<!--red_end-->/g, '')
|
|
51
|
-
.replace(/document\.write\(timeConvert\('\d+'\)\)/g, '')
|
|
52
|
-
.trim();
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const absolutize = (href) => {
|
|
56
|
-
if (!href) return '';
|
|
57
|
-
try {
|
|
58
|
-
return new URL(href, window.location.origin).toString();
|
|
59
|
-
} catch {
|
|
60
|
-
return href;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const bodyText = clean(document.body && document.body.innerText);
|
|
65
|
-
const blocked = /验证码|安全验证|异常访问|访问过于频繁|请输入验证码/.test(bodyText);
|
|
66
|
-
const empty = /没有找到相关的微信文章|未找到相关|暂无相关|没有找到/.test(bodyText)
|
|
67
|
-
|| Boolean(document.querySelector('.no-result, .no_result, .s-noresult'));
|
|
68
|
-
const cards = Array.from(document.querySelectorAll('.news-list li'));
|
|
69
|
-
const extracted = cards.map((item) => {
|
|
70
|
-
const linkEl = item.querySelector('h3 a[href]');
|
|
71
|
-
const summaryEl = item.querySelector('p.txt-info');
|
|
72
|
-
const accountEl = item.querySelector('.s-p .all-time-y2');
|
|
73
|
-
const timeEl = item.querySelector('.s-p .s2');
|
|
74
|
-
return {
|
|
75
|
-
title: clean(linkEl && linkEl.textContent),
|
|
76
|
-
account: clean(accountEl && accountEl.textContent),
|
|
77
|
-
url: absolutize(linkEl && linkEl.getAttribute('href')),
|
|
78
|
-
summary: clean(summaryEl && summaryEl.textContent),
|
|
79
|
-
publish_time: clean(timeEl && timeEl.textContent),
|
|
80
|
-
};
|
|
81
|
-
});
|
|
82
|
-
const rows = extracted.filter((row) => row.title && row.url);
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
blocked,
|
|
86
|
-
empty,
|
|
87
|
-
invalidCount: extracted.length - rows.length,
|
|
88
|
-
rows,
|
|
89
|
-
};
|
|
90
|
-
})()`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
21
|
export const weixinSearchCommand = cli({
|
|
94
22
|
site: 'weixin',
|
|
95
23
|
name: 'sougousearch',
|
|
@@ -106,50 +34,20 @@ export const weixinSearchCommand = cli({
|
|
|
106
34
|
columns: ['rank', 'page', 'title', 'account', 'url', 'summary', 'publish_time'],
|
|
107
35
|
func: async (page, kwargs) => {
|
|
108
36
|
const query = String(kwargs.query ?? '').trim();
|
|
109
|
-
if (!query) {
|
|
110
|
-
throw new ArgumentError('A search query is required.', 'Pass a non-empty keyword to search Weixin articles via Sogou.');
|
|
111
|
-
}
|
|
112
|
-
|
|
113
37
|
const pageNo = normalizePage(kwargs.page);
|
|
114
38
|
const limit = normalizeLimit(kwargs.limit);
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
let payload;
|
|
118
|
-
try {
|
|
119
|
-
await page.goto(searchUrl);
|
|
120
|
-
await page.wait(2);
|
|
121
|
-
payload = await page.evaluate(buildExtractSearchResultsEvaluate());
|
|
122
|
-
} catch (error) {
|
|
123
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
124
|
-
throw new CommandExecutionError('weixin sougousearch failed while loading Sogou results', detail);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) {
|
|
128
|
-
throw new CommandExecutionError('weixin sougousearch returned an unreadable browser payload', 'Sogou Weixin may have changed its result page structure.');
|
|
129
|
-
}
|
|
130
|
-
if (payload.blocked) {
|
|
131
|
-
throw new CommandExecutionError('Sogou Weixin blocked this search request', 'Open weixin.sogou.com in Chrome and complete any verification before retrying.');
|
|
132
|
-
}
|
|
133
|
-
if (payload.invalidCount > 0) {
|
|
134
|
-
throw new CommandExecutionError('Sogou Weixin returned article cards without required title or URL', 'The result page structure may have changed; refusing to return a partial result set.');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const rows = payload.rows;
|
|
138
|
-
if (rows.length === 0 && payload.empty) {
|
|
39
|
+
const result = await searchSogouArticlePage(page, { query, pageNo });
|
|
40
|
+
if (result.state === 'empty') {
|
|
139
41
|
throw new EmptyResultError('weixin sougousearch', 'Try a different keyword or a different page number.');
|
|
140
42
|
}
|
|
141
|
-
|
|
142
|
-
throw new CommandExecutionError('weixin sougousearch did not expose article result cards', 'Sogou Weixin may have changed its selectors or returned a transient shell page.');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return rows.slice(0, limit).map((row, index) => ({
|
|
43
|
+
return result.rows.slice(0, limit).map((row, index) => ({
|
|
146
44
|
rank: (pageNo - 1) * 10 + index + 1,
|
|
147
45
|
page: pageNo,
|
|
148
46
|
title: row.title,
|
|
149
47
|
account: row.account,
|
|
150
48
|
url: row.url,
|
|
151
49
|
summary: row.summary,
|
|
152
|
-
publish_time: row.
|
|
50
|
+
publish_time: row.publishTime,
|
|
153
51
|
}));
|
|
154
52
|
},
|
|
155
53
|
});
|
|
@@ -158,6 +56,4 @@ export const __test__ = {
|
|
|
158
56
|
MAX_LIMIT,
|
|
159
57
|
normalizePage,
|
|
160
58
|
normalizeLimit,
|
|
161
|
-
buildSearchUrl,
|
|
162
|
-
buildExtractSearchResultsEvaluate,
|
|
163
59
|
};
|