@sovovs/bycli 2.1.48 → 2.1.49

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 CHANGED
@@ -28798,6 +28798,103 @@
28798
28798
  "sourceFile": "weixin/accounts.js",
28799
28799
  "navigateBefore": true
28800
28800
  },
28801
+ {
28802
+ "site": "weixin",
28803
+ "name": "article-fetch",
28804
+ "description": "Fetch one published Weixin article with optional browser fallback",
28805
+ "access": "read",
28806
+ "domain": "mp.weixin.qq.com",
28807
+ "strategy": "cookie",
28808
+ "browser": "conditional",
28809
+ "args": [
28810
+ {
28811
+ "name": "article-id",
28812
+ "type": "str",
28813
+ "required": false,
28814
+ "help": "Official article_id for API retrieval"
28815
+ },
28816
+ {
28817
+ "name": "url",
28818
+ "type": "str",
28819
+ "required": false,
28820
+ "help": "Trusted public article URL required for browser mode or fallback"
28821
+ },
28822
+ {
28823
+ "name": "source",
28824
+ "type": "str",
28825
+ "default": "auto",
28826
+ "required": false,
28827
+ "help": "Article source and fallback policy",
28828
+ "choices": [
28829
+ "auto",
28830
+ "api",
28831
+ "browser"
28832
+ ]
28833
+ },
28834
+ {
28835
+ "name": "content",
28836
+ "type": "str",
28837
+ "default": "file",
28838
+ "required": false,
28839
+ "help": "Official API HTML handling",
28840
+ "choices": [
28841
+ "none",
28842
+ "inline",
28843
+ "file"
28844
+ ]
28845
+ },
28846
+ {
28847
+ "name": "output",
28848
+ "type": "str",
28849
+ "default": "./weixin-published",
28850
+ "required": false,
28851
+ "help": "Artifact output directory"
28852
+ },
28853
+ {
28854
+ "name": "download-images",
28855
+ "type": "boolean",
28856
+ "default": true,
28857
+ "required": false,
28858
+ "help": "Download images in browser mode"
28859
+ },
28860
+ {
28861
+ "name": "appid",
28862
+ "type": "str",
28863
+ "required": false,
28864
+ "help": "WeChat Official Account AppID; falls back to WECHAT_APPID"
28865
+ },
28866
+ {
28867
+ "name": "appsecret",
28868
+ "type": "str",
28869
+ "required": false,
28870
+ "help": "WeChat AppSecret; prefer WECHAT_APPSECRET to avoid shell history"
28871
+ },
28872
+ {
28873
+ "name": "access-token",
28874
+ "type": "str",
28875
+ "required": false,
28876
+ "help": "Existing API token; falls back to WECHAT_ACCESS_TOKEN"
28877
+ }
28878
+ ],
28879
+ "columns": [
28880
+ "article_id",
28881
+ "title",
28882
+ "author",
28883
+ "digest",
28884
+ "published_at",
28885
+ "url",
28886
+ "content_html",
28887
+ "artifact_paths_json",
28888
+ "thumb_media_id",
28889
+ "image_info_json",
28890
+ "source",
28891
+ "fallback_reason"
28892
+ ],
28893
+ "type": "js",
28894
+ "modulePath": "weixin/article-fetch.js",
28895
+ "sourceFile": "weixin/article-fetch.js",
28896
+ "navigateBefore": "https://mp.weixin.qq.com"
28897
+ },
28801
28898
  {
28802
28899
  "site": "weixin",
28803
28900
  "name": "articles",
@@ -29266,6 +29363,157 @@
29266
29363
  "sourceFile": "weixin/drafts.js",
29267
29364
  "navigateBefore": false
29268
29365
  },
