@sovovs/bycli 2.1.0 → 2.1.1

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.
Files changed (33) hide show
  1. package/cli-manifest.json +169 -0
  2. package/clis/weixin/_wechat/args.js +48 -0
  3. package/clis/weixin/_wechat/article-content.js +53 -0
  4. package/clis/weixin/_wechat/article-service.js +124 -0
  5. package/clis/weixin/_wechat/auth-session.js +142 -0
  6. package/clis/weixin/_wechat/fingerprint.js +443 -0
  7. package/clis/weixin/_wechat/fixtures/articles-auth-expired.json +3 -0
  8. package/clis/weixin/_wechat/fixtures/articles-page.json +4 -0
  9. package/clis/weixin/_wechat/fixtures/search-auth-expired.json +4 -0
  10. package/clis/weixin/_wechat/fixtures/search-success.json +7 -0
  11. package/clis/weixin/_wechat/markdown.js +29 -0
  12. package/clis/weixin/_wechat/redact.js +405 -0
  13. package/clis/weixin/_wechat/save-service.js +175 -0
  14. package/clis/weixin/_wechat/search-biz.js +102 -0
  15. package/clis/weixin/_wechat/wechat-api.js +133 -0
  16. package/clis/weixin/accounts.js +38 -0
  17. package/clis/weixin/articles.js +35 -0
  18. package/clis/weixin/download.js +5 -47
  19. package/clis/weixin/save-articles.js +175 -0
  20. package/dist/src/download/article-download.d.ts +6 -0
  21. package/dist/src/download/article-download.js +78 -17
  22. package/dist/src/download/wechat-article.d.ts +8 -0
  23. package/dist/src/download/wechat-article.js +137 -0
  24. package/dist/src/download/wechat-article.test.d.ts +1 -0
  25. package/dist/src/recorder/highlevel/verify.d.ts +3 -0
  26. package/dist/src/recorder/highlevel/verify.js +4 -0
  27. package/dist/src/recorder/highlevel/verify.test.d.ts +1 -0
  28. package/dist/src/recorder/runner/runner-port.js +1 -0
  29. package/dist/src/recorder/runner/verify-runner-main.d.ts +17 -2
  30. package/dist/src/recorder/runner/verify-runner-main.js +70 -14
  31. package/dist/src/weixin-built-in-docs.test.d.ts +1 -0
  32. package/package.json +7 -3
  33. package/scripts/check-package-install.mjs +71 -0
package/cli-manifest.json CHANGED
@@ -27804,6 +27804,109 @@
27804
27804
  "sourceFile": "weibo/user-posts.js",
27805
27805
  "navigateBefore": "https://weibo.com"
27806
27806
  },
