@sovovs/bycli 2.1.47 → 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 +403 -0
- package/clis/weixin/_wechat/api-draft.js +3 -3
- package/clis/weixin/_wechat/api-freepublish.js +170 -0
- package/clis/weixin/_wechat/api-newspic.js +180 -0
- package/clis/weixin/_wechat/freepublish-fallback.js +17 -0
- package/clis/weixin/article-fetch.js +66 -0
- package/clis/weixin/create-newspic.js +61 -0
- package/clis/weixin/download.js +1 -1
- package/clis/weixin/freepublish-get.js +30 -0
- package/clis/weixin/freepublish-list.js +53 -0
- package/clis/weixin/published-articles.js +77 -0
- package/package.json +1 -1
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",
|
|
@@ -29044,6 +29141,62 @@
|
|
|
29044
29141
|
"sourceFile": "weixin/create-draft.js",
|
|
29045
29142
|
"navigateBefore": false
|
|
29046
29143
|
},
|
|
29144
|
+
{
|
|
29145
|
+
"site": "weixin",
|
|
29146
|
+
"name": "create-newspic",
|
|
29147
|
+
"description": "通过微信公众号官方 API 创建贴图草稿(不会公开发布)",
|
|
29148
|
+
"access": "write",
|
|
29149
|
+
"example": "bycli weixin create-newspic --title \"相册标题\" --images \"./01.jpg,http://example.com/02.png\" --content \"说明文字\" --appid wx123 --appsecret secret",
|
|
29150
|
+
"strategy": "local",
|
|
29151
|
+
"browser": false,
|
|
29152
|
+
"args": [
|
|
29153
|
+
{
|
|
29154
|
+
"name": "title",
|
|
29155
|
+
"type": "str",
|
|
29156
|
+
"required": true,
|
|
29157
|
+
"help": "贴图标题(最长 32 字)"
|
|
29158
|
+
},
|
|
29159
|
+
{
|
|
29160
|
+
"name": "images",
|
|
29161
|
+
"type": "str",
|
|
29162
|
+
"required": true,
|
|
29163
|
+
"help": "1–20 张本地或 HTTP(S) 图片,使用逗号分隔"
|
|
29164
|
+
},
|
|
29165
|
+
{
|
|
29166
|
+
"name": "content",
|
|
29167
|
+
"type": "str",
|
|
29168
|
+
"required": false,
|
|
29169
|
+
"valueRequired": true,
|
|
29170
|
+
"help": "可选的纯文本贴图说明"
|
|
29171
|
+
},
|
|
29172
|
+
{
|
|
29173
|
+
"name": "appid",
|
|
29174
|
+
"type": "str",
|
|
29175
|
+
"required": true,
|
|
29176
|
+
"help": "公众号 AppID"
|
|
29177
|
+
},
|
|
29178
|
+
{
|
|
29179
|
+
"name": "appsecret",
|
|
29180
|
+
"type": "str",
|
|
29181
|
+
"required": true,
|
|
29182
|
+
"help": "公众号 AppSecret;请勿提交到 shell 历史或日志"
|
|
29183
|
+
},
|
|
29184
|
+
{
|
|
29185
|
+
"name": "allow-private-image-hosts",
|
|
29186
|
+
"type": "boolean",
|
|
29187
|
+
"default": false,
|
|
29188
|
+
"required": false,
|
|
29189
|
+
"help": "允许下载 localhost/内网 HTTP(S) 图片;云元数据地址始终禁止"
|
|
29190
|
+
}
|
|
29191
|
+
],
|
|
29192
|
+
"columns": [
|
|
29193
|
+
"status",
|
|
29194
|
+
"detail"
|
|
29195
|
+
],
|
|
29196
|
+
"type": "js",
|
|
29197
|
+
"modulePath": "weixin/create-newspic.js",
|
|
29198
|
+
"sourceFile": "weixin/create-newspic.js"
|
|
29199
|
+
},
|
|
29047
29200
|
{
|
|
29048
29201
|
"site": "weixin",
|
|
29049
29202
|
"name": "download",
|
|
@@ -29210,6 +29363,157 @@
|
|
|
29210
29363
|
"sourceFile": "weixin/drafts.js",
|
|
29211
29364
|
"navigateBefore": false
|
|
29212
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
|
+
},
|
|
29213
29517
|
{
|
|
29214
29518
|
"site": "weixin",
|
|
29215
29519
|
"name": "home-overview",
|
|
@@ -29303,6 +29607,105 @@
|
|
|
29303
29607
|
"sourceFile": "weixin/published.js",
|
|
29304
29608
|
"navigateBefore": false
|
|
29305
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
|
+
},
|
|
29306
29709
|
{
|
|
29307
29710
|
"site": "weixin",
|
|
29308
29711
|
"name": "save-articles",
|
|
@@ -12,7 +12,7 @@ function apiError(context, payload) {
|
|
|
12
12
|
return new CommandExecutionError(`${context} failed (${code}): ${message}`);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
async function readJsonResponse(response, context) {
|
|
15
|
+
export async function readJsonResponse(response, context) {
|
|
16
16
|
let payload;
|
|
17
17
|
try {
|
|
18
18
|
payload = await response.json();
|
|
@@ -24,7 +24,7 @@ async function readJsonResponse(response, context) {
|
|
|
24
24
|
return payload;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
async function getAccessToken(appid, appsecret, fetchImpl) {
|
|
27
|
+
export async function getAccessToken(appid, appsecret, fetchImpl) {
|
|
28
28
|
const url = new URL(`${API_BASE}/token`);
|
|
29
29
|
url.searchParams.set('grant_type', 'client_credential');
|
|
30
30
|
url.searchParams.set('appid', appid);
|
|
@@ -43,7 +43,7 @@ function mimeType(filePath) {
|
|
|
43
43
|
return 'image/jpeg';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async function uploadImage(filePath, token, fetchImpl) {
|
|
46
|
+
export async function uploadImage(filePath, token, fetchImpl) {
|
|
47
47
|
const data = await nodeFs.readFile(filePath);
|
|
48
48
|
const form = new FormData();
|
|
49
49
|
form.append('media', new Blob([data], { type: mimeType(filePath) }), nodePath.basename(filePath));
|
|
@@ -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,180 @@
|
|
|
1
|
+
import * as nodePath from 'node:path';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
|
+
import { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
5
|
+
import { downloadRemoteImage } from './remote-image.js';
|
|
6
|
+
import { getAccessToken, readJsonResponse, uploadImage } from './api-draft.js';
|
|
7
|
+
|
|
8
|
+
const API_BASE = 'https://api.weixin.qq.com/cgi-bin';
|
|
9
|
+
const MIN_IMAGES = 1;
|
|
10
|
+
const MAX_IMAGES = 20;
|
|
11
|
+
const SUPPORTED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
12
|
+
|
|
13
|
+
function isRemoteImageSource(source) {
|
|
14
|
+
return /^https?:\/\//iu.test(String(source || ''));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validateImageCount(images) {
|
|
18
|
+
if (!Array.isArray(images) || images.length < MIN_IMAGES || images.length > MAX_IMAGES) {
|
|
19
|
+
throw new ArgumentError('newspic requires 1–20 images');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function resolveLocalImage(source, baseDir) {
|
|
24
|
+
const imagePath = nodePath.resolve(baseDir, source);
|
|
25
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.has(nodePath.extname(imagePath).toLowerCase())) {
|
|
26
|
+
throw new ArgumentError(`newspic image must be a jpg, jpeg, png, gif, or webp file: ${source}`);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
await access(imagePath, fsConstants.R_OK);
|
|
30
|
+
const info = await stat(imagePath);
|
|
31
|
+
if (!info.isFile() || info.size <= 0) throw new Error('not a non-empty file');
|
|
32
|
+
} catch {
|
|
33
|
+
throw new ArgumentError(`newspic image must be a readable non-empty file: ${imagePath}`);
|
|
34
|
+
}
|
|
35
|
+
return imagePath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function stageNewspicImages(images, {
|
|
39
|
+
baseDir = process.cwd(),
|
|
40
|
+
allowPrivateHosts = false,
|
|
41
|
+
fetchImpl = globalThis.fetch,
|
|
42
|
+
lookupImpl,
|
|
43
|
+
downloadImpl = downloadRemoteImage,
|
|
44
|
+
} = {}) {
|
|
45
|
+
validateImageCount(images);
|
|
46
|
+
const downloads = [];
|
|
47
|
+
const paths = [];
|
|
48
|
+
let cleaned = false;
|
|
49
|
+
const cleanup = async () => {
|
|
50
|
+
if (cleaned) return;
|
|
51
|
+
cleaned = true;
|
|
52
|
+
const results = await Promise.allSettled(downloads.map(download => download.cleanup()));
|
|
53
|
+
const failure = results.find(result => result.status === 'rejected');
|
|
54
|
+
if (failure) {
|
|
55
|
+
throw new CommandExecutionError(`Failed to clean up a temporary Weixin image: ${failure.reason?.message ?? failure.reason}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
for (const rawSource of images) {
|
|
60
|
+
const source = String(rawSource ?? '').trim();
|
|
61
|
+
if (!source) throw new ArgumentError('newspic image source must not be empty');
|
|
62
|
+
if (isRemoteImageSource(source)) {
|
|
63
|
+
const downloaded = await downloadImpl(source, {
|
|
64
|
+
allowPrivateHosts,
|
|
65
|
+
fetchImpl,
|
|
66
|
+
...(lookupImpl ? { lookupImpl } : {}),
|
|
67
|
+
});
|
|
68
|
+
downloads.push(downloaded);
|
|
69
|
+
paths.push(downloaded.path);
|
|
70
|
+
} else {
|
|
71
|
+
paths.push(await resolveLocalImage(source, baseDir));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { paths, cleanup };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
try {
|
|
77
|
+
await cleanup();
|
|
78
|
+
} catch (cleanupError) {
|
|
79
|
+
throw new CommandExecutionError(`${error?.message ?? error}; ${cleanupError.message}`);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function deleteMaterial(mediaId, token, fetchImpl) {
|
|
86
|
+
const url = new URL(`${API_BASE}/material/del_material`);
|
|
87
|
+
url.searchParams.set('access_token', token);
|
|
88
|
+
const response = await fetchImpl(url.toString(), {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
91
|
+
body: JSON.stringify({ media_id: mediaId }),
|
|
92
|
+
});
|
|
93
|
+
await readJsonResponse(response, '删除图片素材');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function createNewspicDraftViaApi({
|
|
97
|
+
appid,
|
|
98
|
+
appsecret,
|
|
99
|
+
title,
|
|
100
|
+
content = '',
|
|
101
|
+
images,
|
|
102
|
+
baseDir = process.cwd(),
|
|
103
|
+
fetchImpl = globalThis.fetch,
|
|
104
|
+
imageFetchImpl = globalThis.fetch,
|
|
105
|
+
lookupImpl,
|
|
106
|
+
downloadImpl = downloadRemoteImage,
|
|
107
|
+
allowPrivateImageHosts = false,
|
|
108
|
+
} = {}) {
|
|
109
|
+
if (!String(appid ?? '').trim() || !String(appsecret ?? '').trim()) {
|
|
110
|
+
throw new CommandExecutionError('newspic API requires both appid and appsecret');
|
|
111
|
+
}
|
|
112
|
+
if (typeof fetchImpl !== 'function') throw new CommandExecutionError('newspic API requires fetch support');
|
|
113
|
+
validateImageCount(images);
|
|
114
|
+
|
|
115
|
+
const staged = await stageNewspicImages(images, {
|
|
116
|
+
baseDir,
|
|
117
|
+
allowPrivateHosts: allowPrivateImageHosts,
|
|
118
|
+
fetchImpl: imageFetchImpl,
|
|
119
|
+
downloadImpl,
|
|
120
|
+
...(lookupImpl ? { lookupImpl } : {}),
|
|
121
|
+
});
|
|
122
|
+
let token;
|
|
123
|
+
const imageMediaIds = [];
|
|
124
|
+
let draftResult;
|
|
125
|
+
try {
|
|
126
|
+
token = await getAccessToken(String(appid).trim(), String(appsecret).trim(), fetchImpl);
|
|
127
|
+
for (const imagePath of staged.paths) {
|
|
128
|
+
const uploaded = await uploadImage(imagePath, token, fetchImpl);
|
|
129
|
+
imageMediaIds.push(uploaded.media_id);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const url = new URL(`${API_BASE}/draft/add`);
|
|
133
|
+
url.searchParams.set('access_token', token);
|
|
134
|
+
const body = {
|
|
135
|
+
articles: [{
|
|
136
|
+
article_type: 'newspic',
|
|
137
|
+
title: String(title ?? ''),
|
|
138
|
+
content: String(content ?? ''),
|
|
139
|
+
need_open_comment: 0,
|
|
140
|
+
only_fans_can_comment: 0,
|
|
141
|
+
image_info: {
|
|
142
|
+
image_list: imageMediaIds.map(imageMediaId => ({ image_media_id: imageMediaId })),
|
|
143
|
+
},
|
|
144
|
+
}],
|
|
145
|
+
};
|
|
146
|
+
const response = await fetchImpl(url.toString(), {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
149
|
+
body: JSON.stringify(body),
|
|
150
|
+
});
|
|
151
|
+
const payload = await readJsonResponse(response, '创建贴图草稿');
|
|
152
|
+
if (!payload.media_id) throw new CommandExecutionError('创建贴图草稿 failed: response did not contain media_id');
|
|
153
|
+
draftResult = { mediaId: payload.media_id, imageMediaIds };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
const cleanupProblems = [];
|
|
156
|
+
if (token && imageMediaIds.length > 0) {
|
|
157
|
+
const results = await Promise.allSettled(imageMediaIds.map(mediaId => deleteMaterial(mediaId, token, fetchImpl)));
|
|
158
|
+
const failedMediaIds = imageMediaIds.filter((_mediaId, index) => results[index].status === 'rejected');
|
|
159
|
+
if (failedMediaIds.length > 0) {
|
|
160
|
+
cleanupProblems.push(`failed to delete permanent materials: ${failedMediaIds.join(', ')}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
await staged.cleanup();
|
|
165
|
+
} catch (cleanupError) {
|
|
166
|
+
cleanupProblems.push(cleanupError.message);
|
|
167
|
+
}
|
|
168
|
+
if (cleanupProblems.length > 0) {
|
|
169
|
+
throw new CommandExecutionError(`${error?.message ?? error}; cleanup incomplete: ${cleanupProblems.join('; ')}`);
|
|
170
|
+
}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await staged.cleanup();
|
|
176
|
+
} catch (cleanupError) {
|
|
177
|
+
return { ...draftResult, cleanupWarning: cleanupError.message };
|
|
178
|
+
}
|
|
179
|
+
return draftResult;
|
|
180
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError } from '@sovovs/bycli/errors';
|
|
3
|
+
import { createNewspicDraftViaApi } from './_wechat/api-newspic.js';
|
|
4
|
+
|
|
5
|
+
const MAX_TITLE_LENGTH = 32;
|
|
6
|
+
|
|
7
|
+
function codePointLength(value) {
|
|
8
|
+
return [...value].length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function requiredText(value, name) {
|
|
12
|
+
const text = String(value ?? '').trim();
|
|
13
|
+
if (!text) throw new ArgumentError(`${name} must not be empty`);
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseImages(value) {
|
|
18
|
+
const images = requiredText(value, 'images').split(',').map(item => item.trim());
|
|
19
|
+
if (images.some(image => !image)) throw new ArgumentError('images must not contain empty entries');
|
|
20
|
+
if (images.length < 1 || images.length > 20) throw new ArgumentError('newspic requires 1–20 images');
|
|
21
|
+
return images;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const createNewspicCommand = cli({
|
|
25
|
+
site: 'weixin',
|
|
26
|
+
name: 'create-newspic',
|
|
27
|
+
access: 'write',
|
|
28
|
+
description: '通过微信公众号官方 API 创建贴图草稿(不会公开发布)',
|
|
29
|
+
example: 'bycli weixin create-newspic --title "相册标题" --images "./01.jpg,http://example.com/02.png" --content "说明文字" --appid wx123 --appsecret secret',
|
|
30
|
+
strategy: Strategy.LOCAL,
|
|
31
|
+
browser: false,
|
|
32
|
+
args: [
|
|
33
|
+
{ name: 'title', required: true, help: '贴图标题(最长 32 字)' },
|
|
34
|
+
{ name: 'images', required: true, help: '1–20 张本地或 HTTP(S) 图片,使用逗号分隔' },
|
|
35
|
+
{ name: 'content', valueRequired: true, help: '可选的纯文本贴图说明' },
|
|
36
|
+
{ name: 'appid', required: true, help: '公众号 AppID' },
|
|
37
|
+
{ name: 'appsecret', required: true, help: '公众号 AppSecret;请勿提交到 shell 历史或日志' },
|
|
38
|
+
{ name: 'allow-private-image-hosts', type: 'boolean', default: false, help: '允许下载 localhost/内网 HTTP(S) 图片;云元数据地址始终禁止' },
|
|
39
|
+
],
|
|
40
|
+
columns: ['status', 'detail'],
|
|
41
|
+
|
|
42
|
+
func: async (kwargs) => {
|
|
43
|
+
const title = requiredText(kwargs.title, 'title');
|
|
44
|
+
if (codePointLength(title) > MAX_TITLE_LENGTH) {
|
|
45
|
+
throw new ArgumentError(`title must be at most ${MAX_TITLE_LENGTH} characters`);
|
|
46
|
+
}
|
|
47
|
+
const images = parseImages(kwargs.images);
|
|
48
|
+
const result = await createNewspicDraftViaApi({
|
|
49
|
+
appid: requiredText(kwargs.appid, 'appid'),
|
|
50
|
+
appsecret: requiredText(kwargs.appsecret, 'appsecret'),
|
|
51
|
+
title,
|
|
52
|
+
content: kwargs.content == null ? '' : String(kwargs.content),
|
|
53
|
+
images,
|
|
54
|
+
allowPrivateImageHosts: kwargs['allow-private-image-hosts'] === true,
|
|
55
|
+
});
|
|
56
|
+
return [{
|
|
57
|
+
status: 'newspic draft created',
|
|
58
|
+
detail: `"${title}" (media_id: ${result.mediaId}, images: ${images.length})${result.cleanupWarning ? `; warning: ${result.cleanupWarning}` : ''}`,
|
|
59
|
+
}];
|
|
60
|
+
},
|
|
61
|
+
});
|
package/clis/weixin/download.js
CHANGED
|
@@ -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 };
|