29366
+ {
29367
+ "site": "weixin",
29368
+ "name": "freepublish-get",
29369
+ "description": "Get one published Weixin article through the official freepublish API",
29370
+ "access": "read",
29371
+ "domain": "api.weixin.qq.com",
29372
+ "strategy": "local",
29373
+ "browser": false,
29374
+ "args": [
29375
+ {
29376
+ "name": "articleId",
29377
+ "type": "str",
29378
+ "required": true,
29379
+ "positional": true,
29380
+ "help": "Published article_id returned by freepublish-list"
29381
+ },
29382
+ {
29383
+ "name": "content",
29384
+ "type": "str",
29385
+ "default": "inline",
29386
+ "required": false,
29387
+ "help": "HTML handling: omit, return inline, or save files",
29388
+ "choices": [
29389
+ "none",
29390
+ "inline",
29391
+ "file"
29392
+ ]
29393
+ },
29394
+ {
29395
+ "name": "output",
29396
+ "type": "str",
29397
+ "default": "./weixin-published",
29398
+ "required": false,
29399
+ "help": "Artifact directory when --content file is used"
29400
+ },
29401
+ {
29402
+ "name": "appid",
29403
+ "type": "str",
29404
+ "required": false,
29405
+ "help": "WeChat Official Account AppID; falls back to WECHAT_APPID"
29406
+ },
29407
+ {
29408
+ "name": "appsecret",
29409
+ "type": "str",
29410
+ "required": false,
29411
+ "help": "WeChat AppSecret; prefer WECHAT_APPSECRET to avoid shell history"
29412
+ },
29413
+ {
29414
+ "name": "access-token",
29415
+ "type": "str",
29416
+ "required": false,
29417
+ "help": "Existing API token; falls back to WECHAT_ACCESS_TOKEN"
29418
+ }
29419
+ ],
29420
+ "columns": [
29421
+ "article_id",
29422
+ "article_index",
29423
+ "article_type",
29424
+ "title",
29425
+ "author",
29426
+ "digest",
29427
+ "published_url",
29428
+ "thumb_media_id",
29429
+ "updated_at",
29430
+ "content_html",
29431
+ "artifact_paths_json",
29432
+ "image_info_json"
29433
+ ],
29434
+ "type": "js",
29435
+ "modulePath": "weixin/freepublish-get.js",
29436
+ "sourceFile": "weixin/freepublish-get.js"
29437
+ },
29438
+ {
29439
+ "site": "weixin",
29440
+ "name": "freepublish-list",
29441
+ "description": "List published Weixin articles through the official freepublish API",
29442
+ "access": "read",
29443
+ "domain": "api.weixin.qq.com",
29444
+ "strategy": "local",
29445
+ "browser": false,
29446
+ "args": [
29447
+ {
29448
+ "name": "offset",
29449
+ "type": "int",
29450
+ "default": 0,
29451
+ "required": false,
29452
+ "help": "Zero-based published-material offset"
29453
+ },
29454
+ {
29455
+ "name": "count",
29456
+ "type": "int",
29457
+ "default": 20,
29458
+ "required": false,
29459
+ "help": "Number of published materials to return (1–20)"
29460
+ },
29461
+ {
29462
+ "name": "content",
29463
+ "type": "str",
29464
+ "default": "none",
29465
+ "required": false,
29466
+ "help": "HTML handling: omit, return inline, or save files",
29467
+ "choices": [
29468
+ "none",
29469
+ "inline",
29470
+ "file"
29471
+ ]
29472
+ },
29473
+ {
29474
+ "name": "output",
29475
+ "type": "str",
29476
+ "default": "./weixin-published",
29477
+ "required": false,
29478
+ "help": "Artifact directory when --content file is used"
29479
+ },
29480
+ {
29481
+ "name": "appid",
29482
+ "type": "str",
29483
+ "required": false,
29484
+ "help": "WeChat Official Account AppID; falls back to WECHAT_APPID"
29485
+ },
29486
+ {
29487
+ "name": "appsecret",
29488
+ "type": "str",
29489
+ "required": false,
29490
+ "help": "WeChat AppSecret; prefer WECHAT_APPSECRET to avoid shell history"
29491
+ },
29492
+ {
29493
+ "name": "access-token",
29494
+ "type": "str",
29495
+ "required": false,
29496
+ "help": "Existing API token; falls back to WECHAT_ACCESS_TOKEN"
29497
+ }
29498
+ ],
29499
+ "columns": [
29500
+ "article_id",
29501
+ "article_index",
29502
+ "article_type",
29503
+ "title",
29504
+ "author",
29505
+ "digest",
29506
+ "published_url",
29507
+ "thumb_media_id",
29508
+ "updated_at",
29509
+ "content_html",
29510
+ "artifact_paths_json",
29511
+ "image_info_json"
29512
+ ],
29513
+ "type": "js",
29514
+ "modulePath": "weixin/freepublish-list.js",
29515
+ "sourceFile": "weixin/freepublish-list.js"
29516
+ },
29269
29517
  {
29270
29518
  "site": "weixin",
29271
29519
  "name": "home-overview",
@@ -29359,6 +29607,105 @@
29359
29607
  "sourceFile": "weixin/published.js",
29360
29608
  "navigateBefore": false
29361
29609
  },