27807
+ {
27808
+ "site": "weixin",
27809
+ "name": "accounts",
27810
+ "description": "Search WeChat official accounts and return their fakeids",
27811
+ "access": "read",
27812
+ "domain": "mp.weixin.qq.com",
27813
+ "strategy": "intercept",
27814
+ "browser": "conditional",
27815
+ "args": [
27816
+ {
27817
+ "name": "query",
27818
+ "type": "str",
27819
+ "required": true,
27820
+ "positional": true,
27821
+ "help": "Official-account name to search for"
27822
+ },
27823
+ {
27824
+ "name": "limit",
27825
+ "type": "int",
27826
+ "default": 10,
27827
+ "required": false,
27828
+ "help": "Maximum number of matching accounts to return"
27829
+ },
27830
+ {
27831
+ "name": "auth-source",
27832
+ "type": "str",
27833
+ "default": "browser",
27834
+ "required": false,
27835
+ "help": "Credential source: browser session or environment variables",
27836
+ "choices": [
27837
+ "browser",
27838
+ "env"
27839
+ ]
27840
+ }
27841
+ ],
27842
+ "columns": [
27843
+ "nickname",
27844
+ "fakeid",
27845
+ "alias"
27846
+ ],
27847
+ "type": "js",
27848
+ "modulePath": "weixin/accounts.js",
27849
+ "sourceFile": "weixin/accounts.js",
27850
+ "navigateBefore": true
27851
+ },
27852
+ {
27853
+ "site": "weixin",
27854
+ "name": "articles",
27855
+ "description": "List published articles from a WeChat official account",
27856
+ "access": "read",
27857
+ "domain": "mp.weixin.qq.com",
27858
+ "strategy": "cookie",
27859
+ "browser": "conditional",
27860
+ "args": [
27861
+ {
27862
+ "name": "fakeid",
27863
+ "type": "str",
27864
+ "required": true,
27865
+ "positional": true,
27866
+ "help": "Official-account fakeid returned by weixin accounts"
27867
+ },
27868
+ {
27869
+ "name": "name",
27870
+ "type": "str",
27871
+ "required": false,
27872
+ "help": "Optional official-account name for display context"
27873
+ },
27874
+ {
27875
+ "name": "limit",
27876
+ "type": "int",
27877
+ "required": false,
27878
+ "help": "Maximum number of articles to return"
27879
+ },
27880
+ {
27881
+ "name": "max-pages",
27882
+ "type": "int",
27883
+ "required": false,
27884
+ "help": "Maximum number of history pages to scan"
27885
+ },
27886
+ {
27887
+ "name": "auth-source",
27888
+ "type": "str",
27889
+ "default": "browser",
27890
+ "required": false,
27891
+ "help": "Credential source: browser session or environment variables",
27892
+ "choices": [
27893
+ "browser",
27894
+ "env"
27895
+ ]
27896
+ }
27897
+ ],
27898
+ "columns": [
27899
+ "title",
27900
+ "author",
27901
+ "digest",
27902
+ "publishedAt",
27903
+ "url"
27904
+ ],
27905
+ "type": "js",
27906
+ "modulePath": "weixin/articles.js",
27907
+ "sourceFile": "weixin/articles.js",
27908
+ "navigateBefore": "https://mp.weixin.qq.com"
27909
+ },
27807
27910
  {
27808
27911
  "site": "weixin",
27809
27912
  "name": "create-draft",
@@ -27938,6 +28041,72 @@
27938
28041
  "sourceFile": "weixin/drafts.js",
27939
28042
  "navigateBefore": false
27940
28043
  },
