@sovovs/bycli 2.1.11 → 2.1.13

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.
@@ -0,0 +1,34 @@
1
+ import crawler from '@sovovs/wechat-article-crawler';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
3
+
4
+ const {
5
+ CrawlerError,
6
+ collectArticles,
7
+ createWechatApi,
8
+ isTrustedWechatArticleUrl,
9
+ saveArticles,
10
+ } = crawler;
11
+
12
+ export {
13
+ CrawlerError,
14
+ collectArticles,
15
+ createWechatApi,
16
+ isTrustedWechatArticleUrl,
17
+ saveArticles,
18
+ };
19
+
20
+ export async function callCrawler(operation) {
21
+ try {
22
+ return await operation();
23
+ } catch (error) {
24
+ if (!(error instanceof CrawlerError)) throw error;
25
+
26
+ if (error.code === 'INVALID_ARGUMENT') {
27
+ throw new ArgumentError(error.message);
28
+ }
29
+ if (error.code === 'AUTH_REQUIRED') {
30
+ throw new AuthRequiredError('mp.weixin.qq.com', error.message);
31
+ }
32
+ throw new CommandExecutionError(error.message);
33
+ }
34
+ }
@@ -1,8 +1,7 @@
1
1
  import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
2
2
  import { cli, Strategy } from '@sovovs/bycli/registry';
3
3
  import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
4
- import { collectArticles } from './_wechat/article-service.js';
5
- import { createWechatApi } from './_wechat/wechat-api.js';
4
+ import { callCrawler, collectArticles, createWechatApi } from './_wechat/crawler-runtime.js';
6
5
  import { readAuthSource } from './_wechat/args.js';
7
6
 
8
7
  const DOMAIN = 'mp.weixin.qq.com';
