@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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError,
|
|
3
|
+
} from '@sovovs/bycli/errors';
|
|
4
|
+
import { resolveWechatArticleUrl } from './article-link.js';
|
|
5
|
+
import { redactText } from './redact.js';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_SOGOU_MAX_PAGES,
|
|
8
|
+
normalizePositiveInteger,
|
|
9
|
+
searchSogouArticlePage,
|
|
10
|
+
} from './sogou-search.js';
|
|
11
|
+
|
|
12
|
+
const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export function isExactAccountName(actual, expected) {
|
|
15
|
+
const normalizedActual = String(actual ?? '').trim().toLowerCase();
|
|
16
|
+
const normalizedExpected = String(expected ?? '').trim().toLowerCase();
|
|
17
|
+
return normalizedExpected.length > 0 && normalizedActual === normalizedExpected;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizedUrl(raw) {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(raw).href;
|
|
23
|
+
} catch {
|
|
24
|
+
return String(raw ?? '').trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function comparableTimestamp(raw) {
|
|
29
|
+
const value = Number(raw);
|
|
30
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
31
|
+
return value >= 1_000_000_000_000 ? Math.floor(value / 1000) : value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validCstEpochSeconds(year, month, day, hour, minute, second) {
|
|
35
|
+
const epochMs = Date.UTC(year, month - 1, day, hour, minute, second) - CST_OFFSET_MS;
|
|
36
|
+
const check = new Date(epochMs + CST_OFFSET_MS);
|
|
37
|
+
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1
|
|
38
|
+
|| check.getUTCDate() !== day || check.getUTCHours() !== hour
|
|
39
|
+
|| check.getUTCMinutes() !== minute || check.getUTCSeconds() !== second) return null;
|
|
40
|
+
return Math.floor(epochMs / 1000);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function normalizeSogouPublishTimestamp({
|
|
44
|
+
publishTimestamp,
|
|
45
|
+
publishTime,
|
|
46
|
+
scanStartedAt,
|
|
47
|
+
}) {
|
|
48
|
+
const raw = comparableTimestamp(publishTimestamp);
|
|
49
|
+
if (raw !== null) return Math.floor(raw);
|
|
50
|
+
const startMs = Number(scanStartedAt);
|
|
51
|
+
if (!Number.isFinite(startMs)) return null;
|
|
52
|
+
const text = String(publishTime ?? '').trim();
|
|
53
|
+
|
|
54
|
+
const relative = text.match(/^(\d+)(分钟|小时|天)前$/);
|
|
55
|
+
if (relative) {
|
|
56
|
+
const amount = Number(relative[1]);
|
|
57
|
+
const secondsPerUnit = { 分钟: 60, 小时: 3600, 天: 86400 }[relative[2]];
|
|
58
|
+
if (!Number.isSafeInteger(amount)) return null;
|
|
59
|
+
const timestamp = Math.floor(startMs / 1000) - amount * secondsPerUnit;
|
|
60
|
+
return Number.isSafeInteger(timestamp) && timestamp > 0 ? timestamp : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const dayWord = text.match(/^(昨天|前天)(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
64
|
+
if (dayWord) {
|
|
65
|
+
const cstNow = new Date(startMs + CST_OFFSET_MS);
|
|
66
|
+
const dayOffset = dayWord[1] === '昨天' ? 1 : 2;
|
|
67
|
+
const clock = dayWord[2] === undefined
|
|
68
|
+
? [cstNow.getUTCHours(), cstNow.getUTCMinutes(), cstNow.getUTCSeconds()]
|
|
69
|
+
: [Number(dayWord[2]), Number(dayWord[3]), Number(dayWord[4] ?? 0)];
|
|
70
|
+
const prior = new Date(Date.UTC(
|
|
71
|
+
cstNow.getUTCFullYear(), cstNow.getUTCMonth(), cstNow.getUTCDate() - dayOffset,
|
|
72
|
+
));
|
|
73
|
+
return validCstEpochSeconds(
|
|
74
|
+
prior.getUTCFullYear(), prior.getUTCMonth() + 1, prior.getUTCDate(), ...clock,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const absolute = text.match(
|
|
79
|
+
/^(\d{4})(?:-(\d{1,2})-(\d{1,2})|\/(\d{1,2})\/(\d{1,2})|年(\d{1,2})月(\d{1,2})日)(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/,
|
|
80
|
+
);
|
|
81
|
+
if (!absolute) return null;
|
|
82
|
+
const month = Number(absolute[2] ?? absolute[4] ?? absolute[6]);
|
|
83
|
+
const day = Number(absolute[3] ?? absolute[5] ?? absolute[7]);
|
|
84
|
+
return validCstEpochSeconds(
|
|
85
|
+
Number(absolute[1]), month, day,
|
|
86
|
+
Number(absolute[8] ?? 0), Number(absolute[9] ?? 0), Number(absolute[10] ?? 0),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function safeResolutionError(error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : 'Sogou article link resolution failed';
|
|
92
|
+
return redactText(message, []) || 'Sogou article link resolution failed';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function collectSogouAccountArticles({
|
|
96
|
+
page,
|
|
97
|
+
accountName,
|
|
98
|
+
limit,
|
|
99
|
+
maxPages,
|
|
100
|
+
searchPage = searchSogouArticlePage,
|
|
101
|
+
resolveUrl = resolveWechatArticleUrl,
|
|
102
|
+
resolutionPolicy = 'atomic',
|
|
103
|
+
scanStartedAt = Date.now(),
|
|
104
|
+
}) {
|
|
105
|
+
const normalizedName = String(accountName ?? '').trim();
|
|
106
|
+
if (!normalizedName) {
|
|
107
|
+
throw new ArgumentError(
|
|
108
|
+
'weixin Sogou fallback requires --name',
|
|
109
|
+
'Pass the exact official-account name with --name.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (!['atomic', 'rows'].includes(resolutionPolicy)) {
|
|
113
|
+
throw new ArgumentError('Invalid Sogou fallback resolution policy');
|
|
114
|
+
}
|
|
115
|
+
const pageLimit = normalizePositiveInteger(
|
|
116
|
+
maxPages,
|
|
117
|
+
'max-pages',
|
|
118
|
+
DEFAULT_SOGOU_MAX_PAGES,
|
|
119
|
+
);
|
|
120
|
+
const articleLimit = limit === undefined || limit === null
|
|
121
|
+
? null : normalizePositiveInteger(limit, 'limit');
|
|
122
|
+
const seenFingerprints = new Set();
|
|
123
|
+
const seenSogouUrls = new Set();
|
|
124
|
+
const candidates = [];
|
|
125
|
+
let pagesScanned = 0;
|
|
126
|
+
let coverage = 'max-pages-reached';
|
|
127
|
+
let firstSeen = 0;
|
|
128
|
+
|
|
129
|
+
for (let pageNo = 1; pageNo <= pageLimit; pageNo += 1) {
|
|
130
|
+
const result = await searchPage(page, { query: normalizedName, pageNo });
|
|
131
|
+
pagesScanned += 1;
|
|
132
|
+
if (result.state === 'empty') {
|
|
133
|
+
coverage = 'search-exhausted';
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (seenFingerprints.has(result.fingerprint)) {
|
|
137
|
+
throw new CommandExecutionError(
|
|
138
|
+
'Sogou Weixin repeated a result page while scanning account articles',
|
|
139
|
+
`Page ${pageNo} repeated an earlier page; refusing to return a partial article index.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
seenFingerprints.add(result.fingerprint);
|
|
143
|
+
for (const item of result.rows) {
|
|
144
|
+
if (!isExactAccountName(item.account, normalizedName)) continue;
|
|
145
|
+
const sourceKey = normalizedUrl(item.url);
|
|
146
|
+
if (seenSogouUrls.has(sourceKey)) continue;
|
|
147
|
+
seenSogouUrls.add(sourceKey);
|
|
148
|
+
candidates.push({
|
|
149
|
+
...item,
|
|
150
|
+
firstSeen,
|
|
151
|
+
sourceKey,
|
|
152
|
+
normalizedTimestamp: normalizeSogouPublishTimestamp({
|
|
153
|
+
publishTimestamp: item.publishTimestamp,
|
|
154
|
+
publishTime: item.publishTime,
|
|
155
|
+
scanStartedAt,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
firstSeen += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (candidates.length === 0) {
|
|
163
|
+
const coverageHint = coverage === 'max-pages-reached'
|
|
164
|
+
? `Scanned ${pagesScanned} pages and reached the page cap; later pages may still contain a match.`
|
|
165
|
+
: `Sogou search exhausted after ${pagesScanned} pages.`;
|
|
166
|
+
throw new EmptyResultError(
|
|
167
|
+
'weixin Sogou account fallback',
|
|
168
|
+
`No Sogou articles matched the exact official-account name "${normalizedName}"; similarly named accounts were excluded. ${coverageHint}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
candidates.sort((left, right) => {
|
|
173
|
+
const leftTime = left.normalizedTimestamp;
|
|
174
|
+
const rightTime = right.normalizedTimestamp;
|
|
175
|
+
if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return rightTime - leftTime;
|
|
176
|
+
if (leftTime !== null && rightTime === null) return -1;
|
|
177
|
+
if (leftTime === null && rightTime !== null) return 1;
|
|
178
|
+
return left.firstSeen - right.firstSeen;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const seenResolvedUrls = new Set();
|
|
182
|
+
const articles = [];
|
|
183
|
+
const resolutionFailures = [];
|
|
184
|
+
let terminalCount = 0;
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
if (articleLimit !== null && terminalCount >= articleLimit) break;
|
|
187
|
+
try {
|
|
188
|
+
const resolved = await resolveUrl(page, candidate.url);
|
|
189
|
+
const resolvedKey = normalizedUrl(resolved.resolvedUrl);
|
|
190
|
+
if (seenResolvedUrls.has(resolvedKey)) continue;
|
|
191
|
+
seenResolvedUrls.add(resolvedKey);
|
|
192
|
+
articles.push({
|
|
193
|
+
title: candidate.title,
|
|
194
|
+
author: null,
|
|
195
|
+
digest: candidate.summary || null,
|
|
196
|
+
publishedAt: candidate.publishTime || null,
|
|
197
|
+
url: resolved.resolvedUrl,
|
|
198
|
+
sourceUrl: resolved.sourceUrl,
|
|
199
|
+
order: terminalCount,
|
|
200
|
+
});
|
|
201
|
+
terminalCount += 1;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof AuthRequiredError || resolutionPolicy === 'atomic') throw error;
|
|
204
|
+
resolutionFailures.push({
|
|
205
|
+
title: candidate.title,
|
|
206
|
+
status: 'failed',
|
|
207
|
+
stage: 'resolve',
|
|
208
|
+
error: safeResolutionError(error),
|
|
209
|
+
url: candidate.url,
|
|
210
|
+
order: terminalCount,
|
|
211
|
+
});
|
|
212
|
+
terminalCount += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
source: 'sogou',
|
|
218
|
+
coverage,
|
|
219
|
+
pagesScanned,
|
|
220
|
+
articles,
|
|
221
|
+
resolutionFailures,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentError, AuthRequiredError, CommandExecutionError,
|
|
3
|
+
} from '@sovovs/bycli/errors';
|
|
4
|
+
|
|
5
|
+
const SOGOU_WEIXIN_DOMAIN = 'weixin.sogou.com';
|
|
6
|
+
export const DEFAULT_SOGOU_MAX_PAGES = 50;
|
|
7
|
+
|
|
8
|
+
export function normalizePositiveInteger(value, name, defaultValue, maxValue) {
|
|
9
|
+
if (value === undefined || value === null) return defaultValue;
|
|
10
|
+
const text = String(value).trim();
|
|
11
|
+
if (!/^\d+$/.test(text)) {
|
|
12
|
+
throw new ArgumentError(
|
|
13
|
+
`weixin sougousearch --${name} must be a positive integer`,
|
|
14
|
+
`Pass --${name} as a whole number${maxValue ? ` from 1 to ${maxValue}` : ' greater than 0'}.`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const parsed = Number(text);
|
|
18
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || (maxValue && parsed > maxValue)) {
|
|
19
|
+
throw new ArgumentError(
|
|
20
|
+
`weixin sougousearch --${name} is out of range`,
|
|
21
|
+
`Pass --${name} as a whole number${maxValue ? ` from 1 to ${maxValue}` : ' greater than 0'}.`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildSogouSearchUrl(query, pageNo) {
|
|
28
|
+
const searchUrl = new URL('https://weixin.sogou.com/weixin');
|
|
29
|
+
searchUrl.searchParams.set('query', query);
|
|
30
|
+
searchUrl.searchParams.set('type', '2');
|
|
31
|
+
searchUrl.searchParams.set('page', String(pageNo));
|
|
32
|
+
searchUrl.searchParams.set('ie', 'utf8');
|
|
33
|
+
return searchUrl.toString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildExtractSogouSearchResultsEvaluate() {
|
|
37
|
+
return String.raw`(() => {
|
|
38
|
+
const clean = (value) => {
|
|
39
|
+
return (value || '')
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.replace(/<!--red_beg-->|<!--red_end-->/g, '')
|
|
42
|
+
.replace(/document\.write\(timeConvert\(['"]\d+['"]\)\)/g, '')
|
|
43
|
+
.trim();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const absolutize = (href) => {
|
|
47
|
+
if (!href) return '';
|
|
48
|
+
try {
|
|
49
|
+
return new URL(href, window.location.origin).toString();
|
|
50
|
+
} catch {
|
|
51
|
+
return href;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const bodyText = clean(document.body && document.body.innerText);
|
|
56
|
+
const blocked = /验证码|安全验证|异常访问|访问过于频繁|请输入验证码/.test(bodyText);
|
|
57
|
+
const empty = /没有找到相关的微信文章|未找到相关|暂无相关|没有找到/.test(bodyText)
|
|
58
|
+
|| Boolean(document.querySelector('.no-result, .no_result, .s-noresult'));
|
|
59
|
+
const cards = Array.from(document.querySelectorAll('.news-list li'));
|
|
60
|
+
const extracted = cards.map((item) => {
|
|
61
|
+
const linkEl = item.querySelector('h3 a[href]');
|
|
62
|
+
const summaryEl = item.querySelector('p.txt-info');
|
|
63
|
+
const accountEl = item.querySelector('.s-p .all-time-y2');
|
|
64
|
+
const timeEl = item.querySelector('.s-p .s2');
|
|
65
|
+
const rawTimeHtml = timeEl && timeEl.innerHTML || '';
|
|
66
|
+
const timestampMatch = rawTimeHtml.match(/timeConvert\(['"](\d{10,13})['"]\)/);
|
|
67
|
+
return {
|
|
68
|
+
title: clean(linkEl && linkEl.textContent),
|
|
69
|
+
account: clean(accountEl && accountEl.textContent),
|
|
70
|
+
url: absolutize(linkEl && linkEl.getAttribute('href')),
|
|
71
|
+
summary: clean(summaryEl && summaryEl.textContent),
|
|
72
|
+
publishTime: clean(timeEl && timeEl.textContent),
|
|
73
|
+
publishTimestamp: timestampMatch ? Number(timestampMatch[1]) : null,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const rows = extracted.filter((row) => row.title && row.url);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
blocked,
|
|
80
|
+
empty,
|
|
81
|
+
invalidCount: extracted.length - rows.length,
|
|
82
|
+
rows,
|
|
83
|
+
};
|
|
84
|
+
})()`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fingerprintRows(rows) {
|
|
88
|
+
return rows.map(row => `${row.title}\u0000${row.url}`).join('\u0001');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function searchSogouArticlePage(page, { query, pageNo }) {
|
|
92
|
+
const normalizedQuery = String(query ?? '').trim();
|
|
93
|
+
if (!normalizedQuery) {
|
|
94
|
+
throw new ArgumentError(
|
|
95
|
+
'A search query is required.',
|
|
96
|
+
'Pass a non-empty keyword to search Weixin articles via Sogou.',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const normalizedPage = normalizePositiveInteger(pageNo, 'page', 1);
|
|
100
|
+
const searchUrl = buildSogouSearchUrl(normalizedQuery, normalizedPage);
|
|
101
|
+
let payload;
|
|
102
|
+
try {
|
|
103
|
+
await page.goto(searchUrl);
|
|
104
|
+
await page.wait(2);
|
|
105
|
+
payload = await page.evaluate(buildExtractSogouSearchResultsEvaluate());
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
108
|
+
throw new CommandExecutionError('weixin sougousearch failed while loading Sogou results', detail);
|
|
109
|
+
}
|
|
110
|
+
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) {
|
|
111
|
+
throw new CommandExecutionError(
|
|
112
|
+
'weixin sougousearch returned an unreadable browser payload',
|
|
113
|
+
'Sogou Weixin may have changed its result page structure.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (payload.blocked) {
|
|
117
|
+
throw new AuthRequiredError(
|
|
118
|
+
SOGOU_WEIXIN_DOMAIN,
|
|
119
|
+
'Sogou Weixin requires verification. Complete it in the open browser tab and run the command again.',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (payload.invalidCount > 0) {
|
|
123
|
+
throw new CommandExecutionError(
|
|
124
|
+
'Sogou Weixin returned article cards without required title or URL',
|
|
125
|
+
'The result page structure may have changed; refusing to return a partial result set.',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (payload.rows.length === 0 && payload.empty) {
|
|
129
|
+
return { state: 'empty', page: normalizedPage, fingerprint: '', rows: [] };
|
|
130
|
+
}
|
|
131
|
+
if (payload.rows.length === 0) {
|
|
132
|
+
throw new CommandExecutionError(
|
|
133
|
+
'weixin sougousearch did not expose article result cards',
|
|
134
|
+
'Sogou Weixin may have changed its selectors or returned a transient shell page.',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
state: 'results',
|
|
139
|
+
page: normalizedPage,
|
|
140
|
+
fingerprint: fingerprintRows(payload.rows),
|
|
141
|
+
rows: payload.rows,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/clis/weixin/articles.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
2
2
|
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
3
3
|
import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
combineArticleFallbackErrors,
|
|
6
|
+
isEligibleArticleFallbackError,
|
|
7
|
+
withMissingFallbackName,
|
|
8
|
+
} from './_wechat/article-fallback-policy.js';
|
|
9
|
+
import { createArticleIndexFetcher } from './_wechat/article-index.js';
|
|
10
|
+
import { callCrawler, collectArticles } from './_wechat/crawler-runtime.js';
|
|
5
11
|
import { readAuthSource } from './_wechat/args.js';
|
|
12
|
+
import { collectSogouAccountArticles } from './_wechat/sogou-fallback.js';
|
|
6
13
|
|
|
7
14
|
const DOMAIN = 'mp.weixin.qq.com';
|
|
8
15
|
const browserRequired = args => readAuthSource(args) === 'browser';
|
|
@@ -13,24 +20,48 @@ export const articlesCommand = cli({
|
|
|
13
20
|
strategy: Strategy.COOKIE, browser: browserRequired,
|
|
14
21
|
args: [
|
|
15
22
|
{ name: 'fakeid', positional: true, required: true, help: 'Official-account fakeid returned by weixin accounts' },
|
|
16
|
-
{ name: 'name', help: '
|
|
23
|
+
{ name: 'name', help: 'Official-account name; exact case-insensitive match required for browser Sogou fallback' }, { name: 'limit', type: 'int', help: 'Maximum number of articles to return' }, { name: 'max-pages', type: 'int', help: 'Maximum number of history pages to scan' },
|
|
17
24
|
{ name: 'auth-source', default: 'browser', choices: ['browser', 'env'], help: 'Credential source: browser session or environment variables' },
|
|
18
25
|
],
|
|
19
|
-
columns: ['title', 'author', 'digest', 'publishedAt', 'url'],
|
|
26
|
+
columns: ['title', 'author', 'digest', 'publishedAt', 'url', 'source', 'coverage'],
|
|
20
27
|
func: async (page, args) => {
|
|
21
28
|
const fakeid = String(args.fakeid ?? '').trim();
|
|
22
29
|
if (!fakeid) throw new ArgumentError('fakeid is required');
|
|
23
30
|
const authSource = readAuthSource(args);
|
|
24
31
|
const credentials = authSource === 'env'
|
|
25
32
|
? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
|
|
26
|
-
const {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
const fetchPage = createArticleIndexFetcher({ page, source: authSource, credentials });
|
|
34
|
+
let articles;
|
|
35
|
+
let source = 'wechat';
|
|
36
|
+
let coverage = null;
|
|
37
|
+
try {
|
|
38
|
+
const result = await callCrawler(() => collectArticles({
|
|
39
|
+
fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'],
|
|
40
|
+
}));
|
|
41
|
+
articles = result.articles;
|
|
42
|
+
if (articles.length === 0) {
|
|
43
|
+
throw new EmptyResultError('weixin articles', `No published articles were found for ${fakeid}.`);
|
|
44
|
+
}
|
|
45
|
+
} catch (primaryError) {
|
|
46
|
+
if (authSource !== 'browser' || !isEligibleArticleFallbackError(primaryError)) throw primaryError;
|
|
47
|
+
const accountName = String(args.name ?? '').trim();
|
|
48
|
+
if (!accountName) throw withMissingFallbackName('weixin articles', primaryError);
|
|
49
|
+
try {
|
|
50
|
+
const fallback = await collectSogouAccountArticles({
|
|
51
|
+
page, accountName, limit: args.limit, maxPages: args['max-pages'],
|
|
52
|
+
});
|
|
53
|
+
articles = fallback.articles;
|
|
54
|
+
source = fallback.source;
|
|
55
|
+
coverage = fallback.coverage;
|
|
56
|
+
} catch (fallbackError) {
|
|
57
|
+
throw combineArticleFallbackErrors({
|
|
58
|
+
operation: 'weixin articles', primaryError, fallbackError, credentials,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
31
62
|
return articles.map(article => ({
|
|
32
63
|
title: article.title, author: article.author || null, digest: article.digest || null,
|
|
33
|
-
publishedAt: article.publishedAt || null, url: article.url,
|
|
64
|
+
publishedAt: article.publishedAt || null, url: article.url, source, coverage,
|
|
34
65
|
}));
|
|
35
66
|
},
|
|
36
67
|
});
|
|
@@ -1,8 +1,61 @@
|
|
|
1
|
+
import * as nodeFs from 'node:fs';
|
|
2
|
+
import * as nodePath from 'node:path';
|
|
1
3
|
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
-
import { CommandExecutionError } from '@sovovs/bycli/errors';
|
|
4
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
3
5
|
|
|
4
6
|
const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
|
|
5
7
|
const WEIXIN_HOME = 'https://mp.weixin.qq.com/';
|
|
8
|
+
const MAX_TITLE_LENGTH = 64;
|
|
9
|
+
const MAX_AUTHOR_LENGTH = 8;
|
|
10
|
+
const SUPPORTED_COVER_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
11
|
+
|
|
12
|
+
function codePointLength(value) {
|
|
13
|
+
return [...value].length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requiredText(value, name) {
|
|
17
|
+
const text = String(value ?? '').trim();
|
|
18
|
+
if (!text) throw new ArgumentError(`${name} must not be empty`);
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateCoverImage(value) {
|
|
23
|
+
if (value === undefined || value === null) return null;
|
|
24
|
+
const coverPath = nodePath.resolve(requiredText(value, 'cover-image'));
|
|
25
|
+
const extension = nodePath.extname(coverPath).toLowerCase();
|
|
26
|
+
if (!SUPPORTED_COVER_EXTENSIONS.has(extension)) {
|
|
27
|
+
throw new ArgumentError('cover-image must be a jpg, jpeg, png, gif, or webp file');
|
|
28
|
+
}
|
|
29
|
+
const info = nodeFs.statSync(coverPath, { throwIfNoEntry: false });
|
|
30
|
+
if (!info?.isFile() || info.size <= 0) {
|
|
31
|
+
throw new ArgumentError(`cover-image must be a readable non-empty file: ${coverPath}`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
nodeFs.accessSync(coverPath, nodeFs.constants.R_OK);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new ArgumentError(`cover-image must be readable: ${coverPath}`);
|
|
37
|
+
}
|
|
38
|
+
return coverPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeCreateDraftArgs(kwargs) {
|
|
42
|
+
const title = requiredText(kwargs.title, 'title');
|
|
43
|
+
requiredText(kwargs.content, 'content');
|
|
44
|
+
if (codePointLength(title) > MAX_TITLE_LENGTH) {
|
|
45
|
+
throw new ArgumentError(`title must be at most ${MAX_TITLE_LENGTH} characters`);
|
|
46
|
+
}
|
|
47
|
+
const author = kwargs.author == null ? null : requiredText(kwargs.author, 'author');
|
|
48
|
+
if (author && codePointLength(author) > MAX_AUTHOR_LENGTH) {
|
|
49
|
+
throw new ArgumentError(`author must be at most ${MAX_AUTHOR_LENGTH} characters`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
title,
|
|
53
|
+
content: String(kwargs.content),
|
|
54
|
+
author,
|
|
55
|
+
summary: kwargs.summary == null ? null : String(kwargs.summary).trim(),
|
|
56
|
+
coverImage: validateCoverImage(kwargs['cover-image']),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
6
59
|
|
|
7
60
|
async function getToken(page) {
|
|
8
61
|
return page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
|
|
@@ -27,15 +80,18 @@ async function fillField(page, selector, value) {
|
|
|
27
80
|
return page.evaluate(`(() => {
|
|
28
81
|
var el = document.querySelector('${selector}');
|
|
29
82
|
if (!el) return { ok: false, reason: 'not found: ${selector}' };
|
|
83
|
+
var expected = ${JSON.stringify(value)};
|
|
30
84
|
el.focus();
|
|
31
85
|
var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
32
86
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
33
|
-
if (setter && setter.set) setter.set.call(el,
|
|
34
|
-
else el.value =
|
|
35
|
-
el.dispatchEvent(new InputEvent('input', { bubbles: true, data:
|
|
87
|
+
if (setter && setter.set) setter.set.call(el, expected);
|
|
88
|
+
else el.value = expected;
|
|
89
|
+
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: expected }));
|
|
36
90
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
37
91
|
el.blur();
|
|
38
|
-
return
|
|
92
|
+
return el.value === expected
|
|
93
|
+
? { ok: true, value: el.value }
|
|
94
|
+
: { ok: false, reason: 'value mismatch', value: el.value };
|
|
39
95
|
})()`);
|
|
40
96
|
}
|
|
41
97
|
|
|
@@ -49,10 +105,21 @@ async function fillContent(page, text) {
|
|
|
49
105
|
document.execCommand('selectAll', false, null);
|
|
50
106
|
document.execCommand('insertText', false, ${JSON.stringify(text)});
|
|
51
107
|
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
|
52
|
-
|
|
108
|
+
var normalize = value => String(value ?? '').replace(/\\r\\n?/g, '\\n').trim();
|
|
109
|
+
var expected = normalize(${JSON.stringify(text)});
|
|
110
|
+
var actual = normalize(editor.innerText ?? editor.textContent ?? '');
|
|
111
|
+
return actual === expected
|
|
112
|
+
? { ok: true, value: actual }
|
|
113
|
+
: { ok: false, reason: 'value mismatch', value: actual };
|
|
53
114
|
})()`);
|
|
54
115
|
}
|
|
55
116
|
|
|
117
|
+
function requirePageResult(result, label) {
|
|
118
|
+
if (!result?.ok) {
|
|
119
|
+
throw new CommandExecutionError(`Failed to fill ${label}: ${result?.reason ?? 'unverified page state'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
56
123
|
async function uploadContentImage(page, imagePath) {
|
|
57
124
|
const fs = await import('node:fs');
|
|
58
125
|
const path = await import('node:path');
|
|
@@ -76,15 +143,20 @@ async function uploadContentImage(page, imagePath) {
|
|
|
76
143
|
await page.wait(1);
|
|
77
144
|
|
|
78
145
|
await page.setFileInput([absPath], 'input[type="file"][name="file"]');
|
|
79
|
-
await page.wait(8);
|
|
80
146
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
147
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
148
|
+
await page.wait(1);
|
|
149
|
+
const cdnCount = await page.evaluate(`(() => {
|
|
150
|
+
var editors = document.querySelectorAll('#ueditor_0, div[contenteditable="true"]');
|
|
151
|
+
var count = 0;
|
|
152
|
+
editors.forEach(function(editor) {
|
|
153
|
+
count += editor.querySelectorAll('img[src*="mmbiz"], img[data-src*="mmbiz"]').length;
|
|
154
|
+
});
|
|
155
|
+
return count;
|
|
156
|
+
})()`);
|
|
157
|
+
if (cdnCount > 0) return;
|
|
87
158
|
}
|
|
159
|
+
throw new CommandExecutionError('Image did not upload to WeChat CDN');
|
|
88
160
|
}
|
|
89
161
|
|
|
90
162
|
async function selectCoverFromContent(page) {
|
|
@@ -134,18 +206,23 @@ async function selectCoverFromContent(page) {
|
|
|
134
206
|
if (btns[i].textContent.trim() === '确认' && btns[i].offsetHeight > 0 && !btns[i].disabled) { btns[i].click(); return; }
|
|
135
207
|
}
|
|
136
208
|
})()`);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
209
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
210
|
+
await page.wait(1);
|
|
211
|
+
const hasCover = await page.evaluate(`(() => {
|
|
212
|
+
var areas = document.querySelectorAll('#js_cover_area, #js_cover_description_area, #appmsgItem');
|
|
213
|
+
var found = false;
|
|
214
|
+
areas.forEach(function(area) {
|
|
215
|
+
if (area.querySelector('img[src*="mmbiz"], img[data-src*="mmbiz"]')) found = true;
|
|
216
|
+
[area].concat(Array.from(area.querySelectorAll('*'))).forEach(function(el) {
|
|
217
|
+
var bg = window.getComputedStyle(el).backgroundImage;
|
|
218
|
+
if (bg && bg.includes('mmbiz')) found = true;
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
return found;
|
|
222
|
+
})()`);
|
|
223
|
+
if (hasCover) return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
149
226
|
}
|
|
150
227
|
|
|
151
228
|
async function clickSaveDraft(page) {
|
|
@@ -167,7 +244,7 @@ async function clickSaveDraft(page) {
|
|
|
167
244
|
})()`);
|
|
168
245
|
if (saved) return true;
|
|
169
246
|
}
|
|
170
|
-
|
|
247
|
+
throw new CommandExecutionError('Draft save could not be confirmed');
|
|
171
248
|
}
|
|
172
249
|
|
|
173
250
|
export const createDraftCommand = cli({
|
|
@@ -190,37 +267,39 @@ export const createDraftCommand = cli({
|
|
|
190
267
|
columns: ['status', 'detail'],
|
|
191
268
|
|
|
192
269
|
func: async (page, kwargs) => {
|
|
270
|
+
const args = normalizeCreateDraftArgs(kwargs);
|
|
193
271
|
await navigateToEditor(page);
|
|
194
272
|
|
|
195
|
-
const titleResult = await fillField(page, 'textarea#title',
|
|
196
|
-
|
|
273
|
+
const titleResult = await fillField(page, 'textarea#title', args.title);
|
|
274
|
+
requirePageResult(titleResult, 'title');
|
|
197
275
|
|
|
198
|
-
if (
|
|
199
|
-
const authorResult = await fillField(page, 'input#author',
|
|
200
|
-
|
|
276
|
+
if (args.author) {
|
|
277
|
+
const authorResult = await fillField(page, 'input#author', args.author);
|
|
278
|
+
requirePageResult(authorResult, 'author');
|
|
201
279
|
}
|
|
202
280
|
|
|
203
|
-
const contentResult = await fillContent(page,
|
|
204
|
-
|
|
281
|
+
const contentResult = await fillContent(page, args.content);
|
|
282
|
+
requirePageResult(contentResult, 'content');
|
|
205
283
|
|
|
206
|
-
if (
|
|
207
|
-
await uploadContentImage(page,
|
|
284
|
+
if (args.coverImage) {
|
|
285
|
+
await uploadContentImage(page, args.coverImage);
|
|
208
286
|
const coverSet = await selectCoverFromContent(page);
|
|
209
287
|
if (!coverSet) {
|
|
210
|
-
|
|
288
|
+
throw new CommandExecutionError('Failed to set the requested cover image');
|
|
211
289
|
}
|
|
212
290
|
}
|
|
213
291
|
|
|
214
|
-
if (
|
|
215
|
-
await fillField(page, 'textarea#js_description',
|
|
292
|
+
if (args.summary) {
|
|
293
|
+
const summaryResult = await fillField(page, 'textarea#js_description', args.summary);
|
|
294
|
+
requirePageResult(summaryResult, 'summary');
|
|
216
295
|
}
|
|
217
296
|
|
|
218
297
|
await page.wait(1);
|
|
219
|
-
|
|
298
|
+
await clickSaveDraft(page);
|
|
220
299
|
|
|
221
300
|
return [{
|
|
222
|
-
status:
|
|
223
|
-
detail: `"${
|
|
301
|
+
status: 'draft saved',
|
|
302
|
+
detail: `"${args.title}"${args.author ? ` by ${args.author}` : ''}${args.coverImage ? ' (with cover)' : ''}`,
|
|
224
303
|
}];
|
|
225
304
|
},
|
|
226
305
|
});
|