28044
+ {
28045
+ "site": "weixin",
28046
+ "name": "save-articles",
28047
+ "description": "Download WeChat official-account articles as Markdown files",
28048
+ "access": "write",
28049
+ "domain": "mp.weixin.qq.com",
28050
+ "strategy": "cookie",
28051
+ "browser": "conditional",
28052
+ "args": [
28053
+ {
28054
+ "name": "fakeid",
28055
+ "type": "str",
28056
+ "required": true,
28057
+ "positional": true,
28058
+ "help": "Official-account fakeid returned by weixin accounts"
28059
+ },
28060
+ {
28061
+ "name": "name",
28062
+ "type": "str",
28063
+ "required": false,
28064
+ "help": "Official-account name used in Markdown metadata"
28065
+ },
28066
+ {
28067
+ "name": "output",
28068
+ "type": "str",
28069
+ "default": "./weixin-articles",
28070
+ "required": false,
28071
+ "help": "Directory for saved Markdown files"
28072
+ },
28073
+ {
28074
+ "name": "limit",
28075
+ "type": "int",
28076
+ "required": false,
28077
+ "help": "Maximum number of articles to save"
28078
+ },
28079
+ {
28080
+ "name": "max-pages",
28081
+ "type": "int",
28082
+ "required": false,
28083
+ "help": "Maximum number of history pages to scan"
28084
+ },
28085
+ {
28086
+ "name": "auth-source",
28087
+ "type": "str",
28088
+ "default": "browser",
28089
+ "required": false,
28090
+ "help": "Credential source: browser session or environment variables",
28091
+ "choices": [
28092
+ "browser",
28093
+ "env"
28094
+ ]
28095
+ }
28096
+ ],
28097
+ "columns": [
28098
+ "title",
28099
+ "status",
28100
+ "stage",
28101
+ "path",
28102
+ "error",
28103
+ "url"
28104
+ ],
28105
+ "type": "js",
28106
+ "modulePath": "weixin/save-articles.js",
28107
+ "sourceFile": "weixin/save-articles.js",
28108
+ "navigateBefore": "https://mp.weixin.qq.com"
28109
+ },
27941
28110
  {
27942
28111
  "site": "weixin",
27943
28112
  "name": "search",
@@ -0,0 +1,48 @@
1
+ import { ArgumentError } from '@sovovs/bycli/errors';
2
+
3
+ /**
4
+ * @param {Record<string, unknown>} args
5
+ * @returns {'browser' | 'env'}
6
+ */
7
+ export function readAuthSource(args) {
8
+ const value = args['auth-source'] ?? 'browser';
9
+ if (value === 'browser' || value === 'env') return value;
10
+ throw new ArgumentError('--auth-source must be one of: browser, env');
11
+ }
12
+
13
+ /**
14
+ * @param {Record<string, unknown>} args
15
+ * @param {string} key
16
+ * @param {number} [fallback]
17
+ * @returns {number | undefined}
18
+ */
19
+ export function readPositiveInteger(args, key, fallback) {
20
+ const raw = args[key] ?? fallback;
21
+ if (raw === undefined) return undefined;
22
+
23
+ if (typeof raw !== 'number' && typeof raw !== 'string') {
24
+ throw new ArgumentError(`--${key} must be a positive integer`);
25
+ }
26
+ if (typeof raw === 'string' && raw.trim() === '') {
27
+ throw new ArgumentError(`--${key} must be a positive integer`);
28
+ }
29
+
30
+ const value = Number(raw);
31
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
32
+ throw new ArgumentError(`--${key} must be a positive integer`);
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * @param {Record<string, unknown>} args
39
+ * @param {string} key
40
+ * @returns {string}
41
+ */
42
+ export function readRequiredString(args, key) {
43
+ const value = args[key];
44
+ if (typeof value !== 'string' || value.trim() === '') {
45
+ throw new ArgumentError(`--${key} is required`);
46
+ }
47
+ return value.trim();
48
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Extract the mutable WeChat article body. This function is deliberately
3
+ * self-contained so download.js can serialize the exact same implementation
4
+ * into page.evaluate().
5
+ */
6
+ export function extractWechatArticleContent(document) {
7
+ const result = { contentHtml: '', codeBlocks: [], imageUrls: [] };
8
+ const contentEl = document.querySelector('#js_content');
9
+ if (!contentEl) return result;
10
+
11
+ contentEl.querySelectorAll('img').forEach(img => {
12
+ const dataSrc = img.getAttribute('data-src');
13
+ if (dataSrc) img.setAttribute('src', dataSrc);
14
+ });
15
+ contentEl.querySelectorAll('[href], [src], [poster], [data-src]').forEach(el => {
16
+ ['href', 'src', 'poster', 'data-src'].forEach(name => {
17
+ if (!el.hasAttribute(name)) return;
18
+ const raw = (el.getAttribute(name) || '').trim();
19
+ const normalized = raw.startsWith('//') ? `https:${raw}` : raw;
20
+ try {
21
+ const url = new URL(normalized);
22
+ if (!['http:', 'https:'].includes(url.protocol)) el.removeAttribute(name);
23
+ else el.setAttribute(name, url.href);
24
+ } catch {
25
+ el.removeAttribute(name);
26
+ }
27
+ });
28
+ });
29
+ contentEl.querySelectorAll('.code-snippet__line-index').forEach(node => node.remove());
30
+ ['script', 'style', '.qr_code_pc', '.reward_area'].forEach(selector => {
31
+ contentEl.querySelectorAll(selector).forEach(node => node.remove());
32
+ });
33
+ const seen = new Set();
34
+ contentEl.querySelectorAll('img[src]').forEach(img => {
35
+ const src = img.getAttribute('src');
36
+ if (src && !seen.has(src)) {
37
+ seen.add(src);
38
+ result.imageUrls.push(src);
39
+ }
40
+ });
41
+ result.contentHtml = contentEl.innerHTML;
42
+ return result;
43
+ }
44
+
45
+ export function buildExtractWechatArticleContentJs() {
46
+ return `(${extractWechatArticleContent.toString()})`;
47
+ }
48
+
49
+ /** Node-side HTML counterpart used by history saving; owns all body cleanup. */
50
+ export function extractWechatArticleContentFromHtml(htmlInput) {
51
+ return { ...extractWechatArticleHtml(String(htmlInput || '')), codeBlocks: [] };
52
+ }
53
+ import { extractWechatArticleHtml } from '@sovovs/bycli/download/article-download';
@@ -0,0 +1,124 @@
1
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
2
+
3
+ export const MAX_PAGES = 100;
4
+ export const MAX_PAGE_SIZE = 10;
5
+ export const MAX_ARTICLES = 1000;
6
+
7
+ export function isTrustedWechatArticleUrl(value) {
8
+ try {
9
+ const url = new URL(value);
10
+ return url.protocol === 'https:'
11
+ && url.hostname === 'mp.weixin.qq.com'
12
+ && url.port === ''
13
+ && url.username === ''
14
+ && url.password === ''
15
+ && (url.pathname === '/s' || url.pathname.startsWith('/s/'));
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /** @param {any} article */
22
+ export function isUsableArticle(article) {
23
+ return Boolean(article)
24
+ && article.isDeleted !== true
25
+ && typeof article.url === 'string'
26
+ && isTrustedWechatArticleUrl(article.url)
27
+ && !article.url.includes('tempkey=');
28
+ }
29
+
30
+ function canonicalUrl(value) {
31
+ try {
32
+ const url = new URL(value);
33
+ url.hash = '';
34
+ return url.href;
35
+ } catch {
36
+ return value;
37
+ }
38
+ }
39
+
40
+ function publicArticle(article) {
41
+ return {
42
+ title: typeof article.title === 'string' ? article.title : '',
43
+ url: article.url,
44
+ publishedAt: typeof article.publishedAt === 'string' ? article.publishedAt : null,
45
+ digest: typeof article.digest === 'string' ? article.digest : '',
46
+ author: typeof article.author === 'string' ? article.author : '',
47
+ };
48
+ }
49
+
50
+ /**
51
+ * @param {{fakeid:string,fetchPage:(input:{fakeid:string,begin:number,count:number})=>Promise<any>,limit?:number,maxPages?:number,pageSize?:number}} options
52
+ */
53
+ export async function collectArticles({ fakeid, fetchPage, limit, maxPages, pageSize = 10 }) {
54
+ for (const [name, value, maximum] of [
55
+ ['pageSize', pageSize, MAX_PAGE_SIZE],
56
+ ['limit', limit, MAX_ARTICLES],
57
+ ['maxPages', maxPages, MAX_PAGES],
58
+ ]) {
59
+ if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) {
60
+ throw new ArgumentError(`${name} must be a positive safe integer`);
61
+ }
62
+ if (value !== undefined && value > maximum) {
63
+ throw new ArgumentError(`${name} must not exceed ${maximum}`);
64
+ }
65
+ }
66
+ const pageLimit = maxPages ?? MAX_PAGES;
67
+ const articleLimit = limit ?? MAX_ARTICLES;
68
+ const articles = [];
69
+ const seen = new Set();
70
+ let totalFromApi = 0;
71
+ let scanned = 0;
72
+ let invalid = 0;
73
+ let duplicates = 0;
74
+ let pages = 0;
75
+ let begin = 0;
76
+
77
+ while (true) {
78
+ const page = await fetchPage({ fakeid, begin, count: pageSize });
79
+ pages += 1;
80
+ const pageTotal = page?.total === undefined ? 0 : page.total;
81
+ if (!Number.isSafeInteger(pageTotal) || pageTotal < 0) {
82
+ throw new CommandExecutionError('WeChat article history returned invalid total metadata');
83
+ }
84
+ if (pages === 1) totalFromApi = pageTotal;
85
+ const rawArticles = Array.isArray(page?.articles) ? page.articles : [];
86
+ const publishItemCount = page?.publishItemCount === undefined ? 0 : page.publishItemCount;
87
+ if (!Number.isSafeInteger(publishItemCount) || publishItemCount < 0) {
88
+ throw new CommandExecutionError('WeChat article history returned invalid publish-item metadata');
89
+ }
90
+
91
+ for (const article of rawArticles) {
92
+ scanned += 1;
93
+ if (!isUsableArticle(article)) {
94
+ invalid += 1;
95
+ continue;
96
+ }
97
+ const canonical = canonicalUrl(article.url);
98
+ if (seen.has(canonical)) {
99
+ duplicates += 1;
100
+ continue;
101
+ }
102
+ seen.add(canonical);
103
+ articles.push(publicArticle(article));
104
+ if (articles.length >= articleLimit) break;
105
+ }
106
+
107
+ const reachedLimit = articles.length >= articleLimit;
108
+ const reachedMaxPages = pages >= pageLimit;
109
+ const reachedEnd = publishItemCount === 0
110
+ || publishItemCount < pageSize
111
+ || (totalFromApi > 0 && begin + publishItemCount >= totalFromApi);
112
+ if (reachedLimit || reachedMaxPages || reachedEnd) break;
113
+ const nextBegin = begin + pageSize;
114
+ if (!Number.isSafeInteger(nextBegin) || nextBegin <= begin) {
115
+ throw new CommandExecutionError('WeChat article pagination could not advance safely');
116
+ }
117
+ begin = nextBegin;
118
+ }
119
+
120
+ return {
121
+ articles,
122
+ summary: { totalFromApi, scanned, valid: articles.length, invalid, duplicates, pages },
123
+ };
124
+ }
@@ -0,0 +1,142 @@
1
+ import { AuthRequiredError, BrowserConnectError, TimeoutError } from '@sovovs/bycli/errors';
2
+
3
+ const DOMAIN = 'mp.weixin.qq.com';
4
+ const LOGIN_URL = `https://${DOMAIN}/`;
5
+ const DEFAULT_TIMEOUT_MS = 180_000;
6
+ const POLL_INTERVAL_MS = 500;
7
+
8
+ /**
9
+ * @typedef {{token: string, cookie: string, fingerprint?: string}} WechatCredentials
10
+ * @typedef {{url: string | null, hasLoginUi: boolean}} PreflightState
11
+ * @typedef {{timeoutMs?: number, now?: () => number}} ResolveBrowserCredentialsOptions
12
+ * @typedef {Pick<import('@sovovs/bycli/types').IPage, 'evaluate' | 'getCookies' | 'goto' | 'wait' | 'focusWindow'>} AuthPage
13
+ */
14
+
15
+ /**
16
+ * @param {boolean} needsFingerprint
17
+ * @param {NodeJS.ProcessEnv} [env]
18
+ * @returns {WechatCredentials}
19
+ */
20
+ export function readEnvironmentCredentials(needsFingerprint, env = process.env) {
21
+ const token = env.WECHAT_TOKEN?.trim() ?? '';
22
+ const cookie = env.WECHAT_COOKIE?.trim() ?? '';
23
+ const fingerprint = env.WECHAT_FINGERPRINT?.trim() ?? '';
24
+
25
+ if (!token || !cookie || (needsFingerprint && !fingerprint)) {
26
+ throw new AuthRequiredError(
27
+ DOMAIN,
28
+ needsFingerprint
29
+ ? 'WECHAT_TOKEN, WECHAT_COOKIE, and WECHAT_FINGERPRINT are required for env search'
30
+ : 'WECHAT_TOKEN and WECHAT_COOKIE are required for env crawling',
31
+ );
32
+ }
33
+
34
+ return { token, cookie, ...(fingerprint ? { fingerprint } : {}) };
35
+ }
36
+
37
+ /** @param {PreflightState} state @returns {boolean} */
38
+ export function isLoggedInPreflight(state) {
39
+ if (state.url === null || state.hasLoginUi) return false;
40
+
41
+ try {
42
+ const url = new URL(state.url);
43
+ return url.origin === `https://${DOMAIN}`
44
+ && url.pathname.startsWith('/cgi-bin/')
45
+ && Boolean(url.searchParams.get('token')?.trim());
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** @param {AuthPage} page @returns {Promise<PreflightState>} */
52
+ async function readPreflight(page) {
53
+ const result = await page.evaluate(() => {
54
+ const selectors = [
55
+ 'form[action*="login"]',
56
+ 'img[src*="qrcode"]',
57
+ 'canvas[class*="qrcode"]',
58
+ ];
59
+ const isVisible = element => {
60
+ const style = window.getComputedStyle(element);
61
+ const rect = element.getBoundingClientRect();
62
+ return style.display !== 'none'
63
+ && style.visibility !== 'hidden'
64
+ && Number(style.opacity) !== 0
65
+ && rect.width > 0
66
+ && rect.height > 0;
67
+ };
68
+ const hasVisibleSelector = selectors.some(selector =>
69
+ Array.from(document.querySelectorAll(selector)).some(isVisible));
70
+ const hasLoginText = /扫码登录|使用微信扫码/.test(document.body?.innerText ?? '');
71
+
72
+ return {
73
+ href: window.location.href,
74
+ hasLoginUi: hasVisibleSelector || hasLoginText,
75
+ };
76
+ });
77
+
78
+ return { url: result.href, hasLoginUi: result.hasLoginUi };
79
+ }
80
+
81
+ /** @param {string} cookieDomain @returns {boolean} */
82
+ function matchesDomain(cookieDomain) {
83
+ const normalized = cookieDomain.toLowerCase().replace(/^\./, '');
84
+ return normalized === DOMAIN || normalized.endsWith(`.${DOMAIN}`);
85
+ }
86
+
87
+ /**
88
+ * @param {AuthPage | null} page
89
+ * @param {ResolveBrowserCredentialsOptions} [options]
90
+ * @returns {Promise<WechatCredentials>}
91
+ */
92
+ export async function resolveBrowserCredentials(page, options = {}) {
93
+ if (page === null) {
94
+ throw new BrowserConnectError('No browser page is connected for WeChat authentication');
95
+ }
96
+
97
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
98
+ const now = options.now ?? Date.now;
99
+ const startedAt = now();
100
+ let state = await readPreflight(page);
101
+
102
+ if (!isLoggedInPreflight(state)) {
103
+ if (!page.focusWindow) {
104
+ throw new BrowserConnectError(
105
+ 'The connected browser cannot be focused for WeChat login',
106
+ 'Upgrade to byCLI 2.1 or newer to support interactive browser login',
107
+ );
108
+ }
109
+
110
+ await page.goto(LOGIN_URL);
111
+ await page.focusWindow();
112
+
113
+ while (!isLoggedInPreflight(state)) {
114
+ const remainingMs = timeoutMs - (now() - startedAt);
115
+ if (remainingMs <= 0) {
116
+ throw new TimeoutError('WeChat login', timeoutMs / 1000);
117
+ }
118
+ await page.wait(Math.min(POLL_INTERVAL_MS, remainingMs) / 1000);
119
+ state = await readPreflight(page);
120
+ }
121
+ }
122
+
123
+ const finalUrl = state.url;
124
+ const token = finalUrl === null ? '' : new URL(finalUrl).searchParams.get('token')?.trim() ?? '';
125
+ const cookies = await page.getCookies({ url: LOGIN_URL });
126
+ const nowSeconds = now() / 1000;
127
+ const cookie = cookies
128
+ .filter(item => matchesDomain(item.domain))
129
+ .filter(item => item.expirationDate === undefined || item.expirationDate > nowSeconds)
130
+ .map(item => `${item.name}=${item.value}`)
131
+ .join('; ');
132
+
133
+ if (!token || !cookie) {
134
+ throw new AuthRequiredError(DOMAIN, 'A logged-in WeChat session with a token and cookies is required');
135
+ }
136
+
137
+ await page.goto(
138
+ `https://${DOMAIN}/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10&token=${encodeURIComponent(token)}&lang=zh_CN`,
139
+ );
140
+
141
+ return { token, cookie };
142
+ }