@@ -24,8 +23,10 @@ export const articlesCommand = cli({
24
23
  const authSource = readAuthSource(args);
25
24
  const credentials = authSource === 'env'
26
25
  ? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
27
- const { fetchPage } = createWechatApi(credentials);
28
- const { articles } = await collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
26
+ const { articles } = await callCrawler(async () => {
27
+ const { fetchPage } = createWechatApi(credentials);
28
+ return collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
29
+ });
29
30
  if (articles.length === 0) throw new EmptyResultError('weixin articles', `No published articles were found for ${fakeid}.`);
30
31
  return articles.map(article => ({
31
32
  title: article.title, author: article.author || null, digest: article.digest || null,
@@ -2,10 +2,11 @@ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs
2
2
  import { MAX_WECHAT_HTML_BYTES } from '@sovovs/bycli/download/wechat-article';
3
3
  import { cli, Strategy } from '@sovovs/bycli/registry';
4
4
  import { readEnvironmentCredentials, resolveBrowserCredentials } from './_wechat/auth-session.js';
5
- import { collectArticles, isTrustedWechatArticleUrl } from './_wechat/article-service.js';
6
- import { saveArticles } from './_wechat/save-service.js';
7
- import { createWechatApi } from './_wechat/wechat-api.js';
5
+ import {
6
+ callCrawler, collectArticles, createWechatApi, isTrustedWechatArticleUrl, saveArticles,
7
+ } from './_wechat/crawler-runtime.js';
8
8
  import { readAuthSource } from './_wechat/args.js';
9
+ import { wechatArticleToMarkdown } from './_wechat/markdown.js';
9
10
 
10
11
  const DOMAIN = 'mp.weixin.qq.com';
11
12
  const browserRequired = args => readAuthSource(args) === 'browser';
@@ -165,12 +166,18 @@ export const saveArticlesCommand = cli({
165
166
  const authSource = readAuthSource(args);
166
167
  const credentials = authSource === 'env'
167
168
  ? readEnvironmentCredentials(false) : await resolveBrowserCredentials(page);
168
- const { fetchPage } = createWechatApi(credentials);
169
- const { articles } = await collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
170
169
  const articleHtmlDownloader = createArticleHtmlDownloader({ authSource, page });
171
- const rows = await saveArticles({
172
- articles, accountName: String(args.name ?? '').trim(),
173
- outputDir: args.output ?? './weixin-articles', fetchArticleHtml: articleHtmlDownloader,
170
+ const rows = await callCrawler(async () => {
171
+ const { fetchPage } = createWechatApi(credentials);
172
+ const { articles } = await collectArticles({ fakeid, fetchPage, limit: args.limit, maxPages: args['max-pages'] });
173
+ return saveArticles({
174
+ articles, accountName: String(args.name ?? '').trim(),
175
+ outputDir: args.output ?? './weixin-articles', fetchArticleHtml: articleHtmlDownloader,
176
+ buildMarkdown: (article, html) => wechatArticleToMarkdown({
177
+ html, title: article.title, accountName: String(args.name ?? '').trim(), author: article.author,
178
+ publishedAt: article.publishedAt, digest: article.digest, url: article.url,
179
+ }), existingFilePolicy: 'suffix',
180
+ });
174
181
  });
175
182
  return rows.map(row => ({
176
183
  title: row.title, status: row.status, stage: row.stage || null, path: row.saved || null,
@@ -87,6 +87,27 @@ export interface BrowserProfileStatus {
87
87
  pending: number;
88
88
  lastSeenAt?: number;
89
89
  }
90
+ export type DaemonStatusProbe = {
91
+ kind: 'status';
92
+ status: DaemonStatus;
93
+ } | {
94
+ kind: 'stopped';
95
+ } | {
96
+ kind: 'timeout';
97
+ } | {
98
+ kind: 'http_error';
99
+ statusCode: number;
100
+ } | {
101
+ kind: 'invalid_response';
102
+ } | {
103
+ kind: 'config_error';
104
+ } | {
105
+ kind: 'network_error';
106
+ };
107
+ export declare function probeDaemonStatus(opts?: {
108
+ timeout?: number;
109
+ contextId?: string;
110
+ }): Promise<DaemonStatusProbe>;
90
111
  export declare function fetchDaemonStatus(opts?: {
91
112
  timeout?: number;
92
113
  contextId?: string;
@@ -3,12 +3,10 @@
3
3
  *
4
4
  * Provides a typed send() function that posts a Command and returns a Result.
5
5
  */
6
- import { DEFAULT_DAEMON_PORT } from '../constants.js';
7
6
  import { sleep } from '../utils.js';
7
+ import { resolveDaemonPort } from './daemon-config.js';
8
8
  import { classifyBrowserError } from './errors.js';
9
9
  import { resolveProfileContextId } from './profile.js';
10
- const DAEMON_PORT = parseInt(process.env.BYCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
11
- const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
12
10
  const BYCLI_HEADERS = { 'X-byCLI': '1' };
13
11
  let _idCounter = 0;
14
12
  function generateId() {
@@ -24,32 +22,69 @@ export class BrowserCommandError extends Error {
24
22
  this.name = 'BrowserCommandError';
25
23
  }
26
24
  }
27
- async function requestDaemon(pathname, init) {
25
+ async function consumeDaemonResponse(pathname, init, consume, port) {
28
26
  const { timeout = 2000, headers, ...rest } = init ?? {};
27
+ const portResolution = port === undefined ? resolveDaemonPort() : { ok: true, port };
28
+ if (!portResolution.ok)
29
+ throw new Error('Invalid BYCLI_DAEMON_PORT configuration');
29
30
  const controller = new AbortController();
30
31
  const timer = setTimeout(() => controller.abort(), timeout);
31
32
  try {
32
- return await fetch(`${DAEMON_URL}${pathname}`, {
33
+ const response = await fetch(`http://127.0.0.1:${portResolution.port}${pathname}`, {
33
34
  ...rest,
34
35
  headers: { ...BYCLI_HEADERS, ...headers },
35
36
  signal: controller.signal,
36
37
  });
38
+ return await consume(response);
37
39
  }
38
40
  finally {
39
41
  clearTimeout(timer);
40
42
  }
41
43
  }
42
- export async function fetchDaemonStatus(opts) {
44
+ async function requestDaemon(pathname, init) {
45
+ return consumeDaemonResponse(pathname, init, async (response) => response);
46
+ }
47
+ function errorCode(error) {
48
+ if (!error || typeof error !== 'object')
49
+ return undefined;
50
+ const candidate = error;
51
+ if (typeof candidate.code === 'string')
52
+ return candidate.code;
53
+ return errorCode(candidate.cause);
54
+ }
55
+ export async function probeDaemonStatus(opts) {
56
+ const portResolution = resolveDaemonPort();
57
+ if (!portResolution.ok)
58
+ return { kind: 'config_error' };
43
59
  try {
44
60
  const params = opts?.contextId ? `?contextId=${encodeURIComponent(opts.contextId)}` : '';
45
- const res = await requestDaemon(`/status${params}`, { timeout: opts?.timeout ?? 2000 });
46
- if (!res.ok)
47
- return null;
48
- return await res.json();
61
+ return await consumeDaemonResponse(`/status${params}`, { timeout: opts?.timeout ?? 2000 }, async (res) => {
62
+ if (!res.ok)
63
+ return { kind: 'http_error', statusCode: res.status };
64
+ try {
65
+ return { kind: 'status', status: await res.json() };
66
+ }
67
+ catch (error) {
68
+ if (error instanceof Error && error.name === 'AbortError')
69
+ throw error;
70
+ return { kind: 'invalid_response' };
71
+ }
72
+ }, portResolution.port);
49
73
  }
50
- catch {
74
+ catch (error) {
75
+ if (error instanceof Error && error.name === 'AbortError')
76
+ return { kind: 'timeout' };
77
+ if (errorCode(error) === 'ECONNREFUSED')
78
+ return { kind: 'stopped' };
79
+ return { kind: 'network_error' };
80
+ }
81
+ }
82
+ export async function fetchDaemonStatus(opts) {
83
+ const result = await probeDaemonStatus(opts);
84
+ if (result.kind !== 'status') {
51
85
  return null;
52
86
  }
87
+ return result.status;
53
88
  }
54
89
  /**
55
90
  * Unified daemon health check — single entry point for all status queries.
@@ -0,0 +1,9 @@
1
+ import { DEFAULT_DAEMON_PORT } from '../constants.js';
2
+ export type DaemonPortResolution = {
3
+ ok: true;
4
+ port: number;
5
+ } | {
6
+ ok: false;
7
+ port: typeof DEFAULT_DAEMON_PORT;
8
+ };
9
+ export declare function resolveDaemonPort(raw?: string | undefined): DaemonPortResolution;
@@ -0,0 +1,12 @@
1
+ import { DEFAULT_DAEMON_PORT } from '../constants.js';
2
+ export function resolveDaemonPort(raw = process.env.BYCLI_DAEMON_PORT) {
3
+ if (raw === undefined)
4
+ return { ok: true, port: DEFAULT_DAEMON_PORT };
5
+ if (!/^\d+$/.test(raw))
6
+ return { ok: false, port: DEFAULT_DAEMON_PORT };
7
+ const port = Number(raw);
8
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
9
+ return { ok: false, port: DEFAULT_DAEMON_PORT };
10
+ }
11
+ return { ok: true, port };
12
+ }
package/dist/src/cli.js CHANGED
@@ -2958,7 +2958,9 @@ cli({
2958
2958
  daemonCmd
2959
2959
  .command('status')
2960
2960
  .description('Show daemon status')
2961
- .action(async () => { await daemonStatus(); });
2961
+ .option('--json', 'Output stable machine-readable JSON')
2962
+ .option('--verbose', 'Include process and profile diagnostics in JSON output')
2963
+ .action(async (opts) => { await daemonStatus(opts); });
2962
2964
  daemonCmd
2963
2965
  .command('stop')
2964
2966
  .description('Stop the daemon')
@@ -0,0 +1,60 @@
1
+ import type { DaemonStatusProbe } from '../browser/daemon-client.js';
2
+ export declare const DAEMON_STATUS_SCHEMA_VERSION: "1.0";
3
+ export type DaemonStatusState = 'stopped' | 'daemon_stale' | 'extension_disconnected' | 'profile_required' | 'profile_disconnected' | 'ready' | 'degraded';
4
+ export interface DaemonStatusIssue {
5
+ code: string;
6
+ severity: 'info' | 'warning' | 'error';
7
+ message: string;
8
+ remediation?: {
9
+ type: 'command';
10
+ command: string[];
11
+ };
12
+ }
13
+ export interface DaemonStatusReport {
14
+ schemaVersion: typeof DAEMON_STATUS_SCHEMA_VERSION;
15
+ command: 'daemon.status';
16
+ ok: boolean;
17
+ state: DaemonStatusState;
18
+ cli: {
19
+ version: string;
20
+ };
21
+ daemon: {
22
+ state: 'stopped' | 'running' | 'unknown';
23
+ version: string | null;
24
+ port: number;
25
+ stale: boolean;
26
+ pid?: number;
27
+ uptimeSeconds?: number;
28
+ memoryMB?: number;
29
+ };
30
+ extension: {
31
+ state: 'unknown' | 'disconnected' | 'connected';
32
+ version: string | null;
33
+ compatibility: 'unknown' | 'compatible' | 'incompatible';
34
+ };
35
+ profiles: {
36
+ connectedCount: number;
37
+ selectionRequired: boolean;
38
+ selectedContextId?: string | null;
39
+ items?: Array<{
40
+ contextId: string;
41
+ alias?: string;
42
+ selected: boolean;
43
+ extensionVersion: string | null;
44
+ }>;
45
+ };
46
+ issues: DaemonStatusIssue[];
47
+ error?: {
48
+ code: string;
49
+ message: string;
50
+ };
51
+ }
52
+ export interface DaemonStatusReportOptions {
53
+ cliVersion: string;
54
+ port: number;
55
+ verbose?: boolean;
56
+ /** Profile aliases in the persisted alias -> contextId form. */
57
+ profileAliases?: Record<string, string>;
58
+ }
59
+ export declare function buildDaemonStatusErrorReport(options: DaemonStatusReportOptions, code: string, message: string): DaemonStatusReport;
60
+ export declare function buildDaemonStatusReport(probe: DaemonStatusProbe, options: DaemonStatusReportOptions): DaemonStatusReport;
@@ -0,0 +1,195 @@
1
+ import { isDaemonStale } from '../browser/daemon-version.js';
2
+ import { satisfiesRange } from '../plugin-manifest.js';
3
+ export const DAEMON_STATUS_SCHEMA_VERSION = '1.0';
4
+ function baseReport(options) {
5
+ return {
6
+ schemaVersion: DAEMON_STATUS_SCHEMA_VERSION,
7
+ command: 'daemon.status',
8
+ ok: true,
9
+ state: 'stopped',
10
+ cli: { version: options.cliVersion },
11
+ daemon: { state: 'stopped', version: null, port: options.port, stale: false },
12
+ extension: { state: 'unknown', version: null, compatibility: 'unknown' },
13
+ profiles: { connectedCount: 0, selectionRequired: false },
14
+ issues: [],
15
+ };
16
+ }
17
+ function failureReport(options, code, message) {
18
+ const result = baseReport(options);
19
+ result.ok = false;
20
+ result.state = 'degraded';
21
+ result.daemon.state = 'unknown';
22
+ result.error = { code, message };
23
+ return result;
24
+ }
25
+ export function buildDaemonStatusErrorReport(options, code, message) {
26
+ return failureReport(options, code, message);
27
+ }
28
+ function isOptionalString(value) {
29
+ return value === undefined || typeof value === 'string';
30
+ }
31
+ function isOptionalBoolean(value) {
32
+ return value === undefined || typeof value === 'boolean';
33
+ }
34
+ function isFiniteNumber(value) {
35
+ return typeof value === 'number' && Number.isFinite(value);
36
+ }
37
+ function isProfileStatus(value) {
38
+ if (!value || typeof value !== 'object')
39
+ return false;
40
+ const profile = value;
41
+ return typeof profile.contextId === 'string'
42
+ && typeof profile.extensionConnected === 'boolean'
43
+ && isFiniteNumber(profile.pending)
44
+ && isOptionalString(profile.extensionVersion)
45
+ && isOptionalString(profile.extensionCompatRange);
46
+ }
47
+ function isDaemonStatus(value) {
48
+ if (!value || typeof value !== 'object')
49
+ return false;
50
+ const status = value;
51
+ return status.ok === true
52
+ && isFiniteNumber(status.pid)
53
+ && isFiniteNumber(status.uptime)
54
+ && typeof status.extensionConnected === 'boolean'
55
+ && isFiniteNumber(status.pending)
56
+ && isFiniteNumber(status.memoryMB)
57
+ && isFiniteNumber(status.port)
58
+ && isOptionalString(status.daemonVersion)
59
+ && isOptionalString(status.extensionVersion)
60
+ && isOptionalString(status.extensionCompatRange)
61
+ && isOptionalString(status.contextId)
62
+ && isOptionalBoolean(status.profileRequired)
63
+ && isOptionalBoolean(status.profileDisconnected)
64
+ && (status.profiles === undefined
65
+ || (Array.isArray(status.profiles) && status.profiles.every(isProfileStatus)));
66
+ }
67
+ function compatibilityOf(status, cliVersion) {
68
+ if (!status.extensionConnected || !status.extensionVersion || !status.extensionCompatRange) {
69
+ return 'unknown';
70
+ }
71
+ return satisfiesRange(cliVersion, status.extensionCompatRange) ? 'compatible' : 'incompatible';
72
+ }
73
+ function aliasForContextId(aliases, contextId) {
74
+ return Object.entries(aliases).find(([, id]) => id === contextId)?.[0];
75
+ }
76
+ function probeFailure(probe) {
77
+ switch (probe.kind) {
78
+ case 'timeout':
79
+ return { code: 'daemon_status_timeout', message: 'Timed out while requesting daemon status.' };
80
+ case 'http_error':
81
+ return { code: 'daemon_http_error', message: `Daemon status request failed with HTTP ${probe.statusCode}.` };
82
+ case 'invalid_response':
83
+ return { code: 'invalid_daemon_response', message: 'The daemon returned an invalid status response.' };
84
+ case 'config_error':
85
+ return { code: 'invalid_daemon_config', message: 'BYCLI_DAEMON_PORT must be an integer from 1 to 65535.' };
86
+ case 'network_error':
87
+ return { code: 'daemon_unreachable', message: 'The daemon status endpoint could not be reached.' };
88
+ }
89
+ }
90
+ export function buildDaemonStatusReport(probe, options) {
91
+ if (probe.kind === 'stopped')
92
+ return baseReport(options);
93
+ if (probe.kind !== 'status') {
94
+ const failure = probeFailure(probe);
95
+ return failureReport(options, failure.code, failure.message);
96
+ }
97
+ if (!isDaemonStatus(probe.status)) {
98
+ return failureReport(options, 'invalid_daemon_response', 'The daemon returned an invalid status response.');
99
+ }
100
+ const status = probe.status;
101
+ const result = baseReport(options);
102
+ const stale = isDaemonStale(status, options.cliVersion);
103
+ const compatibility = compatibilityOf(status, options.cliVersion);
104
+ const connectedProfiles = status.profiles?.filter((profile) => profile.extensionConnected) ?? [];
105
+ result.daemon = {
106
+ state: 'running',
107
+ version: status.daemonVersion ?? null,
108
+ port: status.port,
109
+ stale,
110
+ ...(options.verbose && {
111
+ pid: status.pid,
112
+ uptimeSeconds: status.uptime,
113
+ memoryMB: status.memoryMB,
114
+ }),
115
+ };
116
+ result.extension = {
117
+ state: status.extensionConnected ? 'connected' : 'disconnected',
118
+ version: status.extensionVersion ?? null,
119
+ compatibility,
120
+ };
121
+ result.profiles = {
122
+ connectedCount: connectedProfiles.length,
123
+ selectionRequired: status.profileRequired === true,
124
+ ...(options.verbose && {
125
+ selectedContextId: status.contextId ?? null,
126
+ items: (status.profiles ?? []).map((profile) => {
127
+ const alias = aliasForContextId(options.profileAliases ?? {}, profile.contextId);
128
+ return {
129
+ contextId: profile.contextId,
130
+ ...(alias !== undefined && { alias }),
131
+ selected: profile.contextId === status.contextId,
132
+ extensionVersion: profile.extensionVersion ?? null,
133
+ };
134
+ }),
135
+ }),
136
+ };
137
+ if (stale) {
138
+ result.issues.push({
139
+ code: 'daemon_stale',
140
+ severity: 'warning',
141
+ message: 'The daemon version does not match the CLI version.',
142
+ remediation: { type: 'command', command: ['bycli', 'daemon', 'restart'] },
143
+ });
144
+ }
145
+ if (!status.extensionConnected) {
146
+ result.issues.push({
147
+ code: 'extension_disconnected',
148
+ severity: 'warning',
149
+ message: 'The Browser Bridge extension is not connected.',
150
+ });
151
+ }
152
+ else if (!status.extensionVersion) {
153
+ result.issues.push({
154
+ code: 'extension_version_missing',
155
+ severity: 'warning',
156
+ message: 'The connected Browser Bridge extension did not report its version.',
157
+ });
158
+ }
159
+ if (compatibility === 'incompatible') {
160
+ result.issues.push({
161
+ code: 'extension_incompatible',
162
+ severity: 'warning',
163
+ message: 'The CLI version is incompatible with the Browser Bridge extension.',
164
+ });
165
+ }
166
+ if (status.profileRequired) {
167
+ result.issues.push({
168
+ code: 'profile_selection_required',
169
+ severity: 'warning',
170
+ message: 'Multiple Browser Bridge profiles are connected and no profile is selected.',
171
+ remediation: { type: 'command', command: ['bycli', 'profile', 'list'] },
172
+ });
173
+ }
174
+ if (status.profileDisconnected) {
175
+ result.issues.push({
176
+ code: 'selected_profile_disconnected',
177
+ severity: 'warning',
178
+ message: 'The selected Browser Bridge profile is disconnected.',
179
+ remediation: { type: 'command', command: ['bycli', 'profile', 'list'] },
180
+ });
181
+ }
182
+ if (stale)
183
+ result.state = 'daemon_stale';
184
+ else if (status.profileRequired)
185
+ result.state = 'profile_required';
186
+ else if (status.profileDisconnected)
187
+ result.state = 'profile_disconnected';
188
+ else if (!status.extensionConnected)
189
+ result.state = 'extension_disconnected';
190
+ else if (compatibility === 'incompatible')
191
+ result.state = 'degraded';
192
+ else
193
+ result.state = 'ready';
194
+ return result;
195
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -4,6 +4,12 @@
4
4
  * bycli daemon stop — graceful shutdown
5
5
  * bycli daemon restart — graceful shutdown, then start a fresh daemon
6
6
  */
7
- export declare function daemonStatus(): Promise<void>;
7
+ export interface DaemonStatusOptions {
8
+ json?: boolean;
9
+ verbose?: boolean;
10
+ /** Pre-validated fast-path usage failure; keeps --json output machine-readable. */
11
+ usageError?: string;
12
+ }
13
+ export declare function daemonStatus(options?: DaemonStatusOptions): Promise<void>;
8
14
  export declare function daemonStop(): Promise<void>;
9
15
  export declare function daemonRestart(): Promise<void>;
@@ -4,13 +4,57 @@
4
4
  * bycli daemon stop — graceful shutdown
5
5
  * bycli daemon restart — graceful shutdown, then start a fresh daemon
6
6
  */
7
- import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
7
+ import { fetchDaemonStatus, probeDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
8
+ import { resolveDaemonPort } from '../browser/daemon-config.js';
8
9
  import { restartDaemon } from '../browser/daemon-lifecycle.js';
10
+ import { loadProfileConfig } from '../browser/profile.js';
9
11
  import { formatDuration } from '../download/progress.js';
12
+ import { EXIT_CODES } from '../errors.js';
10
13
  import { log } from '../logger.js';
11
14
  import { PKG_VERSION } from '../version.js';
12
15
  import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js';
13
- export async function daemonStatus() {
16
+ import { buildDaemonStatusErrorReport, buildDaemonStatusReport } from './daemon-status.js';
17
+ function configuredDaemonPort() {
18
+ return resolveDaemonPort().port;
19
+ }
20
+ function jsonStatusExitCode(errorCode) {
21
+ if (!errorCode)
22
+ return EXIT_CODES.SUCCESS;
23
+ if (errorCode === 'daemon_status_timeout')
24
+ return EXIT_CODES.TEMPFAIL;
25
+ if (errorCode === 'invalid_daemon_response' || errorCode === 'invalid_daemon_config') {
26
+ return EXIT_CODES.CONFIG_ERROR;
27
+ }
28
+ return EXIT_CODES.GENERIC_ERROR;
29
+ }
30
+ export async function daemonStatus(options = {}) {
31
+ if (options.verbose && !options.json) {
32
+ console.error('Error: --verbose requires --json.');
33
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
34
+ return;
35
+ }
36
+ if (options.json) {
37
+ if (options.usageError) {
38
+ const report = buildDaemonStatusErrorReport({
39
+ cliVersion: PKG_VERSION,
40
+ port: configuredDaemonPort(),
41
+ }, 'invalid_arguments', options.usageError);
42
+ console.log(JSON.stringify(report));
43
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
44
+ return;
45
+ }
46
+ const probe = await probeDaemonStatus();
47
+ const profileConfig = loadProfileConfig();
48
+ const report = buildDaemonStatusReport(probe, {
49
+ cliVersion: PKG_VERSION,
50
+ port: configuredDaemonPort(),
51
+ verbose: options.verbose,
52
+ profileAliases: profileConfig.aliases,
53
+ });
54
+ console.log(JSON.stringify(report));
55
+ process.exitCode = jsonStatusExitCode(report.error?.code);
56
+ return;
57
+ }
14
58
  const status = await fetchDaemonStatus();
15
59
  if (!status) {
16
60
  console.log('Daemon: not running');
@@ -0,0 +1 @@
1
+ export {};
package/dist/src/main.js CHANGED
@@ -39,6 +39,29 @@ if (typeof globalThis.Bun === 'undefined' && !isSupportedNodeVersion(process.ver
39
39
  ].join('\n'));
40
40
  process.exit(EXIT_CODES.CONFIG_ERROR);
41
41
  }
42
+ // Fast path: passive machine-readable daemon status. Keep this before discovery,
43
+ // hooks, proxy installation, and update checks so stdout/stderr remain API-safe
44
+ // and the only network operation is the loopback daemon status request.
45
+ const daemonStatusArgs = argv.slice(2);
46
+ if (argv[0] === 'daemon'
47
+ && argv[1] === 'status'
48
+ && daemonStatusArgs.includes('--json')) {
49
+ const unsupportedArgs = daemonStatusArgs.filter((arg) => arg !== '--json' && arg !== '--verbose');
50
+ const { daemonStatus } = await import('./commands/daemon.js');
51
+ await daemonStatus({
52
+ json: true,
53
+ verbose: daemonStatusArgs.includes('--verbose'),
54
+ ...(unsupportedArgs.length > 0 && {
55
+ usageError: `Unsupported daemon status argument(s): ${unsupportedArgs.join(', ')}`,
56
+ }),
57
+ });
58
+ // console.log() may still be buffered when stdout is piped. An empty queued
59
+ // write completes only after the preceding JSON line has drained.
60
+ await new Promise((resolve, reject) => {
61
+ process.stdout.write('', (error) => error ? reject(error) : resolve());
62
+ });
63
+ process.exit(process.exitCode ?? EXIT_CODES.SUCCESS);
64
+ }
42
65
  // Fast path: --version (only when it's the top-level intent, not passed to a subcommand)
43
66
  // e.g. `bycli --version` or `bycli -V`, but NOT `bycli gh --version`
44
67
  if (argv[0] === '--version' || argv[0] === '-V') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.11",
3
+ "version": "2.1.13",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -90,6 +90,7 @@
90
90
  "dependencies": {
91
91
  "@mozilla/readability": "^0.6.0",
92
92
  "@sovovs/bycli-recorder-core": "^0.1.0",
93
+ "@sovovs/wechat-article-crawler": "^1.1.0",
93
94
  "cli-table3": "^0.6.5",
94
95
  "commander": "^14.0.3",
95
96
  "js-yaml": "^4.1.0",
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { dirname, join, resolve } from 'node:path';
6
+ import { createRequire } from 'node:module';
6
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
8
 
8
9
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -58,8 +59,24 @@ try {
58
59
  project, 'node_modules/@sovovs/bycli/package.json',
59
60
  ), 'utf8'));
60
61
  assert.equal(mainManifest.dependencies?.['@sovovs/bycli-recorder-core'], '^0.1.0');
62
+ assert.equal(mainManifest.dependencies?.['@sovovs/wechat-article-crawler'], '^1.1.0');
61
63
 
62
64
  const coreDirectory = join(project, 'node_modules/@sovovs/bycli-recorder-core');
65
+ const crawlerDirectoryInstalled = join(project, 'node_modules/@sovovs/wechat-article-crawler');
66
+ const crawlerManifest = JSON.parse(readFileSync(
67
+ join(crawlerDirectoryInstalled, 'package.json'), 'utf8',
68
+ ));
69
+ assert.equal(crawlerManifest.version, '1.1.2');
70
+ const projectRequire = createRequire(join(project, 'package.json'));
71
+ const crawlerEntry = projectRequire.resolve('@sovovs/wechat-article-crawler');
72
+ const crawlerModule = await import(pathToFileURL(crawlerEntry).href);
73
+ const crawlerApi = crawlerModule.default ?? crawlerModule;
74
+ for (const name of [
75
+ 'CrawlerError', 'createWechatApi', 'collectArticles',
76
+ 'isTrustedWechatArticleUrl', 'saveArticles',
77
+ ]) {
78
+ assert.ok(crawlerApi[name], `crawler root API missing ${name}`);
79
+ }
63
80
  const recorderEntry = join(
64
81
  project, 'node_modules/@sovovs/bycli/dist/src/browser/analyze.js',
65
82
  );
@@ -1,124 +0,0 @@
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
- }
@@ -1,176 +0,0 @@
1
- import * as defaultFs from 'node:fs';
2
- import path from 'node:path';
3
- import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
4
- import { cleanMarkdownFilename, wechatArticleToMarkdown } from './markdown.js';
5
-
6
- export const MAX_FILENAME_ATTEMPTS = 100;
7
-
8
- function commandError(action, error) {
9
- return new CommandExecutionError(`Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`);
10
- }
11
-
12
- function assertInside(root, target) {
13
- const relative = path.relative(root, target);
14
- if (relative === '' || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
15
- throw new CommandExecutionError('Refusing to save an article outside the output directory');
16
- }
17
- }
18
-
19
- function sameIdentity(left, right) {
20
- return left.dev === right.dev && left.ino === right.ino;
21
- }
22
-
23
- function assertResolvedPathComponents(root, fsImpl) {
24
- const parsed = path.parse(root);
25
- let current = parsed.root;
26
- for (const part of root.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
27
- current = path.join(current, part);
28
- const stat = fsImpl.lstatSync(current);
29
- if (stat.isSymbolicLink?.()) throw new CommandExecutionError('Refusing to save through a symbolic link');
30
- }
31
- }
32
-
33
- function assertRootIdentity(root, rootFd, rootIdentity, fsImpl) {
34
- assertResolvedPathComponents(root, fsImpl);
35
- const pathStat = fsImpl.lstatSync(root);
36
- const fdStat = fsImpl.fstatSync(rootFd);
37
- if (!pathStat.isDirectory?.() || !fdStat.isDirectory?.()
38
- || !sameIdentity(pathStat, rootIdentity) || !sameIdentity(fdStat, rootIdentity)) {
39
- throw new CommandExecutionError('Output directory identity changed during save');
40
- }
41
- }
42
-
43
- function cleanupOpenedTarget(target, openedStat, fsImpl) {
44
- try {
45
- const current = fsImpl.lstatSync(target);
46
- if (sameIdentity(current, openedStat) && !current.isSymbolicLink?.()) fsImpl.unlinkSync(target);
47
- } catch {
48
- // Fail closed; cleanup is best effort after identity mismatch.
49
- }
50
- }
51
-
52
- function writeExclusive(root, rootFd, rootIdentity, target, markdown, fsImpl) {
53
- const noFollow = defaultFs.constants.O_NOFOLLOW;
54
- if (typeof noFollow !== 'number') {
55
- throw new CommandExecutionError('Secure article saving is unavailable: O_NOFOLLOW is unsupported');
56
- }
57
- // The opened root fd plus its dev/ino is the authorization capability.
58
- // Path checks detect namespace replacement, but cannot and need not prevent
59
- // a same-privilege process from renaming that already-authorized inode.
60
- assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
61
- let fd;
62
- let openedStat;
63
- try {
64
- fd = fsImpl.openSync(target,
65
- defaultFs.constants.O_CREAT | defaultFs.constants.O_EXCL | defaultFs.constants.O_WRONLY | noFollow,
66
- 0o600);
67
- // Once open succeeds, this fd remains bound to that inode; later renames
68
- // or symlink swaps cannot redirect its writes into a replacement root.
69
- openedStat = fsImpl.fstatSync(fd);
70
- if (!openedStat.isFile?.() || openedStat.isSymbolicLink?.()) {
71
- throw new CommandExecutionError('Refusing to write a non-regular article target');
72
- }
73
- assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
74
- const body = Buffer.from(markdown, 'utf8');
75
- let offset = 0;
76
- while (offset < body.length) {
77
- const written = fsImpl.writeSync(fd, body, offset, body.length - offset);
78
- if (!Number.isInteger(written) || written <= 0) throw new CommandExecutionError('Failed to write article bytes');
79
- offset += written;
80
- }
81
- fsImpl.fsyncSync?.(fd);
82
- assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
83
- } catch (error) {
84
- if (openedStat) cleanupOpenedTarget(target, openedStat, fsImpl);
85
- throw error;
86
- } finally {
87
- if (fd !== undefined) fsImpl.closeSync(fd);
88
- }
89
- }
90
-
91
- export async function saveArticles({ articles, accountName, outputDir, fetchArticleHtml, buildMarkdown = wechatArticleToMarkdown, fsImpl = defaultFs }) {
92
- if (!Array.isArray(articles) || articles.length > 1000) {
93
- throw new ArgumentError('articles must be an array of at most 1000 items');
94
- }
95
- const requestedRoot = path.resolve(outputDir);
96
- try { fsImpl.mkdirSync(requestedRoot, { recursive: true }); } catch (error) { throw commandError('create output directory', error); }
97
- let root;
98
- try { root = fsImpl.realpathSync(requestedRoot); } catch (error) { throw commandError('resolve output directory', error); }
99
- let rootFd;
100
- let rootIdentity;
101
- try {
102
- assertResolvedPathComponents(root, fsImpl);
103
- rootIdentity = fsImpl.lstatSync(root);
104
- if (!rootIdentity.isDirectory?.() || rootIdentity.isSymbolicLink?.()) throw new Error('not a directory');
105
- rootFd = fsImpl.openSync(root, defaultFs.constants.O_RDONLY);
106
- const openedRoot = fsImpl.fstatSync(rootFd);
107
- if (!openedRoot.isDirectory?.() || !sameIdentity(openedRoot, rootIdentity)) {
108
- throw new CommandExecutionError('Output directory identity changed during secure open');
109
- }
110
- assertRootIdentity(root, rootFd, rootIdentity, fsImpl);
111
- } catch (error) {
112
- if (rootFd !== undefined) fsImpl.closeSync(rootFd);
113
- if (error instanceof CommandExecutionError) throw error;
114
- throw commandError('secure output directory', error);
115
- }
116
- const reserved = new Set();
117
- const rows = [];
118
-
119
- try {
120
- for (const article of articles) {
121
- let articleHtml;
122
- try {
123
- articleHtml = await fetchArticleHtml(article);
124
- } catch (error) {
125
- if (error instanceof AuthRequiredError) throw error;
126
- rows.push({ title: article.title || '', url: article.url || '', status: 'failed', stage: 'download', saved: '', error: 'article download failed' });
127
- continue;
128
- }
129
- let markdown;
130
- try {
131
- markdown = buildMarkdown({ html: articleHtml, title: article.title, accountName,
132
- author: article.author, publishedAt: article.publishedAt, digest: article.digest, url: article.url });
133
- } catch {
134
- rows.push({ title: article.title || '', url: article.url || '', status: 'failed', stage: 'download', saved: '', error: 'invalid article content' });
135
- continue;
136
- }
137
-
138
- let suffix = 1;
139
- let target;
140
- while (suffix <= MAX_FILENAME_ATTEMPTS) {
141
- const suffixText = suffix === 1 ? '' : `-${suffix}`;
142
- const name = `${cleanMarkdownFilename(article.title, 100, suffixText)}${suffixText}`;
143
- target = path.resolve(root, `${name}.md`);
144
- assertInside(root, target);
145
- if (reserved.has(target)) { suffix += 1; continue; }
146
- try {
147
- const stat = fsImpl.lstatSync(target);
148
- if (stat.isSymbolicLink?.()) throw new CommandExecutionError('Refusing to overwrite a symbolic link');
149
- suffix += 1;
150
- continue;
151
- } catch (error) {
152
- if (error instanceof CommandExecutionError) throw error;
153
- if (error?.code !== 'ENOENT') throw commandError('inspect article target', error);
154
- }
155
- try {
156
- writeExclusive(root, rootFd, rootIdentity, target, markdown, fsImpl);
157
- reserved.add(target);
158
- break;
159
- } catch (error) {
160
- if (error?.code === 'EEXIST') {
161
- suffix += 1;
162
- continue;
163
- }
164
- throw commandError('write article Markdown', error);
165
- }
166
- }
167
- if (suffix > MAX_FILENAME_ATTEMPTS) {
168
- throw new CommandExecutionError(`Failed to reserve an article filename after ${MAX_FILENAME_ATTEMPTS} attempts`);
169
- }
170
- rows.push({ title: article.title || '', url: article.url || '', status: 'saved', stage: null, saved: target, error: '' });
171
- }
172
- } finally {
173
- fsImpl.closeSync(rootFd);
174
- }
175
- return rows;
176
- }
@@ -1,133 +0,0 @@
1
- import { AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
2
- import { buildSecretSet, redactText } from './redact.js';
3
-
4
- const DOMAIN = 'mp.weixin.qq.com';
5
- const ENDPOINT = `https://${DOMAIN}/cgi-bin/appmsgpublish`;
6
-
7
- function normalizedMessage(value) {
8
- return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
9
- }
10
-
11
- function commandError(message) {
12
- return new CommandExecutionError(redactText(message, []));
13
- }
14
-
15
- function parseNestedJson(value, label) {
16
- if (typeof value !== 'string') return value;
17
- try {
18
- return JSON.parse(value);
19
- } catch (error) {
20
- const detail = error instanceof Error ? error.message : String(error);
21
- throw commandError(`WeChat ${label} is malformed: ${detail}`);
22
- }
23
- }
24
-
25
- /** @param {unknown} data */
26
- export function parsePublishData(data) {
27
- if (!data || typeof data !== 'object') {
28
- throw new CommandExecutionError('WeChat article history returned an unreadable response');
29
- }
30
- const response = /** @type {Record<string, any>} */ (data);
31
- const ret = response.base_resp?.ret;
32
- const message = response.base_resp?.err_msg ?? response.base_resp?.err_msg_en ?? '';
33
- if (ret === 200013 && normalizedMessage(message) === 'invalid credential') {
34
- throw new AuthRequiredError(DOMAIN, 'WeChat article-history credentials have expired');
35
- }
36
- if (ret !== undefined && ret !== 0) {
37
- throw new CommandExecutionError(`WeChat article history failed (ret=${String(ret)})`);
38
- }
39
- if (response.publish_page === undefined || response.publish_page === null || response.publish_page === '') {
40
- return { total: 0, publishItemCount: 0, articles: [] };
41
- }
42
- const page = parseNestedJson(response.publish_page, 'publish_page');
43
- if (!page || typeof page !== 'object' || !Array.isArray(page.publish_list)) {
44
- throw new CommandExecutionError('WeChat article history returned an invalid publish page');
45
- }
46
- const total = page.total_count === undefined ? 0 : page.total_count;
47
- if (!Number.isSafeInteger(total) || total < 0) {
48
- throw new CommandExecutionError('WeChat article history returned invalid total metadata');
49
- }
50
- const articles = [];
51
- for (const item of page.publish_list) {
52
- const info = parseNestedJson(item?.publish_info ?? {}, 'publish_info');
53
- if (!info || typeof info !== 'object' || !Array.isArray(info.appmsg_info)) {
54
- throw new CommandExecutionError('WeChat article history returned invalid publish information');
55
- }
56
- const timestamp = info.sent_info?.time ?? info.publish_info?.create_time ?? 0;
57
- let publishedAt = null;
58
- if (timestamp !== 0) {
59
- if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) {
60
- throw new CommandExecutionError('WeChat article history returned an invalid publish timestamp');
61
- }
62
- const date = new Date(timestamp * 1000);
63
- if (!Number.isFinite(date.getTime())) {
64
- throw new CommandExecutionError('WeChat article history returned an invalid publish timestamp');
65
- }
66
- publishedAt = date.toISOString();
67
- }
68
- for (const messageItem of info.appmsg_info) {
69
- const article = messageItem && typeof messageItem === 'object' ? messageItem : {};
70
- articles.push({
71
- title: typeof article.title === 'string' ? article.title : '',
72
- url: typeof article.content_url === 'string' ? article.content_url : '',
73
- isDeleted: article.is_deleted === true,
74
- timestamp,
75
- publishedAt,
76
- digest: typeof article.digest === 'string' ? article.digest : '',
77
- author: typeof article.author === 'string' ? article.author : '',
78
- });
79
- }
80
- }
81
- return {
82
- total,
83
- publishItemCount: page.publish_list.length,
84
- articles,
85
- };
86
- }
87
-
88
- export function requestHeaders(cookie, token) {
89
- return {
90
- Accept: 'application/json, text/javascript, */*; q=0.01',
91
- Cookie: cookie,
92
- Origin: `https://${DOMAIN}`,
93
- Referer: `https://${DOMAIN}/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=10&token=${encodeURIComponent(token)}&lang=zh_CN`,
94
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/143 Safari/537.36',
95
- 'X-Requested-With': 'XMLHttpRequest',
96
- };
97
- }
98
-
99
- /**
100
- * @param {{token:string,cookie:string,timeoutMs?:number,fetchImpl?:typeof fetch}} options
101
- */
102
- export function createWechatApi({ token, cookie, timeoutMs = 30_000, fetchImpl = fetch }) {
103
- const headers = requestHeaders(cookie, token);
104
- const secrets = buildSecretSet({ token, cookie });
105
-
106
- return {
107
- async fetchPage({ fakeid, begin = 0, count = 10 }) {
108
- const query = new URLSearchParams({
109
- sub: 'list', begin: String(begin), count: String(count), fakeid, token,
110
- lang: 'zh_CN', f: 'json', ajax: '1',
111
- });
112
- try {
113
- const response = await fetchImpl(`${ENDPOINT}?${query}`, {
114
- headers,
115
- signal: AbortSignal.timeout(timeoutMs),
116
- });
117
- if (!response.ok) {
118
- throw new CommandExecutionError(`WeChat article history request failed: HTTP ${response.status} ${response.statusText ?? ''}`.trim());
119
- }
120
- return parsePublishData(await response.json());
121
- } catch (error) {
122
- if (error instanceof AuthRequiredError && error.domain === DOMAIN) throw error;
123
- const message = error instanceof Error ? error.message : String(error);
124
- const hint = error && typeof error === 'object' && 'hint' in error && typeof error.hint === 'string'
125
- ? error.hint : undefined;
126
- throw new CommandExecutionError(
127
- `WeChat article history request failed: ${redactText(message, secrets)}`,
128
- hint ? redactText(hint, secrets) : undefined,
129
- );
130
- }
131
- },
132
- };
133
- }