@jackwener/opencli 1.7.17 → 1.7.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +585 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/doubao/utils.js +17 -0
  9. package/clis/doubao/utils.test.js +61 -0
  10. package/clis/google/search.js +16 -6
  11. package/clis/google-scholar/search.js +20 -5
  12. package/clis/google-scholar/search.test.js +35 -2
  13. package/clis/reddit/home.js +117 -0
  14. package/clis/reddit/home.test.js +127 -0
  15. package/clis/reddit/read.js +400 -54
  16. package/clis/reddit/read.test.js +315 -12
  17. package/clis/reddit/reply.js +182 -0
  18. package/clis/reddit/reply.test.js +89 -0
  19. package/clis/reddit/subreddit-info.js +117 -0
  20. package/clis/reddit/subreddit-info.test.js +163 -0
  21. package/clis/reddit/whoami.js +84 -0
  22. package/clis/reddit/whoami.test.js +105 -0
  23. package/clis/rednote/comments.js +76 -0
  24. package/clis/rednote/download.js +59 -0
  25. package/clis/rednote/feed.js +95 -0
  26. package/clis/rednote/navigation.test.js +26 -0
  27. package/clis/rednote/note.js +68 -0
  28. package/clis/rednote/notifications.js +139 -0
  29. package/clis/rednote/rednote.test.js +157 -0
  30. package/clis/rednote/search.js +101 -0
  31. package/clis/rednote/user.js +55 -0
  32. package/clis/twitter/bookmark-folder.js +3 -1
  33. package/clis/twitter/bookmarks.js +3 -1
  34. package/clis/twitter/followers.js +20 -5
  35. package/clis/twitter/followers.test.js +44 -0
  36. package/clis/twitter/following.js +36 -20
  37. package/clis/twitter/following.test.js +60 -8
  38. package/clis/twitter/likes.js +28 -13
  39. package/clis/twitter/likes.test.js +111 -1
  40. package/clis/twitter/list-add.js +128 -204
  41. package/clis/twitter/list-add.test.js +97 -1
  42. package/clis/twitter/list-tweets.js +13 -4
  43. package/clis/twitter/list-tweets.test.js +48 -0
  44. package/clis/twitter/lists.js +5 -2
  45. package/clis/twitter/post.js +23 -4
  46. package/clis/twitter/post.test.js +30 -0
  47. package/clis/twitter/profile.js +16 -8
  48. package/clis/twitter/profile.test.js +39 -0
  49. package/clis/twitter/reply.js +133 -10
  50. package/clis/twitter/reply.test.js +55 -0
  51. package/clis/twitter/search.js +188 -170
  52. package/clis/twitter/search.test.js +96 -258
  53. package/clis/twitter/shared.js +167 -16
  54. package/clis/twitter/shared.test.js +102 -1
  55. package/clis/twitter/timeline.js +3 -1
  56. package/clis/twitter/tweets.js +147 -51
  57. package/clis/twitter/tweets.test.js +238 -1
  58. package/clis/xiaohongshu/comments.js +57 -26
  59. package/clis/xiaohongshu/comments.test.js +63 -1
  60. package/clis/xiaohongshu/download.js +32 -23
  61. package/clis/xiaohongshu/feed.js +23 -15
  62. package/clis/xiaohongshu/note-helpers.js +16 -6
  63. package/clis/xiaohongshu/note.js +26 -20
  64. package/clis/xiaohongshu/notifications.js +26 -19
  65. package/clis/xiaohongshu/search.js +201 -37
  66. package/clis/xiaohongshu/search.test.js +82 -8
  67. package/clis/xiaohongshu/user-helpers.js +13 -4
  68. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  69. package/clis/xiaohongshu/user.js +9 -4
  70. package/clis/xueqiu/earnings-date.js +2 -2
  71. package/clis/xueqiu/kline.js +2 -2
  72. package/clis/xueqiu/utils.js +19 -0
  73. package/clis/xueqiu/utils.test.js +26 -0
  74. package/clis/youtube/transcript.js +28 -3
  75. package/clis/youtube/transcript.test.js +90 -1
  76. package/clis/zhihu/answer-detail.js +233 -0
  77. package/clis/zhihu/answer-detail.test.js +330 -0
  78. package/clis/zhihu/question.js +44 -10
  79. package/clis/zhihu/question.test.js +78 -1
  80. package/clis/zhihu/recommend.js +103 -0
  81. package/clis/zhihu/recommend.test.js +143 -0
  82. package/dist/src/browser/base-page.d.ts +3 -2
  83. package/dist/src/browser/base-page.test.js +2 -2
  84. package/dist/src/browser/cdp.js +3 -3
  85. package/dist/src/browser/page.d.ts +3 -2
  86. package/dist/src/browser/page.js +4 -4
  87. package/dist/src/browser/page.test.js +31 -0
  88. package/dist/src/browser/utils.d.ts +10 -0
  89. package/dist/src/browser/utils.js +37 -0
  90. package/dist/src/browser/utils.test.d.ts +1 -0
  91. package/dist/src/browser/utils.test.js +29 -0
  92. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  93. package/dist/src/cli-argv-preprocess.js +131 -0
  94. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  95. package/dist/src/cli-argv-preprocess.test.js +130 -0
  96. package/dist/src/cli.js +123 -86
  97. package/dist/src/cli.test.js +32 -22
  98. package/dist/src/commands/daemon.js +6 -7
  99. package/dist/src/doctor.js +21 -17
  100. package/dist/src/doctor.test.js +2 -0
  101. package/dist/src/download/progress.js +15 -11
  102. package/dist/src/download/progress.test.d.ts +1 -0
  103. package/dist/src/download/progress.test.js +25 -0
  104. package/dist/src/execution.js +1 -3
  105. package/dist/src/execution.test.js +4 -16
  106. package/dist/src/help.d.ts +11 -0
  107. package/dist/src/help.js +46 -5
  108. package/dist/src/logger.js +8 -9
  109. package/dist/src/main.js +16 -0
  110. package/dist/src/output.js +4 -5
  111. package/dist/src/runtime-detect.d.ts +1 -1
  112. package/dist/src/runtime-detect.js +1 -1
  113. package/dist/src/runtime-detect.test.js +3 -2
  114. package/dist/src/tui.d.ts +0 -1
  115. package/dist/src/tui.js +9 -22
  116. package/dist/src/types.d.ts +3 -1
  117. package/dist/src/update-check.js +4 -5
  118. package/package.json +5 -4