29610
+ {
29611
+ "site": "weixin",
29612
+ "name": "published-articles",
29613
+ "description": "List published Weixin articles with optional official-API-to-browser fallback",
29614
+ "access": "read",
29615
+ "domain": "mp.weixin.qq.com",
29616
+ "strategy": "cookie",
29617
+ "browser": "conditional",
29618
+ "args": [
29619
+ {
29620
+ "name": "source",
29621
+ "type": "str",
29622
+ "default": "auto",
29623
+ "required": false,
29624
+ "help": "Article source and fallback policy",
29625
+ "choices": [
29626
+ "auto",
29627
+ "api",
29628
+ "browser"
29629
+ ]
29630
+ },
29631
+ {
29632
+ "name": "limit",
29633
+ "type": "int",
29634
+ "default": 20,
29635
+ "required": false,
29636
+ "help": "Maximum articles to return"
29637
+ },
29638
+ {
29639
+ "name": "max-pages",
29640
+ "type": "int",
29641
+ "default": 5,
29642
+ "required": false,
29643
+ "help": "Maximum browser pages to scan"
29644
+ },
29645
+ {
29646
+ "name": "timeout",
29647
+ "type": "int",
29648
+ "default": 30,
29649
+ "required": false,
29650
+ "help": "Browser request timeout in seconds"
29651
+ },
29652
+ {
29653
+ "name": "content",
29654
+ "type": "str",
29655
+ "default": "none",
29656
+ "required": false,
29657
+ "help": "Official API HTML handling",
29658
+ "choices": [
29659
+ "none",
29660
+ "inline",
29661
+ "file"
29662
+ ]
29663
+ },
29664
+ {
29665
+ "name": "output",
29666
+ "type": "str",
29667
+ "default": "./weixin-published",
29668
+ "required": false,
29669
+ "help": "Official API artifact directory"
29670
+ },
29671
+ {
29672
+ "name": "appid",
29673
+ "type": "str",
29674
+ "required": false,
29675
+ "help": "WeChat Official Account AppID; falls back to WECHAT_APPID"
29676
+ },
29677
+ {
29678
+ "name": "appsecret",
29679
+ "type": "str",
29680
+ "required": false,
29681
+ "help": "WeChat AppSecret; prefer WECHAT_APPSECRET to avoid shell history"
29682
+ },
29683
+ {
29684
+ "name": "access-token",
29685
+ "type": "str",
29686
+ "required": false,
29687
+ "help": "Existing API token; falls back to WECHAT_ACCESS_TOKEN"
29688
+ }
29689
+ ],
29690
+ "columns": [
29691
+ "article_id",
29692
+ "title",
29693
+ "author",
29694
+ "digest",
29695
+ "published_at",
29696
+ "url",
29697
+ "content_html",
29698
+ "artifact_paths_json",
29699
+ "thumb_media_id",
29700
+ "image_info_json",
29701
+ "source",
29702
+ "fallback_reason"
29703
+ ],
29704
+ "type": "js",
29705
+ "modulePath": "weixin/published-articles.js",
29706
+ "sourceFile": "weixin/published-articles.js",
29707
+ "navigateBefore": "https://mp.weixin.qq.com"
29708
+ },
29362
29709
  {
29363
29710
  "site": "weixin",
29364
29711
  "name": "save-articles",
@@ -0,0 +1,170 @@
1
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { resolve, join } from 'node:path';
4
+ import { getAccessToken } from './api-draft.js';
5
+
6
+ const API_BASE = 'https://api.weixin.qq.com/cgi-bin/freepublish';
7
+
8
+ export class WechatOfficialApiError extends CommandExecutionError {
9
+ constructor(context, errcode, errmsg) {
10
+ super(`${context} failed (${String(errcode)}): ${String(errmsg || 'unknown error')}`);
11
+ this.wechatErrcode = errcode;
12
+ }
13
+ }
14
+
15
+ function text(value) {
16
+ const normalized = String(value ?? '').trim();
17
+ return normalized || null;
18
+ }
19
+
20
+ export function readOfficialApiCredentials(args = {}, env = process.env) {
21
+ const accessToken = text(args['access-token']) ?? text(env.WECHAT_ACCESS_TOKEN);
22
+ if (accessToken) return { accessToken, appid: null, appsecret: null, configured: true };
23
+ const explicitAppid = text(args.appid);
24
+ const explicitSecret = text(args.appsecret);
25
+ const usesExplicitPair = Boolean(explicitAppid || explicitSecret);
26
+ const appid = usesExplicitPair ? explicitAppid : text(env.WECHAT_APPID);
27
+ const appsecret = usesExplicitPair ? explicitSecret : text(env.WECHAT_APPSECRET);
28
+ if (Boolean(appid) !== Boolean(appsecret)) {
29
+ throw new ArgumentError('appid and appsecret must be provided together');
30
+ }
31
+ return {
32
+ accessToken: null, appid, appsecret, configured: Boolean(appid && appsecret),
33
+ };
34
+ }
35
+
36
+ export async function resolveOfficialApiAccessToken(credentials, fetchImpl = globalThis.fetch) {
37
+ if (credentials?.accessToken) return credentials.accessToken;
38
+ if (!credentials?.appid || !credentials?.appsecret) {
39
+ throw new ArgumentError('Official Weixin API requires WECHAT_ACCESS_TOKEN or an AppID/AppSecret pair');
40
+ }
41
+ try {
42
+ return await getAccessToken(credentials.appid, credentials.appsecret, fetchImpl);
43
+ } catch (error) {
44
+ const secret = String(credentials.appsecret);
45
+ let message = error instanceof Error ? error.message : String(error);
46
+ for (const value of [secret, encodeURIComponent(secret)]) message = message.replaceAll(value, '[REDACTED]');
47
+ message = message.replace(/([?&]secret=)[^&\s]*/giu, '$1[REDACTED]');
48
+ throw new CommandExecutionError(message);
49
+ }
50
+ }
51
+
52
+ async function postJson(path, accessToken, body, fetchImpl) {
53
+ if (typeof fetchImpl !== 'function') throw new CommandExecutionError('Official Weixin API requires fetch support');
54
+ const url = new URL(`${API_BASE}/${path}`);
55
+ url.searchParams.set('access_token', accessToken);
56
+ let response;
57
+ try {
58
+ response = await fetchImpl(url.toString(), {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
61
+ body: JSON.stringify(body),
62
+ signal: AbortSignal.timeout(30_000),
63
+ });
64
+ } catch {
65
+ throw new CommandExecutionError(`Weixin freepublish ${path} request failed`);
66
+ }
67
+ let payload;
68
+ try { payload = await response.json(); } catch { throw new CommandExecutionError(`Weixin freepublish ${path} returned invalid JSON`); }
69
+ if (!response.ok) throw new CommandExecutionError(`Weixin freepublish ${path} returned HTTP ${response.status}`);
70
+ if (payload?.errcode != null && payload.errcode !== 0) {
71
+ throw new WechatOfficialApiError(`Weixin freepublish ${path}`, payload.errcode, payload.errmsg);
72
+ }
73
+ return payload;
74
+ }
75
+
76
+ export function batchGetFreepublish({ accessToken, offset, count, noContent, fetchImpl = globalThis.fetch }) {
77
+ return postJson('batchget', accessToken, { offset, count, no_content: noContent }, fetchImpl);
78
+ }
79
+
80
+ export function getFreepublishArticle({ accessToken, articleId, fetchImpl = globalThis.fetch }) {
81
+ return postJson('getarticle', accessToken, { article_id: articleId }, fetchImpl);
82
+ }
83
+
84
+ function record(value, label) {
85
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
86
+ throw new CommandExecutionError(`Weixin freepublish returned invalid ${label}`);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ function unixIso(value) {
92
+ if (value == null) return null;
93
+ if (!Number.isSafeInteger(value) || value < 0) throw new CommandExecutionError('Weixin freepublish returned invalid update_time');
94
+ return new Date(value * 1000).toISOString();
95
+ }
96
+
97
+ function flattenArticle(container, articleId, updateTime) {
98
+ const content = container.content && typeof container.content === 'object' ? container.content : container;
99
+ const items = content.news_item;
100
+ if (!Array.isArray(items)) throw new CommandExecutionError('Weixin freepublish returned invalid news_item');
101
+ return items.map((raw, articleIndex) => {
102
+ const item = record(raw, `news_item[${articleIndex}]`);
103
+ const articleType = text(item.article_type) ?? (item.image_info ? 'newspic' : 'news');
104
+ return {
105
+ article_id: articleId,
106
+ article_index: articleIndex,
107
+ article_type: articleType,
108
+ title: text(item.title),
109
+ author: text(item.author),
110
+ digest: text(item.digest),
111
+ published_url: text(item.url),
112
+ thumb_media_id: text(item.thumb_media_id),
113
+ updated_at: unixIso(updateTime),
114
+ content_html: item.content == null ? null : String(item.content),
115
+ artifact_paths_json: null,
116
+ image_info_json: item.image_info == null ? null : JSON.stringify(item.image_info),
117
+ };
118
+ });
119
+ }
120
+
121
+ export function normalizeFreepublishPayload(payload) {
122
+ const response = record(payload, 'response');
123
+ if (Array.isArray(response.item)) {
124
+ return response.item.flatMap((raw, index) => {
125
+ const item = record(raw, `item[${index}]`);
126
+ const articleId = text(item.article_id);
127
+ if (!articleId) throw new CommandExecutionError('Weixin freepublish returned an item without article_id');
128
+ return flattenArticle(item, articleId, item.update_time);
129
+ });
130
+ }
131
+ const articleId = text(response.article_id);
132
+ if (!articleId) throw new CommandExecutionError('Weixin freepublish returned an article without article_id');
133
+ return flattenArticle(response, articleId, response.update_time);
134
+ }
135
+
136
+ function safeStem(row) {
137
+ const base = String(row.title || row.article_id || 'article')
138
+ .normalize('NFKC').replace(/[\\/:*?"<>|\u0000-\u001f]/gu, '-').replace(/\.{2,}/gu, '-').trim();
139
+ return (base || 'article').slice(0, 80);
140
+ }
141
+
142
+ async function writeUnique(directory, stem, extension, content) {
143
+ for (let suffix = 0; suffix < 10_000; suffix += 1) {
144
+ const name = `${stem}${suffix === 0 ? '' : `-${suffix}`}.${extension}`;
145
+ const path = join(directory, name);
146
+ try {
147
+ await writeFile(path, content, { encoding: 'utf8', flag: 'wx' });
148
+ return path;
149
+ } catch (error) {
150
+ if (error?.code !== 'EEXIST') throw error;
151
+ }
152
+ }
153
+ throw new CommandExecutionError('Could not allocate a unique freepublish artifact name');
154
+ }
155
+
156
+ export async function materializeFreepublishRows(rows, { contentMode = 'inline', output = './weixin-published' } = {}) {
157
+ if (!['none', 'inline', 'file'].includes(contentMode)) throw new ArgumentError('content must be none, inline, or file');
158
+ if (contentMode === 'none') return rows.map(row => ({ ...row, content_html: null, artifact_paths_json: null }));
159
+ if (contentMode === 'inline') return rows.map(row => ({ ...row, artifact_paths_json: null }));
160
+ const directory = resolve(output);
161
+ await mkdir(directory, { recursive: true });
162
+ const materialized = [];
163
+ for (const row of rows) {
164
+ const stem = `${safeStem(row)}-${row.article_index + 1}`;
165
+ const html = await writeUnique(directory, stem, 'html', row.content_html ?? '');
166
+ const metadata = await writeUnique(directory, stem, 'json', `${JSON.stringify(row, null, 2)}\n`);
167
+ materialized.push({ ...row, content_html: null, artifact_paths_json: JSON.stringify({ html, metadata }) });
168
+ }
169
+ return materialized;
170
+ }
@@ -0,0 +1,17 @@
1
+ import { ArgumentError } from '@sovovs/bycli/errors';
2
+ import { isTrustedWechatArticleUrl } from './article-link.js';
3
+ import { WechatOfficialApiError } from './api-freepublish.js';
4
+
5
+ const FALLBACK_CODES = new Set([48001, 50001]);
6
+
7
+ export function isFreepublishFallbackEligible(error) {
8
+ return error instanceof WechatOfficialApiError && FALLBACK_CODES.has(error.wechatErrcode);
9
+ }
10
+
11
+ export function requireBrowserFallbackUrl(value) {
12
+ const url = String(value ?? '').trim();
13
+ if (!url || !isTrustedWechatArticleUrl(url)) {
14
+ throw new ArgumentError('Browser article fallback requires a trusted mp.weixin.qq.com article URL');
15
+ }
16
+ return url;
17
+ }
@@ -0,0 +1,66 @@
1
+ import { ArgumentError } from '@sovovs/bycli/errors';
2
+ import { cli, getRegistry, Strategy } from '@sovovs/bycli/registry';
3
+ import { readOfficialApiCredentials } from './_wechat/api-freepublish.js';
4
+ import { isFreepublishFallbackEligible, requireBrowserFallbackUrl } from './_wechat/freepublish-fallback.js';
5
+ import { freepublishGetCommand, } from './freepublish-get.js';
6
+ import { OFFICIAL_API_ARGS } from './freepublish-list.js';
7
+ import { FACADE_COLUMNS, projectOfficialRows, sourceValue } from './published-articles.js';
8
+
9
+ export async function loadBrowserDownloadCommand() {
10
+ const existing = getRegistry().get('weixin/download');
11
+ if (typeof existing?.func === 'function') return existing;
12
+ const module = await import('./download.js');
13
+ const registered = getRegistry().get('weixin/download');
14
+ return typeof registered?.func === 'function' ? registered : module.weixinDownloadCommand;
15
+ }
16
+
17
+ async function browserArticle(page, args, fallbackReason) {
18
+ const url = requireBrowserFallbackUrl(args.url);
19
+ const download = await loadBrowserDownloadCommand();
20
+ if (!download) throw new ArgumentError('weixin download command is unavailable for browser fallback');
21
+ const rows = await download.func(page, { url, output: args.output, 'download-images': args['download-images'] ?? true });
22
+ return rows.map(row => ({
23
+ article_id: null, title: row.title, author: row.author ?? null, digest: null,
24
+ published_at: row.publish_time ?? null, url: row.resolved_url ?? url, content_html: null,
25
+ artifact_paths_json: JSON.stringify({ markdown: row.saved }), thumb_media_id: null,
26
+ image_info_json: null, source: 'browser', fallback_reason: fallbackReason,
27
+ }));
28
+ }
29
+
30
+ export const articleFetchCommand = cli({
31
+ site: 'weixin', name: 'article-fetch', access: 'read', domain: 'mp.weixin.qq.com',
32
+ description: 'Fetch one published Weixin article with optional browser fallback',
33
+ strategy: Strategy.COOKIE,
34
+ browser: args => sourceValue(args.source) !== 'api',
35
+ args: [
36
+ { name: 'article-id', help: 'Official article_id for API retrieval' },
37
+ { name: 'url', help: 'Trusted public article URL required for browser mode or fallback' },
38
+ { name: 'source', default: 'auto', choices: ['auto', 'api', 'browser'], help: 'Article source and fallback policy' },
39
+ { name: 'content', default: 'file', choices: ['none', 'inline', 'file'], help: 'Official API HTML handling' },
40
+ { name: 'output', default: './weixin-published', help: 'Artifact output directory' },
41
+ { name: 'download-images', type: 'boolean', default: true, help: 'Download images in browser mode' },
42
+ ...OFFICIAL_API_ARGS,
43
+ ],
44
+ columns: FACADE_COLUMNS,
45
+ func: async (page, args) => {
46
+ const source = sourceValue(args.source);
47
+ if (source === 'browser') return browserArticle(page, args, null);
48
+ const articleId = String(args['article-id'] ?? '').trim();
49
+ if (!articleId) {
50
+ if (source === 'auto') return browserArticle(page, args, 'article-id-not-provided');
51
+ throw new ArgumentError('article-id is required in API mode');
52
+ }
53
+ const credentials = readOfficialApiCredentials(args);
54
+ if (source === 'auto' && !credentials.configured) return browserArticle(page, args, 'api-not-configured');
55
+ try {
56
+ return projectOfficialRows(await freepublishGetCommand.func({
57
+ ...args, articleId, content: args.content ?? 'file',
58
+ }));
59
+ } catch (error) {
60
+ if (source === 'auto' && isFreepublishFallbackEligible(error)) {
61
+ return browserArticle(page, args, 'api-not-authorized');
62
+ }
63
+ throw error;
64
+ }
65
+ },
66
+ });
@@ -149,7 +149,7 @@ export function buildDetectWechatAccessIssueJs() {
149
149
  // ============================================================
150
150
  // CLI Registration
151
151
  // ============================================================
152
- cli({
152
+ export const weixinDownloadCommand = cli({
153
153
  site: 'weixin',
154
154
  name: 'download',
155
155
  access: 'read',
@@ -0,0 +1,30 @@
1
+ import { ArgumentError } from '@sovovs/bycli/errors';
2
+ import { cli, Strategy } from '@sovovs/bycli/registry';
3
+ import {
4
+ getFreepublishArticle, materializeFreepublishRows, normalizeFreepublishPayload,
5
+ readOfficialApiCredentials, resolveOfficialApiAccessToken,
6
+ } from './_wechat/api-freepublish.js';
7
+ import { contentMode, FREEPUBLISH_COLUMNS, OFFICIAL_API_ARGS } from './freepublish-list.js';
8
+
9
+ export const freepublishGetCommand = cli({
10
+ site: 'weixin', name: 'freepublish-get', access: 'read', domain: 'api.weixin.qq.com',
11
+ description: 'Get one published Weixin article through the official freepublish API',
12
+ strategy: Strategy.LOCAL, browser: false,
13
+ args: [
14
+ { name: 'articleId', positional: true, required: true, help: 'Published article_id returned by freepublish-list' },
15
+ { name: 'content', default: 'inline', choices: ['none', 'inline', 'file'], help: 'HTML handling: omit, return inline, or save files' },
16
+ { name: 'output', default: './weixin-published', help: 'Artifact directory when --content file is used' },
17
+ ...OFFICIAL_API_ARGS,
18
+ ],
19
+ columns: FREEPUBLISH_COLUMNS,
20
+ func: async args => {
21
+ const articleId = String(args.articleId ?? '').trim();
22
+ if (!articleId) throw new ArgumentError('articleId is required');
23
+ const mode = contentMode(args.content, 'inline');
24
+ const credentials = readOfficialApiCredentials(args);
25
+ const accessToken = await resolveOfficialApiAccessToken(credentials);
26
+ const payload = await getFreepublishArticle({ accessToken, articleId });
27
+ const rows = normalizeFreepublishPayload({ ...payload, article_id: payload.article_id ?? articleId });
28
+ return materializeFreepublishRows(rows, { contentMode: mode, output: args.output });
29
+ },
30
+ });
@@ -0,0 +1,53 @@
1
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
+ import { cli, Strategy } from '@sovovs/bycli/registry';
3
+ import {
4
+ batchGetFreepublish, materializeFreepublishRows, normalizeFreepublishPayload,
5
+ readOfficialApiCredentials, resolveOfficialApiAccessToken,
6
+ } from './_wechat/api-freepublish.js';
7
+
8
+ export const FREEPUBLISH_COLUMNS = [
9
+ 'article_id', 'article_index', 'article_type', 'title', 'author', 'digest',
10
+ 'published_url', 'thumb_media_id', 'updated_at', 'content_html',
11
+ 'artifact_paths_json', 'image_info_json',
12
+ ];
13
+
14
+ export const OFFICIAL_API_ARGS = [
15
+ { name: 'appid', help: 'WeChat Official Account AppID; falls back to WECHAT_APPID' },
16
+ { name: 'appsecret', help: 'WeChat AppSecret; prefer WECHAT_APPSECRET to avoid shell history' },
17
+ { name: 'access-token', help: 'Existing API token; falls back to WECHAT_ACCESS_TOKEN' },
18
+ ];
19
+
20
+ function contentMode(value, fallback) {
21
+ const mode = String(value ?? fallback);
22
+ if (!['none', 'inline', 'file'].includes(mode)) throw new ArgumentError('content must be none, inline, or file');
23
+ return mode;
24
+ }
25
+
26
+ export const freepublishListCommand = cli({
27
+ site: 'weixin', name: 'freepublish-list', access: 'read', domain: 'api.weixin.qq.com',
28
+ description: 'List published Weixin articles through the official freepublish API',
29
+ strategy: Strategy.LOCAL, browser: false,
30
+ args: [
31
+ { name: 'offset', type: 'int', default: 0, help: 'Zero-based published-material offset' },
32
+ { name: 'count', type: 'int', default: 20, help: 'Number of published materials to return (1–20)' },
33
+ { name: 'content', default: 'none', choices: ['none', 'inline', 'file'], help: 'HTML handling: omit, return inline, or save files' },
34
+ { name: 'output', default: './weixin-published', help: 'Artifact directory when --content file is used' },
35
+ ...OFFICIAL_API_ARGS,
36
+ ],
37
+ columns: FREEPUBLISH_COLUMNS,
38
+ func: async args => {
39
+ const offset = args.offset ?? 0;
40
+ const count = args.count ?? 20;
41
+ if (!Number.isSafeInteger(offset) || offset < 0) throw new ArgumentError('offset must be a non-negative safe integer');
42
+ if (!Number.isSafeInteger(count) || count < 1 || count > 20) throw new ArgumentError('count must be an integer from 1 to 20');
43
+ const mode = contentMode(args.content, 'none');
44
+ const credentials = readOfficialApiCredentials(args);
45
+ const accessToken = await resolveOfficialApiAccessToken(credentials);
46
+ const payload = await batchGetFreepublish({ accessToken, offset, count, noContent: mode === 'none' ? 1 : 0 });
47
+ const rows = normalizeFreepublishPayload(payload);
48
+ if (rows.length === 0) throw new EmptyResultError('weixin freepublish-list', 'No published articles were returned by the official API.');
49
+ return materializeFreepublishRows(rows, { contentMode: mode, output: args.output });
50
+ },
51
+ });
52
+
53
+ export { contentMode };
@@ -0,0 +1,77 @@
1
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
+ import { cli, Strategy } from '@sovovs/bycli/registry';
3
+ import { readOfficialApiCredentials } from './_wechat/api-freepublish.js';
4
+ import { resolveBrowserCredentials } from './_wechat/auth-session.js';
5
+ import { isFreepublishFallbackEligible } from './_wechat/freepublish-fallback.js';
6
+ import { collectPublishedRecords } from './_wechat/publish-records.js';
7
+ import { freepublishListCommand, OFFICIAL_API_ARGS } from './freepublish-list.js';
8
+
9
+ export const FACADE_COLUMNS = [
10
+ 'article_id', 'title', 'author', 'digest', 'published_at', 'url', 'content_html',
11
+ 'artifact_paths_json', 'thumb_media_id', 'image_info_json', 'source', 'fallback_reason',
12
+ ];
13
+
14
+ function sourceValue(value) {
15
+ const source = String(value ?? 'auto');
16
+ if (!['auto', 'api', 'browser'].includes(source)) throw new ArgumentError('source must be auto, api, or browser');
17
+ return source;
18
+ }
19
+
20
+ export function projectOfficialRows(rows, fallbackReason = null) {
21
+ return rows.map(row => ({
22
+ article_id: row.article_id, title: row.title, author: row.author, digest: row.digest,
23
+ published_at: row.updated_at, url: row.published_url, content_html: row.content_html,
24
+ artifact_paths_json: row.artifact_paths_json, thumb_media_id: row.thumb_media_id,
25
+ image_info_json: row.image_info_json, source: 'official-api', fallback_reason: fallbackReason,
26
+ }));
27
+ }
28
+
29
+ async function browserRows(page, args, fallbackReason) {
30
+ const { token } = await resolveBrowserCredentials(page);
31
+ const rows = await collectPublishedRecords(page, {
32
+ token, limit: args.limit ?? 20, maxPages: args['max-pages'] ?? 5, timeout: args.timeout ?? 30,
33
+ });
34
+ return rows.map(row => ({
35
+ article_id: null, title: row.title, author: null, digest: null,
36
+ published_at: row.publishedAt, url: row.url, content_html: null,
37
+ artifact_paths_json: null, thumb_media_id: null, image_info_json: null,
38
+ source: 'browser', fallback_reason: fallbackReason,
39
+ }));
40
+ }
41
+
42
+ export const publishedArticlesCommand = cli({
43
+ site: 'weixin', name: 'published-articles', access: 'read', domain: 'mp.weixin.qq.com',
44
+ description: 'List published Weixin articles with optional official-API-to-browser fallback',
45
+ strategy: Strategy.COOKIE,
46
+ browser: args => sourceValue(args.source) !== 'api',
47
+ args: [
48
+ { name: 'source', default: 'auto', choices: ['auto', 'api', 'browser'], help: 'Article source and fallback policy' },
49
+ { name: 'limit', type: 'int', default: 20, help: 'Maximum articles to return' },
50
+ { name: 'max-pages', type: 'int', default: 5, help: 'Maximum browser pages to scan' },
51
+ { name: 'timeout', type: 'int', default: 30, help: 'Browser request timeout in seconds' },
52
+ { name: 'content', default: 'none', choices: ['none', 'inline', 'file'], help: 'Official API HTML handling' },
53
+ { name: 'output', default: './weixin-published', help: 'Official API artifact directory' },
54
+ ...OFFICIAL_API_ARGS,
55
+ ],
56
+ columns: FACADE_COLUMNS,
57
+ func: async (page, args) => {
58
+ const source = sourceValue(args.source);
59
+ if (source === 'browser') return browserRows(page, args, null);
60
+ const credentials = readOfficialApiCredentials(args);
61
+ if (source === 'auto' && !credentials.configured) return browserRows(page, args, 'api-not-configured');
62
+ try {
63
+ const rows = await freepublishListCommand.func({
64
+ ...args, offset: 0, count: Math.min(args.limit ?? 20, 20), content: args.content ?? 'none',
65
+ });
66
+ return projectOfficialRows(rows);
67
+ } catch (error) {
68
+ if (source === 'auto' && isFreepublishFallbackEligible(error)) {
69
+ return browserRows(page, args, 'api-not-authorized');
70
+ }
71
+ if (error instanceof EmptyResultError) throw error;
72
+ throw error;
73
+ }
74
+ },
75
+ });
76
+
77
+ export { sourceValue };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.48",
3
+ "version": "2.1.49",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },