@jackwener/opencli 1.6.7 → 1.6.9

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 (122) hide show
  1. package/README.md +5 -1
  2. package/README.zh-CN.md +8 -3
  3. package/dist/clis/1688/assets.d.ts +42 -0
  4. package/dist/clis/1688/assets.js +204 -0
  5. package/dist/clis/1688/assets.test.d.ts +1 -0
  6. package/dist/clis/1688/assets.test.js +39 -0
  7. package/dist/clis/1688/download.d.ts +9 -0
  8. package/dist/clis/1688/download.js +76 -0
  9. package/dist/clis/1688/download.test.d.ts +1 -0
  10. package/dist/clis/1688/download.test.js +31 -0
  11. package/dist/clis/1688/shared.d.ts +10 -0
  12. package/dist/clis/1688/shared.js +43 -0
  13. package/dist/clis/jianyu/search.d.ts +14 -0
  14. package/dist/clis/jianyu/search.js +135 -0
  15. package/dist/clis/jianyu/search.test.d.ts +1 -0
  16. package/dist/clis/jianyu/search.test.js +23 -0
  17. package/dist/clis/linux-do/topic-content.d.ts +35 -0
  18. package/dist/clis/linux-do/topic-content.js +154 -0
  19. package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
  20. package/dist/clis/linux-do/topic-content.test.js +59 -0
  21. package/dist/clis/linux-do/topic.yaml +1 -16
  22. package/dist/clis/quark/ls.d.ts +1 -0
  23. package/dist/clis/quark/ls.js +63 -0
  24. package/dist/clis/quark/mkdir.d.ts +1 -0
  25. package/dist/clis/quark/mkdir.js +36 -0
  26. package/dist/clis/quark/mv.d.ts +1 -0
  27. package/dist/clis/quark/mv.js +53 -0
  28. package/dist/clis/quark/rename.d.ts +1 -0
  29. package/dist/clis/quark/rename.js +26 -0
  30. package/dist/clis/quark/rm.d.ts +1 -0
  31. package/dist/clis/quark/rm.js +24 -0
  32. package/dist/clis/quark/save.d.ts +1 -0
  33. package/dist/clis/quark/save.js +80 -0
  34. package/dist/clis/quark/share-tree.d.ts +1 -0
  35. package/dist/clis/quark/share-tree.js +45 -0
  36. package/dist/clis/quark/utils.d.ts +50 -0
  37. package/dist/clis/quark/utils.js +146 -0
  38. package/dist/clis/quark/utils.test.d.ts +1 -0
  39. package/dist/clis/quark/utils.test.js +58 -0
  40. package/dist/clis/twitter/reply.js +3 -8
  41. package/dist/clis/twitter/reply.test.js +5 -5
  42. package/dist/clis/xiaohongshu/note.js +8 -3
  43. package/dist/clis/xiaohongshu/note.test.js +11 -0
  44. package/dist/clis/xueqiu/groups.yaml +23 -0
  45. package/dist/clis/xueqiu/kline.yaml +65 -0
  46. package/dist/clis/xueqiu/watchlist.yaml +9 -9
  47. package/dist/clis/zhihu/answer.d.ts +1 -0
  48. package/dist/clis/zhihu/answer.js +194 -0
  49. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  50. package/dist/clis/zhihu/answer.test.js +81 -0
  51. package/dist/clis/zhihu/comment.d.ts +1 -0
  52. package/dist/clis/zhihu/comment.js +335 -0
  53. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  54. package/dist/clis/zhihu/comment.test.js +54 -0
  55. package/dist/clis/zhihu/favorite.d.ts +1 -0
  56. package/dist/clis/zhihu/favorite.js +224 -0
  57. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  58. package/dist/clis/zhihu/favorite.test.js +196 -0
  59. package/dist/clis/zhihu/follow.d.ts +1 -0
  60. package/dist/clis/zhihu/follow.js +80 -0
  61. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  62. package/dist/clis/zhihu/follow.test.js +45 -0
  63. package/dist/clis/zhihu/like.d.ts +1 -0
  64. package/dist/clis/zhihu/like.js +91 -0
  65. package/dist/clis/zhihu/like.test.d.ts +1 -0
  66. package/dist/clis/zhihu/like.test.js +64 -0
  67. package/dist/clis/zhihu/target.d.ts +24 -0
  68. package/dist/clis/zhihu/target.js +91 -0
  69. package/dist/clis/zhihu/target.test.d.ts +1 -0
  70. package/dist/clis/zhihu/target.test.js +77 -0
  71. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  72. package/dist/clis/zhihu/write-shared.js +221 -0
  73. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  74. package/dist/clis/zhihu/write-shared.test.js +175 -0
  75. package/dist/src/analysis.d.ts +2 -0
  76. package/dist/src/analysis.js +6 -0
  77. package/dist/src/browser/bridge.d.ts +2 -0
  78. package/dist/src/browser/bridge.js +30 -24
  79. package/dist/src/browser/cdp.js +96 -0
  80. package/dist/src/browser/daemon-client.d.ts +17 -8
  81. package/dist/src/browser/daemon-client.js +12 -13
  82. package/dist/src/browser/daemon-client.test.js +32 -25
  83. package/dist/src/browser/index.d.ts +2 -1
  84. package/dist/src/browser/index.js +1 -1
  85. package/dist/src/browser.test.js +2 -3
  86. package/dist/src/build-manifest.d.ts +3 -1
  87. package/dist/src/build-manifest.js +10 -7
  88. package/dist/src/build-manifest.test.js +8 -4
  89. package/dist/src/cli.d.ts +2 -1
  90. package/dist/src/cli.js +48 -46
  91. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  92. package/dist/src/clis/binance/commands.test.js +54 -0
  93. package/dist/src/commanderAdapter.js +19 -6
  94. package/dist/src/commands/daemon.js +2 -10
  95. package/dist/src/diagnostic.d.ts +28 -2
  96. package/dist/src/diagnostic.js +263 -25
  97. package/dist/src/diagnostic.test.js +220 -1
  98. package/dist/src/discovery.js +7 -17
  99. package/dist/src/doctor.d.ts +2 -0
  100. package/dist/src/doctor.js +59 -31
  101. package/dist/src/doctor.test.js +89 -16
  102. package/dist/src/download/progress.js +7 -2
  103. package/dist/src/execution.js +1 -13
  104. package/dist/src/explore.d.ts +0 -2
  105. package/dist/src/explore.js +61 -38
  106. package/dist/src/extension-manifest-regression.test.js +0 -1
  107. package/dist/src/generate.d.ts +3 -6
  108. package/dist/src/generate.js +4 -8
  109. package/dist/src/package-paths.d.ts +8 -0
  110. package/dist/src/package-paths.js +41 -0
  111. package/dist/src/plugin-scaffold.js +1 -3
  112. package/dist/src/plugin.d.ts +2 -1
  113. package/dist/src/plugin.js +25 -8
  114. package/dist/src/plugin.test.js +16 -1
  115. package/dist/src/record.d.ts +1 -2
  116. package/dist/src/record.js +14 -52
  117. package/dist/src/synthesize.d.ts +0 -2
  118. package/dist/src/synthesize.js +8 -4
  119. package/package.json +3 -3
  120. package/dist/cli-manifest.json +0 -17250
  121. package/dist/src/browser/discover.d.ts +0 -15
  122. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,91 @@