@@ -1,7 +1,10 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { extractXhsUserNotes, normalizeXhsUserId } from './user-helpers.js';
3
- async function readUserSnapshot(page) {
4
- return await page.evaluate(`
3
+ /**
4
+ * Host-agnostic IIFE that snapshots the user profile's Pinia store. Exported
5
+ * so the rednote adapter can reuse it without copying the safeClone block.
6
+ */
7
+ export const USER_SNAPSHOT_JS = `
5
8
  (() => {
6
9
  const safeClone = (value) => {
7
10
  try {
@@ -17,9 +20,11 @@ async function readUserSnapshot(page) {
17
20
  pageData: safeClone(userStore.userPageData?._value || userStore.userPageData || {}),
18
21
  };
19
22
  })()
20
- `);
23
+ `;
24
+ async function readUserSnapshot(page) {
25
+ return await page.evaluate(USER_SNAPSHOT_JS);
21
26
  }
22
- cli({
27
+ export const command = cli({
23
28
  site: 'xiaohongshu',
24
29
  name: 'user',
25
30
  access: 'read',
@@ -1,6 +1,6 @@
1
1
  import { cli } from '@jackwener/opencli/registry';
2
2
  import { EmptyResultError } from '@jackwener/opencli/errors';
3
- import { fetchXueqiuJson } from './utils.js';
3
+ import { fetchXueqiuJson, formatChinaDate } from './utils.js';
4
4
  cli({
5
5
  site: 'xueqiu',
6
6
  name: 'earnings-date',
@@ -32,7 +32,7 @@ cli({
32
32
  .filter((item) => item.subtype === 2)
33
33
  .map((item) => {
34
34
  const ts = item.timestamp;
35
- const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;
35
+ const dateStr = ts ? formatChinaDate(ts) : null;
36
36
  const isFuture = ts && ts > now;
37
37
  return { date: dateStr, report: item.message, status: isFuture ? '⏳ 未发布' : '✅ 已发布', _ts: ts, _future: isFuture };
38
38
  });
@@ -1,6 +1,6 @@
1
1
  import { cli } from '@jackwener/opencli/registry';
2
2
  import { EmptyResultError } from '@jackwener/opencli/errors';
3
- import { fetchXueqiuJson } from './utils.js';
3
+ import { fetchXueqiuJson, formatChinaDate } from './utils.js';
4
4
  cli({
5
5
  site: 'xueqiu',
6
6
  name: 'kline',
@@ -31,7 +31,7 @@ cli({
31
31
  const colIdx = {};
32
32
  columns.forEach((name, i) => { colIdx[name] = i; });
33
33
  return d.data.item.map(row => ({
34
- date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,
34
+ date: colIdx.timestamp != null ? formatChinaDate(row[colIdx.timestamp]) : null,
35
35
  open: row[colIdx.open] ?? null,
36
36
  high: row[colIdx.high] ?? null,
37
37
  low: row[colIdx.low] ?? null,
@@ -1,4 +1,23 @@
1
1
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+
3
+ const CHINA_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
4
+ timeZone: 'Asia/Shanghai',
5
+ year: 'numeric',
6
+ month: '2-digit',
7
+ day: '2-digit',
8
+ });
9
+
10
+ /** Format a Unix ms timestamp as the matching `YYYY-MM-DD` in Asia/Shanghai (xueqiu's canonical user timezone for all markets). */
11
+ export function formatChinaDate(ts) {
12
+ if (ts == null) return null;
13
+ const parts = Object.fromEntries(
14
+ CHINA_DATE_FORMATTER.formatToParts(new Date(ts))
15
+ .filter((part) => part.type !== 'literal')
16
+ .map((part) => [part.type, part.value]),
17
+ );
18
+ return `${parts.year}-${parts.month}-${parts.day}`;
19
+ }
20
+
2
21
  /**
3
22
  * Fetch a xueqiu JSON API from inside the browser context (credentials included).
4
23
  * Page must already be navigated to xueqiu.com before calling this function.
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatChinaDate } from './utils.js';
3
+
4
+ describe('formatChinaDate', () => {
5
+ it('returns the Asia/Shanghai date for a UTC ms at China midnight', () => {
6
+ expect(formatChinaDate(Date.UTC(2026, 4, 7, 16, 0, 0))).toBe('2026-05-08');
7
+ });
8
+ it('returns the same China date for a moment late in the day', () => {
9
+ expect(formatChinaDate(Date.UTC(2026, 4, 8, 14, 0, 0))).toBe('2026-05-08');
10
+ });
11
+ it('formats representative A-share and US-market bars on xueqiu Beijing dates', () => {
12
+ expect(formatChinaDate(Date.UTC(2026, 4, 7, 16, 0, 0))).toBe('2026-05-08');
13
+ expect(formatChinaDate(Date.UTC(2026, 4, 10, 16, 0, 0))).toBe('2026-05-11');
14
+ });
15
+ it('crosses the China day boundary at 16:00 UTC', () => {
16
+ expect(formatChinaDate(Date.UTC(2026, 0, 1, 15, 59, 59))).toBe('2026-01-01');
17
+ expect(formatChinaDate(Date.UTC(2026, 0, 1, 16, 0, 0))).toBe('2026-01-02');
18
+ });
19
+ it('always returns an ISO calendar date string, not a locale-shaped slash date', () => {
20
+ expect(formatChinaDate(Date.UTC(2026, 0, 1, 16, 0, 0))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
21
+ });
22
+ it('returns null for nullish input', () => {
23
+ expect(formatChinaDate(null)).toBeNull();
24
+ expect(formatChinaDate(undefined)).toBeNull();
25
+ });
26
+ });
@@ -86,12 +86,37 @@ cli({
86
86
  console.error(`Warning: --lang "${captionData.requestedLang}" not found. Using "${captionData.language}" instead. Available: ${captionData.available.join(', ')}`);
87
87
  }
88
88
  // Step 2: Fetch caption XML and parse segments
89
+ // Ensure caption URL requests srv3 XML format — YouTube may return empty
90
+ // responses when no explicit format is specified.
91
+ const originalCaptionUrl = captionData.captionUrl;
92
+ let captionUrl = originalCaptionUrl;
93
+ if (!/[&?]fmt=/.test(originalCaptionUrl)) {
94
+ captionUrl = originalCaptionUrl + (originalCaptionUrl.includes('?') ? '&' : '?') + 'fmt=srv3';
95
+ }
89
96
  const segments = await page.evaluate(`
90
97
  (async () => {
91
- const resp = await fetch(${JSON.stringify(captionData.captionUrl)});
92
- const xml = await resp.text();
98
+ async function fetchCaptionXml(url) {
99
+ const resp = await fetch(url);
100
+ if (!resp.ok) return { error: 'Caption URL returned HTTP ' + resp.status };
101
+ return { xml: await resp.text() || '' };
102
+ }
103
+
104
+ const primaryUrl = ${JSON.stringify(captionUrl)};
105
+ const originalUrl = ${JSON.stringify(originalCaptionUrl)};
106
+ let result = await fetchCaptionXml(primaryUrl);
107
+ if (result.error) return result;
108
+
109
+ // If srv3 format returned an empty successful body, retry with the
110
+ // original URL. Do not hide HTTP/non-OK failures behind fallback.
111
+ if (!result.xml.length && originalUrl !== primaryUrl) {
112
+ result = await fetchCaptionXml(originalUrl);
113
+ if (result.error) {
114
+ return result;
115
+ }
116
+ }
117
+ const xml = result.xml;
93
118
 
94
- if (!xml?.length) {
119
+ if (!xml.length) {
95
120
  return { error: 'Caption URL returned empty response' };
96
121
  }
97
122
 
@@ -1,11 +1,37 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { getRegistry } from '@jackwener/opencli/registry';
6
+ import './transcript.js';
5
7
 
6
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
9
  const transcriptSource = readFileSync(resolve(__dirname, 'transcript.js'), 'utf8');
8
10
 
11
+ function createPageMock(captionUrl) {
12
+ const page = {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate: vi.fn(),
16
+ };
17
+ page.evaluate
18
+ .mockResolvedValueOnce({
19
+ captionUrl,
20
+ language: 'en',
21
+ kind: 'manual',
22
+ available: ['en'],
23
+ requestedLang: null,
24
+ langMatched: false,
25
+ langPrefixMatched: false,
26
+ })
27
+ .mockResolvedValue([{ start: 1, end: 3, text: 'hello & world' }]);
28
+ return page;
29
+ }
30
+
31
+ afterEach(() => {
32
+ vi.unstubAllGlobals();
33
+ });
34
+
9
35
  describe('youtube transcript source contract', () => {
10
36
  it('gets caption tracks from watch page bootstrap data, not Android InnerTube', () => {
11
37
  expect(transcriptSource).toContain("fetch('/watch?v='");
@@ -14,4 +40,67 @@ describe('youtube transcript source contract', () => {
14
40
  expect(transcriptSource).not.toContain('/youtubei/v1/player');
15
41
  expect(transcriptSource).not.toContain("clientName: 'ANDROID'");
16
42
  });
43
+
44
+ it('normalizes caption URL to request srv3 XML format', () => {
45
+ expect(transcriptSource).toContain('fmt=srv3');
46
+ });
47
+
48
+ it('checks HTTP status before reading caption response body', () => {
49
+ expect(transcriptSource).toContain('resp.ok');
50
+ });
51
+ });
52
+
53
+ describe('youtube transcript caption fetch', () => {
54
+ const command = getRegistry().get('youtube/transcript');
55
+
56
+ it('requests srv3 when the caption track URL has no explicit format', async () => {
57
+ const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en');
58
+
59
+ const rows = await command.func(page, { url: 'abc', mode: 'raw' });
60
+
61
+ expect(page.evaluate.mock.calls[1][0]).toContain('const primaryUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=srv3"');
62
+ expect(page.evaluate.mock.calls[1][0]).toContain('const originalUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en"');
63
+ expect(rows).toEqual([{ index: 1, start: '1.00s', end: '3.00s', text: 'hello & world' }]);
64
+ });
65
+
66
+ it('does not override an existing caption format', async () => {
67
+ const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=vtt');
68
+
69
+ await command.func(page, { url: 'abc', mode: 'raw' });
70
+
71
+ expect(page.evaluate.mock.calls[1][0]).toContain('const primaryUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=vtt"');
72
+ expect(page.evaluate.mock.calls[1][0]).toContain('const originalUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=vtt"');
73
+ });
74
+
75
+ it('falls back to the original URL only after an empty successful srv3 response', async () => {
76
+ const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en');
77
+
78
+ await command.func(page, { url: 'abc', mode: 'raw' });
79
+
80
+ const script = page.evaluate.mock.calls[1][0];
81
+ expect(script).toContain('if (!result.xml.length && originalUrl !== primaryUrl)');
82
+ expect(script).toContain('result = await fetchCaptionXml(originalUrl)');
83
+ expect(script).toContain('if (result.error) {');
84
+ });
85
+
86
+ it('fails typed on caption HTTP errors instead of falling back silently', async () => {
87
+ const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en');
88
+ page.evaluate.mockReset();
89
+ page.evaluate
90
+ .mockResolvedValueOnce({
91
+ captionUrl: 'https://www.youtube.com/api/timedtext?v=abc&lang=en',
92
+ language: 'en',
93
+ kind: 'manual',
94
+ available: ['en'],
95
+ requestedLang: null,
96
+ langMatched: false,
97
+ langPrefixMatched: false,
98
+ })
99
+ .mockResolvedValueOnce({ error: 'Caption URL returned HTTP 503' });
100
+
101
+ await expect(command.func(page, { url: 'abc', mode: 'raw' })).rejects.toMatchObject({
102
+ code: 'COMMAND_EXEC',
103
+ message: expect.stringContaining('HTTP 503'),
104
+ });
105
+ });
17
106
  });
@@ -0,0 +1,233 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ // Light-weight HTML → text, preserving paragraph / heading / list-item
5
+ // line breaks. Zhihu answer `content` is HTML, so we map block-level
6
+ // closing tags + `<br>` to newlines before stripping the rest.
7
+ function stripHtml(html) {
8
+ if (!html) return '';
9
+ return html
10
+ .replace(/<br\s*\/?\s*>/gi, '\n')
11
+ // Block-level closing tags become paragraph breaks (double
12
+ // newline) so the stripped text stays readable. The trailing
13
+ // `\n{3,}` collapse pass below normalizes accidental triples.
14
+ .replace(/<\/(?:p|div|h[1-6]|li|blockquote)>/gi, '\n\n')
15
+ .replace(/<[^>]+>/g, '')
16
+ .replace(/&nbsp;/g, ' ')
17
+ .replace(/&lt;/g, '<')
18
+ .replace(/&gt;/g, '>')
19
+ .replace(/&amp;/g, '&')
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&#39;/g, "'")
22
+ .replace(/\n{3,}/g, '\n\n')
23
+ .trim();
24
+ }
25
+
26
+ const ANSWER_ID_RE = /^\d+$/;
27
+ const ANSWER_TYPED_RE = /^answer:(\d+):(\d+)$/;
28
+ const ANSWER_PATH_RE = /^\/question\/(\d+)\/answer\/(\d+)\/?$/;
29
+ const BARE_ANSWER_PATH_RE = /^\/answer\/(\d+)\/?$/;
30
+ const QUESTION_PATH_RE = /^\/question\/(\d+)\/?$/;
31
+ const QUESTION_API_PATH_RE = /^\/api\/v4\/questions\/(\d+)\/?$/;
32
+
33
+ // Accepts: bare numeric id (`1937205528846655537`), the typed
34
+ // target form used by the existing zhihu write adapters
35
+ // (`answer:<qid>:<aid>`), or the full Zhihu URL pasted from a
36
+ // browser (`https://www.zhihu.com/question/<qid>/answer/<aid>`).
37
+ // Returns string-safe ids, or null when the input does not resolve to
38
+ // any of those exact shapes.
39
+ function parseAnswerTarget(input) {
40
+ const value = String(input ?? '').trim();
41
+ if (!value) return null;
42
+ if (ANSWER_ID_RE.test(value)) return { answerId: value, questionId: '' };
43
+ const typed = value.match(ANSWER_TYPED_RE);
44
+ if (typed) return { questionId: typed[1], answerId: typed[2] };
45
+ try {
46
+ const url = new URL(value);
47
+ if (
48
+ url.protocol !== 'https:' ||
49
+ url.username ||
50
+ url.password ||
51
+ url.port ||
52
+ (url.hostname !== 'www.zhihu.com' && url.hostname !== 'zhihu.com')
53
+ ) {
54
+ return null;
55
+ }
56
+ let m = url.pathname.match(ANSWER_PATH_RE);
57
+ if (m) return { questionId: m[1], answerId: m[2] };
58
+ m = url.pathname.match(BARE_ANSWER_PATH_RE);
59
+ if (m) return { answerId: m[1], questionId: '' };
60
+ } catch {
61
+ return null;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function extractAnswerId(input) {
67
+ return parseAnswerTarget(input)?.answerId ?? null;
68
+ }
69
+
70
+ function extractQuestionIdFromAnswerUrl(input) {
71
+ const value = String(input ?? '').trim();
72
+ if (!value) return '';
73
+ try {
74
+ const url = new URL(value);
75
+ if (url.protocol !== 'https:' || (url.hostname !== 'www.zhihu.com' && url.hostname !== 'zhihu.com')) {
76
+ return '';
77
+ }
78
+ return url.pathname.match(ANSWER_PATH_RE)?.[1]
79
+ || url.pathname.match(QUESTION_PATH_RE)?.[1]
80
+ || url.pathname.match(QUESTION_API_PATH_RE)?.[1]
81
+ || '';
82
+ } catch {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ function normalizeCount(value) {
88
+ return Number.isInteger(value) && value >= 0 ? value : 0;
89
+ }
90
+
91
+ function normalizeUnixSeconds(value) {
92
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
93
+ ? new Date(value * 1000).toISOString()
94
+ : '';
95
+ }
96
+
97
+ cli({
98
+ site: 'zhihu',
99
+ name: 'answer-detail',
100
+ access: 'read',
101
+ description: '知乎单个回答完整内容(按 answer ID 获取)',
102
+ domain: 'www.zhihu.com',
103
+ strategy: Strategy.COOKIE,
104
+ args: [
105
+ { name: 'id', required: true, positional: true, help: 'Answer ID, full Zhihu answer URL, or typed target (answer:<qid>:<aid>)' },
106
+ { name: 'max-content', type: 'int', default: 0, help: 'Optional cap on stripped content length in characters (0 = no truncation, return the full answer)' },
107
+ ],
108
+ columns: ['id', 'author', 'votes', 'comments', 'question_id', 'question_title', 'url', 'created_at', 'updated_at', 'content'],
109
+ func: async (page, kwargs) => {
110
+ const target = parseAnswerTarget(kwargs.id);
111
+ if (!target) {
112
+ throw new ArgumentError(
113
+ 'Answer ID must be a numeric id, a Zhihu answer URL, or answer:<qid>:<aid>',
114
+ 'Example: opencli zhihu answer-detail 1937205528846655537',
115
+ );
116
+ }
117
+ const { answerId } = target;
118
+ // `--max-content 0` (the default) means "no cap, return the
119
+ // full stripped answer". Any positive value is an opt-in user
120
+ // cap, mirroring the wikipedia `page` pattern — we never
121
+ // silently truncate behind the user's back.
122
+ const rawMaxContent = kwargs['max-content'];
123
+ const maxContent = rawMaxContent == null ? 0 : Number(rawMaxContent);
124
+ if (!Number.isInteger(maxContent) || maxContent < 0) {
125
+ throw new ArgumentError(
126
+ '--max-content must be a non-negative integer (0 = no cap, full content)',
127
+ 'Example: --max-content 2000',
128
+ );
129
+ }
130
+ // Navigate to the answer page itself: this both seeds the
131
+ // cookie/anti-bot context and works even when the caller did
132
+ // not supply the parent question id (Zhihu redirects from
133
+ // `/answer/<aid>` to the canonical `/question/<qid>/answer/<aid>`).
134
+ try {
135
+ await page.goto(`https://www.zhihu.com/answer/${answerId}`);
136
+ } catch (err) {
137
+ throw new CommandExecutionError(
138
+ `Failed to open Zhihu answer ${answerId}: ${err instanceof Error ? err.message : String(err)}`,
139
+ 'Open the answer URL in Chrome and retry after the page is reachable.',
140
+ );
141
+ }
142
+ const currentQuestionId = page.getCurrentUrl
143
+ ? extractQuestionIdFromAnswerUrl(await page.getCurrentUrl().catch(() => ''))
144
+ : '';
145
+ const apiUrl = `https://www.zhihu.com/api/v4/answers/${answerId}?include=content,voteup_count,comment_count,author,created_time,updated_time,question`;
146
+ const data = await page.evaluate(`
147
+ (async () => {
148
+ const r = await fetch(${JSON.stringify(apiUrl)}, { credentials: 'include' });
149
+ if (!r.ok) return { __httpError: r.status };
150
+ try {
151
+ return await r.json();
152
+ } catch (error) {
153
+ return { __malformedJson: error instanceof Error ? error.message : String(error) };
154
+ }
155
+ })()
156
+ `).catch((err) => {
157
+ throw new CommandExecutionError(
158
+ `Zhihu answer detail request failed: ${err instanceof Error ? err.message : String(err)}`,
159
+ 'Try again later or rerun with -v for more detail.',
160
+ );
161
+ });
162
+ if (!data || data.__httpError) {
163
+ const status = data?.__httpError;
164
+ if (status === 401 || status === 403) {
165
+ throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch Zhihu answer detail');
166
+ }
167
+ if (status === 404) {
168
+ throw new EmptyResultError('zhihu answer-detail', `No Zhihu answer was found for ${answerId}.`);
169
+ }
170
+ throw new CommandExecutionError(
171
+ status
172
+ ? `Zhihu answer detail request failed (HTTP ${status})`
173
+ : 'Zhihu answer detail request failed',
174
+ 'Try again later or rerun with -v for more detail',
175
+ );
176
+ }
177
+ if (data.__malformedJson) {
178
+ throw new CommandExecutionError(
179
+ `Zhihu answer detail returned malformed JSON: ${data.__malformedJson}`,
180
+ 'Try again later or rerun with -v for more detail',
181
+ );
182
+ }
183
+ if (typeof data !== 'object' || Array.isArray(data)) {
184
+ throw new CommandExecutionError(
185
+ 'Zhihu answer detail returned a malformed payload',
186
+ 'Try again later or rerun with -v for more detail',
187
+ );
188
+ }
189
+ if (data.error || data.error_msg || data.message) {
190
+ throw new CommandExecutionError(
191
+ `Zhihu answer detail returned an error payload: ${data.error?.message || data.error_msg || data.message}`,
192
+ 'Try again later or rerun with -v for more detail',
193
+ );
194
+ }
195
+ if (!Object.prototype.hasOwnProperty.call(data, 'content')) {
196
+ throw new CommandExecutionError(
197
+ 'Zhihu answer detail payload did not include answer content',
198
+ 'Try again later or rerun with -v for more detail',
199
+ );
200
+ }
201
+ const question = data.question || {};
202
+ // Answer ids and newer question ids can exceed
203
+ // Number.MAX_SAFE_INTEGER. Prefer ids parsed from user input or
204
+ // the canonical redirected URL; only fall back to API numeric ids
205
+ // when no string-safe source is available.
206
+ const questionId = target.questionId
207
+ || currentQuestionId
208
+ || extractQuestionIdFromAnswerUrl(question.url)
209
+ || (question.id == null ? '' : String(question.id));
210
+ const stripped = stripHtml(data.content || '');
211
+ // Truncation is opt-in only; default `maxContent === 0` short-
212
+ // circuits the conditional so the full stripped body is returned.
213
+ const content = maxContent > 0 && stripped.length > maxContent
214
+ ? stripped.substring(0, maxContent)
215
+ : stripped;
216
+ return [{
217
+ id: answerId,
218
+ author: data.author?.name || 'anonymous',
219
+ votes: normalizeCount(data.voteup_count),
220
+ comments: normalizeCount(data.comment_count),
221
+ question_id: questionId,
222
+ question_title: question.title || '',
223
+ url: questionId
224
+ ? `https://www.zhihu.com/question/${questionId}/answer/${answerId}`
225
+ : `https://www.zhihu.com/answer/${answerId}`,
226
+ created_at: normalizeUnixSeconds(data.created_time),
227
+ updated_at: normalizeUnixSeconds(data.updated_time),
228
+ content,
229
+ }];
230
+ },
231
+ });
232
+
233
+ export const __test__ = { stripHtml, extractAnswerId, parseAnswerTarget, extractQuestionIdFromAnswerUrl };