1
+ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { assertAllowedKinds, parseTarget } from './target.js';
4
+ import { buildResultRow, requireExecute } from './write-shared.js';
5
+ cli({
6
+ site: 'zhihu',
7
+ name: 'like',
8
+ description: 'Like a Zhihu answer or article',
9
+ domain: 'zhihu.com',
10
+ strategy: Strategy.UI,
11
+ browser: true,
12
+ args: [
13
+ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
14
+ { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
15
+ ],
16
+ columns: ['status', 'outcome', 'message', 'target_type', 'target'],
17
+ func: async (page, kwargs) => {
18
+ if (!page)
19
+ throw new CommandExecutionError('Browser session required for zhihu like');
20
+ requireExecute(kwargs);
21
+ const rawTarget = String(kwargs.target);
22
+ const target = assertAllowedKinds('like', parseTarget(rawTarget));
23
+ await page.goto(target.url);
24
+ const result = await page.evaluate(`(async () => {
25
+ const targetKind = ${JSON.stringify(target.kind)};
26
+ const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
27
+ const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
28
+
29
+ let btn = null;
30
+ if (targetKind === 'answer') {
31
+ const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
32
+ const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
33
+ if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
34
+ return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
35
+ const href = link.getAttribute('href') || '';
36
+ return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
37
+ });
38
+ });
39
+ if (!block) return { state: 'wrong_answer' };
40
+ const candidates = Array.from(block?.querySelectorAll('button') || []).filter((node) => {
41
+ const text = (node.textContent || '').trim();
42
+ const inCommentItem = Boolean(node.closest('[data-comment-id], .CommentItem'));
43
+ return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed') && !inCommentItem;
44
+ });
45
+ if (candidates.length !== 1) return { state: 'ambiguous_answer_like' };
46
+ btn = candidates[0];
47
+ } else {
48
+ const articleRoot =
49
+ document.querySelector('article')
50
+ || document.querySelector('.Post-Main')
51
+ || document.querySelector('[itemprop="articleBody"]')
52
+ || document;
53
+ const candidates = Array.from(articleRoot.querySelectorAll('button')).filter((node) => {
54
+ const text = (node.textContent || '').trim();
55
+ return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed');
56
+ });
57
+ if (candidates.length !== 1) return { state: 'ambiguous_article_like' };
58
+ btn = candidates[0];
59
+ }
60
+
61
+ if (!btn) return { state: 'missing' };
62
+ if (btn.getAttribute('aria-pressed') === 'true') return { state: 'already_liked' };
63
+
64
+ btn.click();
65
+ await new Promise((resolve) => setTimeout(resolve, 1200));
66
+
67
+ return btn.getAttribute('aria-pressed') === 'true'
68
+ ? { state: 'liked' }
69
+ : { state: 'unknown' };
70
+ })()`);
71
+ if (result?.state === 'wrong_answer') {
72
+ throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
73
+ }
74
+ if (result?.state === 'already_liked') {
75
+ return buildResultRow(`Already liked ${target.kind}`, target.kind, rawTarget, 'already_applied');
76
+ }
77
+ if (result?.state === 'ambiguous_answer_like') {
78
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Answer like control was not uniquely anchored on the requested answer');
79
+ }
80
+ if (result?.state === 'ambiguous_article_like') {
81
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Article like control was not uniquely anchored on the requested target');
82
+ }
83
+ if (result?.state === 'missing') {
84
+ throw new CliError('ACTION_FAILED', 'Zhihu like control was missing before any write was dispatched');
85
+ }
86
+ if (result?.state !== 'liked') {
87
+ throw new CliError('OUTCOME_UNKNOWN', 'Zhihu like click was dispatched, but the final state could not be verified safely');
88
+ }
89
+ return buildResultRow(`Liked ${target.kind}`, target.kind, rawTarget, 'applied');
90
+ },
91
+ });
@@ -0,0 +1 @@
1
+ import './like.js';
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './like.js';
4
+ describe('zhihu like', () => {
5
+ it('rejects article pages where the like control is not uniquely anchored', async () => {
6
+ const cmd = getRegistry().get('zhihu/like');
7
+ expect(cmd?.func).toBeTypeOf('function');
8
+ const page = {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_article_like' }),
11
+ };
12
+ await expect(cmd.func(page, { target: 'article:9', execute: true })).rejects.toMatchObject({
13
+ code: 'ACTION_NOT_AVAILABLE',
14
+ });
15
+ });
16
+ it('returns already_applied for an already-liked article target', async () => {
17
+ const cmd = getRegistry().get('zhihu/like');
18
+ const page = {
19
+ goto: vi.fn().mockResolvedValue(undefined),
20
+ evaluate: vi.fn().mockResolvedValue({ state: 'already_liked' }),
21
+ };
22
+ await expect(cmd.func(page, { target: 'article:9', execute: true })).resolves.toEqual([
23
+ expect.objectContaining({ outcome: 'already_applied', target_type: 'article', target: 'article:9' }),
24
+ ]);
25
+ });
26
+ it('anchors to the requested answer block before clicking like', async () => {
27
+ const cmd = getRegistry().get('zhihu/like');
28
+ const page = {
29
+ goto: vi.fn().mockResolvedValue(undefined),
30
+ evaluate: vi.fn().mockResolvedValue({ state: 'liked' }),
31
+ };
32
+ await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).resolves.toEqual([
33
+ expect.objectContaining({ outcome: 'applied', target_type: 'answer', target: 'answer:123:456' }),
34
+ ]);
35
+ expect(page.goto).toHaveBeenCalledWith('https://www.zhihu.com/question/123/answer/456');
36
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
37
+ expect(page.evaluate.mock.calls[0][0]).toContain('targetQuestionId');
38
+ expect(page.evaluate.mock.calls[0][0]).toContain('"123"');
39
+ expect(page.evaluate.mock.calls[0][0]).toContain('"456"');
40
+ expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-answerid')");
41
+ expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-zop-question-answer')");
42
+ });
43
+ it('rejects answer targets when the answer-level like control is not unique', async () => {
44
+ const cmd = getRegistry().get('zhihu/like');
45
+ const page = {
46
+ goto: vi.fn().mockResolvedValue(undefined),
47
+ evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_answer_like' }),
48
+ };
49
+ await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({
50
+ code: 'ACTION_NOT_AVAILABLE',
51
+ });
52
+ });
53
+ it('maps missing answer blocks to TARGET_NOT_FOUND', async () => {
54
+ const cmd = getRegistry().get('zhihu/like');
55
+ const page = {
56
+ goto: vi.fn().mockResolvedValue(undefined),
57
+ evaluate: vi.fn().mockResolvedValue({ state: 'wrong_answer' }),
58
+ };
59
+ await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({
60
+ code: 'TARGET_NOT_FOUND',
61
+ });
62
+ expect(page.evaluate.mock.calls[0][0]).toContain("if (!block) return { state: 'wrong_answer' }");
63
+ });
64
+ });
@@ -0,0 +1,24 @@
1
+ export type ZhihuTarget = {
2
+ kind: 'user';
3
+ slug: string;
4
+ url: string;
5
+ } | {
6
+ kind: 'question';
7
+ id: string;
8
+ url: string;
9
+ } | {
10
+ kind: 'answer';
11
+ questionId: string;
12
+ id: string;
13
+ url: string;
14
+ } | {
15
+ kind: 'article';
16
+ id: string;
17
+ url: string;
18
+ };
19
+ export declare function parseTarget(input: string): ZhihuTarget;
20
+ export declare function assertAllowedKinds(command: string, target: ZhihuTarget): ZhihuTarget;
21
+ export declare const __test__: {
22
+ parseTarget: typeof parseTarget;
23
+ assertAllowedKinds: typeof assertAllowedKinds;
24
+ };
@@ -0,0 +1,91 @@
1
+ import { CliError } from '@jackwener/opencli/errors';
2
+ const USER_RE = /^user:([A-Za-z0-9_-]+)$/;
3
+ const QUESTION_RE = /^question:(\d+)$/;
4
+ const ANSWER_RE = /^answer:(\d+):(\d+)$/;
5
+ const ARTICLE_RE = /^article:(\d+)$/;
6
+ const USER_PATH_RE = /^\/people\/([A-Za-z0-9_-]+)\/?$/;
7
+ const QUESTION_PATH_RE = /^\/question\/(\d+)\/?$/;
8
+ const ANSWER_PATH_RE = /^\/question\/(\d+)\/answer\/(\d+)\/?$/;
9
+ const ARTICLE_PATH_RE = /^\/p\/(\d+)\/?$/;
10
+ const EMPTY_AUTHORITY_RE = /^https:\/\/(?::)?@/i;
11
+ function isAllowedZhihuUrl(url) {
12
+ return url.protocol === 'https:' && url.username === '' && url.password === '' && url.port === '';
13
+ }
14
+ export function parseTarget(input) {
15
+ const value = String(input).trim();
16
+ if (EMPTY_AUTHORITY_RE.test(value)) {
17
+ throw new CliError('INVALID_INPUT', 'Zhihu write commands require a normal HTTPS Zhihu URL without malformed authority', 'Example: https://www.zhihu.com/question/123456');
18
+ }
19
+ if (value.startsWith('answer:') && !ANSWER_RE.test(value)) {
20
+ throw new CliError('INVALID_INPUT', 'Invalid answer target, expected answer:<questionId>:<answerId>', 'Example: opencli zhihu like answer:123:456 --execute');
21
+ }
22
+ let match = value.match(USER_RE);
23
+ if (match) {
24
+ return { kind: 'user', slug: match[1], url: `https://www.zhihu.com/people/${match[1]}` };
25
+ }
26
+ match = value.match(QUESTION_RE);
27
+ if (match) {
28
+ return { kind: 'question', id: match[1], url: `https://www.zhihu.com/question/${match[1]}` };
29
+ }
30
+ match = value.match(ANSWER_RE);
31
+ if (match) {
32
+ return {
33
+ kind: 'answer',
34
+ questionId: match[1],
35
+ id: match[2],
36
+ url: `https://www.zhihu.com/question/${match[1]}/answer/${match[2]}`,
37
+ };
38
+ }
39
+ match = value.match(ARTICLE_RE);
40
+ if (match) {
41
+ return { kind: 'article', id: match[1], url: `https://zhuanlan.zhihu.com/p/${match[1]}` };
42
+ }
43
+ try {
44
+ const url = new URL(value);
45
+ if (!isAllowedZhihuUrl(url)) {
46
+ throw new Error('unsupported zhihu url variant');
47
+ }
48
+ if (url.hostname === 'www.zhihu.com') {
49
+ const userMatch = url.pathname.match(USER_PATH_RE);
50
+ if (userMatch) {
51
+ const slug = userMatch[1];
52
+ return { kind: 'user', slug, url: `https://www.zhihu.com/people/${slug}` };
53
+ }
54
+ const questionMatch = url.pathname.match(QUESTION_PATH_RE);
55
+ if (questionMatch) {
56
+ return { kind: 'question', id: questionMatch[1], url: `https://www.zhihu.com/question/${questionMatch[1]}` };
57
+ }
58
+ const answerMatch = url.pathname.match(ANSWER_PATH_RE);
59
+ if (answerMatch) {
60
+ return {
61
+ kind: 'answer',
62
+ questionId: answerMatch[1],
63
+ id: answerMatch[2],
64
+ url: `https://www.zhihu.com/question/${answerMatch[1]}/answer/${answerMatch[2]}`,
65
+ };
66
+ }
67
+ }
68
+ if (url.hostname === 'zhuanlan.zhihu.com') {
69
+ const articleMatch = url.pathname.match(ARTICLE_PATH_RE);
70
+ if (articleMatch) {
71
+ return { kind: 'article', id: articleMatch[1], url: `https://zhuanlan.zhihu.com/p/${articleMatch[1]}` };
72
+ }
73
+ }
74
+ }
75
+ catch { }
76
+ throw new CliError('INVALID_INPUT', 'Zhihu write commands require a Zhihu URL or typed target like question:123 or answer:123:456', 'Example: opencli zhihu like answer:123:456 --execute');
77
+ }
78
+ export function assertAllowedKinds(command, target) {
79
+ const allowed = {
80
+ follow: ['user', 'question'],
81
+ like: ['answer', 'article'],
82
+ favorite: ['answer', 'article'],
83
+ comment: ['answer', 'article'],
84
+ answer: ['question'],
85
+ };
86
+ if (!allowed[command]?.includes(target.kind)) {
87
+ throw new CliError('UNSUPPORTED_TARGET', `zhihu ${command} does not support ${target.kind} targets`);
88
+ }
89
+ return target;
90
+ }
91
+ export const __test__ = { parseTarget, assertAllowedKinds };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CliError } from '@jackwener/opencli/errors';
3
+ import { __test__ } from './target.js';
4
+ describe('zhihu target parser', () => {
5
+ it('parses typed answer IDs into canonical targets', () => {
6
+ expect(__test__.parseTarget('answer:123:456')).toEqual({
7
+ kind: 'answer',
8
+ questionId: '123',
9
+ id: '456',
10
+ url: 'https://www.zhihu.com/question/123/answer/456',
11
+ });
12
+ });
13
+ it('parses question URLs into canonical targets', () => {
14
+ expect(__test__.parseTarget('https://www.zhihu.com/question/123456')).toEqual({
15
+ kind: 'question',
16
+ id: '123456',
17
+ url: 'https://www.zhihu.com/question/123456',
18
+ });
19
+ });
20
+ it('canonicalizes question URLs with query strings and fragments', () => {
21
+ expect(__test__.parseTarget('https://www.zhihu.com/question/123456/?utm_source=share#answer-1')).toEqual({
22
+ kind: 'question',
23
+ id: '123456',
24
+ url: 'https://www.zhihu.com/question/123456',
25
+ });
26
+ });
27
+ it('canonicalizes answer URLs with query strings and fragments', () => {
28
+ expect(__test__.parseTarget('https://www.zhihu.com/question/123456/answer/7890/?utm_psn=1#comment')).toEqual({
29
+ kind: 'answer',
30
+ questionId: '123456',
31
+ id: '7890',
32
+ url: 'https://www.zhihu.com/question/123456/answer/7890',
33
+ });
34
+ });
35
+ it('canonicalizes article URLs with query strings and fragments', () => {
36
+ expect(__test__.parseTarget('https://zhuanlan.zhihu.com/p/98765/?utm_id=1#heading')).toEqual({
37
+ kind: 'article',
38
+ id: '98765',
39
+ url: 'https://zhuanlan.zhihu.com/p/98765',
40
+ });
41
+ });
42
+ it('canonicalizes user URLs with trailing slash, query strings, and fragments', () => {
43
+ expect(__test__.parseTarget('https://www.zhihu.com/people/example-user/?utm_term=share#about')).toEqual({
44
+ kind: 'user',
45
+ slug: 'example-user',
46
+ url: 'https://www.zhihu.com/people/example-user',
47
+ });
48
+ });
49
+ it('rejects non-https Zhihu URLs', () => {
50
+ expect(() => __test__.parseTarget('http://www.zhihu.com/question/123456')).toThrowError(CliError);
51
+ });
52
+ it('rejects Zhihu URLs with embedded credentials', () => {
53
+ expect(() => __test__.parseTarget('https://user@www.zhihu.com/question/123456')).toThrowError(CliError);
54
+ });
55
+ it('rejects Zhihu URLs with explicit ports', () => {
56
+ expect(() => __test__.parseTarget('https://www.zhihu.com:8443/question/123456')).toThrowError(CliError);
57
+ });
58
+ it('rejects Zhihu URLs with empty authority usernames', () => {
59
+ expect(() => __test__.parseTarget('https://@www.zhihu.com/question/123456')).toThrowError(CliError);
60
+ });
61
+ it('rejects Zhihu URLs with empty authority username and password markers', () => {
62
+ expect(() => __test__.parseTarget('https://:@www.zhihu.com/question/123456')).toThrowError(CliError);
63
+ });
64
+ it('rejects ambiguous bare numeric IDs', () => {
65
+ expect(() => __test__.parseTarget('123456')).toThrowError(CliError);
66
+ });
67
+ it('rejects malformed typed IDs', () => {
68
+ expect(() => __test__.parseTarget('answer:123')).toThrowError(/answer:<questionId>:<answerId>/);
69
+ });
70
+ it('rejects unsupported target kinds per command', () => {
71
+ expect(() => __test__.assertAllowedKinds('follow', {
72
+ kind: 'article',
73
+ id: '1',
74
+ url: 'https://zhuanlan.zhihu.com/p/1',
75
+ })).toThrowError(/follow/);
76
+ });
77
+ });
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import type { IPage } from '@jackwener/opencli/types';
3
+ type FileStatLike = {
4
+ isFile(): boolean;
5
+ };
6
+ type FileReaderDeps = {
7
+ readFile: typeof readFile;
8
+ stat: (path: string) => Promise<FileStatLike>;
9
+ decodeUtf8: (raw: Buffer) => string;
10
+ };
11
+ type IdentityRootLike = {
12
+ querySelectorAll(selector: string): ArrayLike<unknown>;
13
+ };
14
+ export declare function resolveCurrentUserSlugFromDom(state: unknown, documentRoot: IdentityRootLike): string | null;
15
+ export declare function requireExecute(kwargs: Record<string, unknown>): void;
16
+ export declare function resolvePayload(kwargs: Record<string, unknown>, deps?: FileReaderDeps): Promise<string>;
17
+ export declare function resolveCurrentUserIdentity(page: Pick<IPage, 'evaluate'>): Promise<string>;
18
+ export declare function buildResultRow(message: string, targetType: string, target: string, outcome: 'applied' | 'already_applied' | 'created', extra?: Record<string, unknown>): {
19
+ status: string;
20
+ outcome: "created" | "applied" | "already_applied";
21
+ message: string;
22
+ target_type: string;
23
+ target: string;
24
+ }[];
25
+ export declare const __test__: {
26
+ requireExecute: typeof requireExecute;
27
+ resolvePayload: typeof resolvePayload;
28
+ resolveCurrentUserIdentity: typeof resolveCurrentUserIdentity;
29
+ resolveCurrentUserSlugFromDom: typeof resolveCurrentUserSlugFromDom;
30
+ buildResultRow: typeof buildResultRow;
31
+ };
32
+ export {};
@@ -0,0 +1,221 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { CliError } from '@jackwener/opencli/errors';
3
+ const RESULT_ROW_RESERVED_KEYS = new Set(['status', 'outcome', 'message', 'target_type', 'target']);
4
+ const NAV_SCOPE_SELECTOR = 'header, nav, [role="banner"], [role="navigation"]';
5
+ const PROFILE_LINK_SELECTOR = 'a[href^="/people/"]';
6
+ const AVATAR_SELECTOR = 'img, [class*="Avatar"], [data-testid*="avatar" i], [aria-label*="头像"]';
7
+ const SELF_LABEL_TOKENS = ['我', '我的', '个人主页'];
8
+ const EXPLICIT_IDENTITY_META_TOKEN_GROUPS = [
9
+ ['self'],
10
+ ['current', 'user'],
11
+ ['account', 'profile'],
12
+ ['my', 'profile'],
13
+ ['my', 'account'],
14
+ ];
15
+ const IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS = JSON.stringify(EXPLICIT_IDENTITY_META_TOKEN_GROUPS);
16
+ function defaultFileReaderDeps() {
17
+ return {
18
+ readFile,
19
+ stat: (path) => stat(path),
20
+ decodeUtf8: (raw) => new TextDecoder('utf-8', { fatal: true }).decode(raw),
21
+ };
22
+ }
23
+ function hasExplicitIdentityLabel(text) {
24
+ const normalized = text.toLowerCase();
25
+ return SELF_LABEL_TOKENS.some((token) => text.includes(token)) || normalized.includes('my profile') || normalized.includes('my account');
26
+ }
27
+ function tokenizeIdentityMeta(text) {
28
+ return text
29
+ .toLowerCase()
30
+ .split(/[^a-z0-9]+/)
31
+ .filter(Boolean);
32
+ }
33
+ function hasExplicitIdentityMeta(text) {
34
+ const tokens = new Set(tokenizeIdentityMeta(text));
35
+ return EXPLICIT_IDENTITY_META_TOKEN_GROUPS.some((group) => group.every((token) => tokens.has(token)));
36
+ }
37
+ function isIdentityRootLike(value) {
38
+ return typeof value === 'object' && value !== null && 'querySelectorAll' in value
39
+ && typeof value.querySelectorAll === 'function';
40
+ }
41
+ function isIdentityNodeLike(value) {
42
+ return typeof value === 'object' && value !== null
43
+ && 'getAttribute' in value
44
+ && 'querySelector' in value
45
+ && typeof value.getAttribute === 'function'
46
+ && typeof value.querySelector === 'function';
47
+ }
48
+ function resolveSlugFromState(state) {
49
+ const slugFromState = state?.topstory?.me?.slug
50
+ || state?.me?.slug
51
+ || state?.initialState?.me?.slug;
52
+ return typeof slugFromState === 'string' && slugFromState ? slugFromState : null;
53
+ }
54
+ function getSlugFromIdentityLink(node, allowAvatarOnly) {
55
+ const href = node.getAttribute('href') || '';
56
+ const match = href.match(/^\/people\/([A-Za-z0-9_-]+)/);
57
+ if (!match)
58
+ return null;
59
+ const aria = node.getAttribute('aria-label') || '';
60
+ const title = node.getAttribute('title') || '';
61
+ const testid = node.getAttribute('data-testid') || '';
62
+ const className = node.getAttribute('class') || '';
63
+ const rel = node.getAttribute('rel') || '';
64
+ const identityLabel = `${aria} ${title} ${node.textContent || ''}`;
65
+ const identityMeta = `${testid} ${className} ${rel}`;
66
+ const hasAvatar = Boolean(node.querySelector(AVATAR_SELECTOR));
67
+ const isExplicitIdentityLabel = hasExplicitIdentityLabel(identityLabel);
68
+ const isExplicitIdentityMeta = hasExplicitIdentityMeta(identityMeta);
69
+ if (isExplicitIdentityLabel || isExplicitIdentityMeta)
70
+ return match[1];
71
+ if (allowAvatarOnly && hasAvatar)
72
+ return match[1];
73
+ return null;
74
+ }
75
+ function findCurrentUserSlugFromRoots(roots, allowAvatarOnly) {
76
+ for (const root of roots) {
77
+ for (const node of Array.from(root.querySelectorAll(PROFILE_LINK_SELECTOR)).filter(isIdentityNodeLike)) {
78
+ const slug = getSlugFromIdentityLink(node, allowAvatarOnly);
79
+ if (slug)
80
+ return slug;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ export function resolveCurrentUserSlugFromDom(state, documentRoot) {
86
+ const slugFromState = resolveSlugFromState(state);
87
+ if (slugFromState)
88
+ return slugFromState;
89
+ const navScopes = Array.from(documentRoot.querySelectorAll(NAV_SCOPE_SELECTOR)).filter(isIdentityRootLike);
90
+ return findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([documentRoot], false);
91
+ }
92
+ export function requireExecute(kwargs) {
93
+ if (!kwargs.execute) {
94
+ throw new CliError('INVALID_INPUT', 'This Zhihu write command requires --execute');
95
+ }
96
+ }
97
+ export async function resolvePayload(kwargs, deps = defaultFileReaderDeps()) {
98
+ const text = typeof kwargs.text === 'string' ? kwargs.text : undefined;
99
+ const file = typeof kwargs.file === 'string' ? kwargs.file : undefined;
100
+ if (text && file) {
101
+ throw new CliError('INVALID_INPUT', 'Use either <text> or --file, not both');
102
+ }
103
+ let resolved = text ?? '';
104
+ if (file) {
105
+ let fileStat;
106
+ try {
107
+ fileStat = await deps.stat(file);
108
+ }
109
+ catch {
110
+ throw new CliError('INVALID_INPUT', `File not found: ${file}`);
111
+ }
112
+ if (!fileStat.isFile()) {
113
+ throw new CliError('INVALID_INPUT', `File must be a readable text file: ${file}`);
114
+ }
115
+ let raw;
116
+ try {
117
+ raw = await deps.readFile(file);
118
+ }
119
+ catch {
120
+ throw new CliError('INVALID_INPUT', `File could not be read: ${file}`);
121
+ }
122
+ try {
123
+ resolved = deps.decodeUtf8(raw);
124
+ }
125
+ catch {
126
+ throw new CliError('INVALID_INPUT', `File could not be decoded as UTF-8 text: ${file}`);
127
+ }
128
+ }
129
+ if (!resolved.trim()) {
130
+ throw new CliError('INVALID_INPUT', 'Payload cannot be empty or whitespace only');
131
+ }
132
+ return resolved;
133
+ }
134
+ function buildResolveCurrentUserIdentityJs() {
135
+ return `(() => {
136
+ const selfLabelTokens = ${JSON.stringify(SELF_LABEL_TOKENS)};
137
+ const explicitIdentityMetaTokenGroups = ${IN_PAGE_EXPLICIT_IDENTITY_META_TOKEN_GROUPS};
138
+ const navScopeSelector = ${JSON.stringify(NAV_SCOPE_SELECTOR)};
139
+ const profileLinkSelector = ${JSON.stringify(PROFILE_LINK_SELECTOR)};
140
+ const avatarSelector = ${JSON.stringify(AVATAR_SELECTOR)};
141
+
142
+ const hasExplicitIdentityLabel = (text) => {
143
+ const normalized = String(text || '').toLowerCase();
144
+ return selfLabelTokens.some((token) => String(text || '').includes(token))
145
+ || normalized.includes('my profile')
146
+ || normalized.includes('my account');
147
+ };
148
+
149
+ const tokenizeIdentityMeta = (text) => String(text || '')
150
+ .toLowerCase()
151
+ .split(/[^a-z0-9]+/)
152
+ .filter(Boolean);
153
+
154
+ const hasExplicitIdentityMeta = (text) => {
155
+ const tokens = new Set(tokenizeIdentityMeta(text));
156
+ return explicitIdentityMetaTokenGroups.some((group) => group.every((token) => tokens.has(token)));
157
+ };
158
+
159
+ const getSlugFromIdentityLink = (node, allowAvatarOnly) => {
160
+ const href = node.getAttribute('href') || '';
161
+ const match = href.match(/^\\/people\\/([A-Za-z0-9_-]+)/);
162
+ if (!match) return null;
163
+
164
+ const aria = node.getAttribute('aria-label') || '';
165
+ const title = node.getAttribute('title') || '';
166
+ const testid = node.getAttribute('data-testid') || '';
167
+ const className = node.getAttribute('class') || '';
168
+ const rel = node.getAttribute('rel') || '';
169
+ const identityLabel = \`\${aria} \${title} \${node.textContent || ''}\`;
170
+ const identityMeta = \`\${testid} \${className} \${rel}\`;
171
+ const hasAvatar = Boolean(node.querySelector(avatarSelector));
172
+
173
+ if (hasExplicitIdentityLabel(identityLabel) || hasExplicitIdentityMeta(identityMeta)) return match[1];
174
+ if (allowAvatarOnly && hasAvatar) return match[1];
175
+ return null;
176
+ };
177
+
178
+ const findCurrentUserSlugFromRoots = (roots, allowAvatarOnly) => {
179
+ for (const root of roots) {
180
+ for (const node of Array.from(root.querySelectorAll(profileLinkSelector))) {
181
+ const slug = getSlugFromIdentityLink(node, allowAvatarOnly);
182
+ if (slug) return slug;
183
+ }
184
+ }
185
+ return null;
186
+ };
187
+
188
+ const scopedGlobal = globalThis;
189
+ const state = scopedGlobal.__INITIAL_STATE__ || (scopedGlobal.window && scopedGlobal.window.__INITIAL_STATE__) || null;
190
+ const slugFromState = state && (state.topstory && state.topstory.me && state.topstory.me.slug)
191
+ || (state && state.me && state.me.slug)
192
+ || (state && state.initialState && state.initialState.me && state.initialState.me.slug);
193
+ if (typeof slugFromState === 'string' && slugFromState) return { slug: slugFromState };
194
+
195
+ const navScopes = Array.from(document.querySelectorAll(navScopeSelector));
196
+ const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false);
197
+ return slug ? { slug } : null;
198
+ })()`;
199
+ }
200
+ export async function resolveCurrentUserIdentity(page) {
201
+ const identity = await page.evaluate(buildResolveCurrentUserIdentityJs());
202
+ if (!identity?.slug) {
203
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Could not resolve the logged-in Zhihu user identity before write');
204
+ }
205
+ return identity.slug;
206
+ }
207
+ export function buildResultRow(message, targetType, target, outcome, extra = {}) {
208
+ for (const key of Object.keys(extra)) {
209
+ if (RESULT_ROW_RESERVED_KEYS.has(key)) {
210
+ throw new CliError('INVALID_INPUT', `Result extra field cannot overwrite reserved key: ${key}`);
211
+ }
212
+ }
213
+ return [{ status: 'success', outcome, message, target_type: targetType, target, ...extra }];
214
+ }
215
+ export const __test__ = {
216
+ requireExecute,
217
+ resolvePayload,
218
+ resolveCurrentUserIdentity,
219
+ resolveCurrentUserSlugFromDom,
220
+ buildResultRow,
221
+ };
@@ -0,0 +1 @@
1
+ export {};