@jackwener/opencli 1.7.17 → 1.7.18

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.
@@ -1,23 +1,11 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- cli({
3
- site: 'xiaohongshu',
4
- name: 'notifications',
5
- access: 'read',
6
- description: '小红书通知 (mentions/likes/connections)',
7
- domain: 'www.xiaohongshu.com',
8
- strategy: Strategy.INTERCEPT,
9
- browser: true,
10
- args: [
11
- {
12
- name: 'type',
13
- default: 'mentions',
14
- help: 'Notification type: mentions, likes, or connections',
15
- },
16
- { name: 'limit', type: 'int', default: 20, help: 'Number of notifications to return' },
17
- ],
18
- columns: ['rank', 'user', 'action', 'content', 'note', 'time'],
19
- pipeline: [
20
- { navigate: 'https://www.xiaohongshu.com/notification' },
2
+ /**
3
+ * Build the notifications pipeline for the given web host. Exported so the
4
+ * rednote adapter can register the same pipeline against www.rednote.com.
5
+ */
6
+ export function buildNotificationsPipeline(webHost) {
7
+ return [
8
+ { navigate: `https://${webHost}/notification` },
21
9
  { tap: {
22
10
  store: 'notification',
23
11
  action: 'getNotification',
@@ -35,5 +23,24 @@ cli({
35
23
  time: '${{ item.time }}',
36
24
  } },
37
25
  { limit: '${{ args.limit | default(20) }}' },
26
+ ];
27
+ }
28
+ export const command = cli({
29
+ site: 'xiaohongshu',
30
+ name: 'notifications',
31
+ access: 'read',
32
+ description: '小红书通知 (mentions/likes/connections)',
33
+ domain: 'www.xiaohongshu.com',
34
+ strategy: Strategy.INTERCEPT,
35
+ browser: true,
36
+ args: [
37
+ {
38
+ name: 'type',
39
+ default: 'mentions',
40
+ help: 'Notification type: mentions, likes, or connections',
41
+ },
42
+ { name: 'limit', type: 'int', default: 20, help: 'Number of notifications to return' },
38
43
  ],
44
+ columns: ['rank', 'user', 'action', 'content', 'note', 'time'],
45
+ pipeline: buildNotificationsPipeline('www.xiaohongshu.com'),
39
46
  });
@@ -52,37 +52,19 @@ export function stripXhsAuthorDateSuffix(value) {
52
52
  const stripped = text.replace(/\s*(?:\d{1,2}天前|\d+小时前|\d+分钟前|\d+秒前|刚刚|昨天|前天|\d+周前|\d+个月前|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2})$/u, '').trim();
53
53
  return stripped || text;
54
54
  }
55
- cli({
56
- site: 'xiaohongshu',
57
- name: 'search',
58
- access: 'read',
59
- description: '搜索小红书笔记',
60
- domain: 'www.xiaohongshu.com',
61
- strategy: Strategy.COOKIE,
62
- navigateBefore: false,
63
- args: [
64
- { name: 'query', required: true, positional: true, help: 'Search keyword' },
65
- { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
66
- ],
67
- columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
68
- func: async (page, kwargs) => {
69
- const keyword = encodeURIComponent(kwargs.query);
70
- await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
71
- // Wait for search results to render (or login wall to appear).
72
- // Uses MutationObserver to resolve as soon as content appears,
73
- // instead of a fixed delay + blind retry.
74
- const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
75
- if (waitResult === 'login_wall') {
76
- throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
77
- }
78
- // Scroll a couple of times to load more results
79
- await page.autoScroll({ times: 2 });
80
- const payload = await page.evaluate(`
55
+ /**
56
+ * Build the search-result extraction IIFE. The web host is baked into the
57
+ * `normalizeUrl` fallback so relative `/explore/...` hrefs resolve to a full
58
+ * URL on the calling site. Exported so the rednote adapter can call it with
59
+ * `www.rednote.com` without duplicating the selector logic.
60
+ */
61
+ export function buildSearchExtractJs(webHost) {
62
+ return `
81
63
  (() => {
82
64
  const normalizeUrl = (href) => {
83
65
  if (!href) return '';
84
66
  if (href.startsWith('http://') || href.startsWith('https://')) return href;
85
- if (href.startsWith('/')) return 'https://www.xiaohongshu.com' + href;
67
+ if (href.startsWith('/')) return 'https://${webHost}' + href;
86
68
  return '';
87
69
  };
88
70
 
@@ -131,7 +113,34 @@ cli({
131
113
 
132
114
  return results;
133
115
  })()
134
- `);
116
+ `;
117
+ }
118
+ export const command = cli({
119
+ site: 'xiaohongshu',
120
+ name: 'search',
121
+ access: 'read',
122
+ description: '搜索小红书笔记',
123
+ domain: 'www.xiaohongshu.com',
124
+ strategy: Strategy.COOKIE,
125
+ navigateBefore: false,
126
+ args: [
127
+ { name: 'query', required: true, positional: true, help: 'Search keyword' },
128
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
129
+ ],
130
+ columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
131
+ func: async (page, kwargs) => {
132
+ const keyword = encodeURIComponent(kwargs.query);
133
+ await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
134
+ // Wait for search results to render (or login wall to appear).
135
+ // Uses MutationObserver to resolve as soon as content appears,
136
+ // instead of a fixed delay + blind retry.
137
+ const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
138
+ if (waitResult === 'login_wall') {
139
+ throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
140
+ }
141
+ // Scroll a couple of times to load more results
142
+ await page.autoScroll({ times: 2 });
143
+ const payload = await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com'));
135
144
  const data = Array.isArray(payload) ? payload : [];
136
145
  return data
137
146
  .filter((item) => item.title)
@@ -27,12 +27,17 @@ export function flattenXhsNoteGroups(noteGroups) {
27
27
  }
28
28
  return notes;
29
29
  }
30
- export function buildXhsNoteUrl(userId, noteId, xsecToken) {
30
+ /**
31
+ * Build a signed user-profile note URL on the given web host (defaults to
32
+ * `www.xiaohongshu.com`). The rednote adapter passes `'www.rednote.com'` so
33
+ * the same builder works for both sites.
34
+ */
35
+ export function buildXhsNoteUrl(userId, noteId, xsecToken, webHost = 'www.xiaohongshu.com') {
31
36
  const cleanUserId = toCleanString(userId);
32
37
  const cleanNoteId = toCleanString(noteId);
33
38
  if (!cleanUserId || !cleanNoteId)
34
39
  return '';
35
- const url = new URL(`https://www.xiaohongshu.com/user/profile/${cleanUserId}/${cleanNoteId}`);
40
+ const url = new URL(`https://${webHost}/user/profile/${cleanUserId}/${cleanNoteId}`);
36
41
  const cleanToken = toCleanString(xsecToken);
37
42
  if (cleanToken) {
38
43
  url.searchParams.set('xsec_token', cleanToken);
@@ -40,7 +45,11 @@ export function buildXhsNoteUrl(userId, noteId, xsecToken) {
40
45
  }
41
46
  return url.toString();
42
47
  }
43
- export function extractXhsUserNotes(snapshot, fallbackUserId) {
48
+ /**
49
+ * Normalise a Pinia user-store snapshot into CLI rows. `webHost` is forwarded
50
+ * to `buildXhsNoteUrl` so the resulting URLs point at the calling site.
51
+ */
52
+ export function extractXhsUserNotes(snapshot, fallbackUserId, webHost = 'www.xiaohongshu.com') {
44
53
  const notes = flattenXhsNoteGroups(snapshot.noteGroups);
45
54
  const rows = [];
46
55
  const seen = new Set();
@@ -62,7 +71,7 @@ export function extractXhsUserNotes(snapshot, fallbackUserId) {
62
71
  type: toCleanString(noteCard.type),
63
72
  likes,
64
73
  cover,
65
- url: buildXhsNoteUrl(userId || fallbackUserId, noteId, xsecToken),
74
+ url: buildXhsNoteUrl(userId || fallbackUserId, noteId, xsecToken, webHost),
66
75
  });
67
76
  }
68
77
  return rows;
@@ -20,6 +20,9 @@ describe('buildXhsNoteUrl', () => {
20
20
  it('includes xsec token when available', () => {
21
21
  expect(buildXhsNoteUrl('user123', 'note456', 'token789')).toBe('https://www.xiaohongshu.com/user/profile/user123/note456?xsec_token=token789&xsec_source=pc_user');
22
22
  });
23
+ it('emits a rednote URL when webHost is overridden', () => {
24
+ expect(buildXhsNoteUrl('user123', 'note456', 'token789', 'www.rednote.com')).toBe('https://www.rednote.com/user/profile/user123/note456?xsec_token=token789&xsec_source=pc_user');
25
+ });
23
26
  });
24
27
  describe('extractXhsUserNotes', () => {
25
28
  it('normalizes grouped note cards into CLI rows', () => {
@@ -96,4 +99,21 @@ describe('extractXhsUserNotes', () => {
96
99
  expect(rows).toHaveLength(1);
97
100
  expect(rows[0]?.title).toBe('keep me');
98
101
  });
102
+ it('emits rednote-hosted URLs when webHost is overridden', () => {
103
+ const rows = extractXhsUserNotes({
104
+ noteGroups: [
105
+ [
106
+ {
107
+ xsecToken: 'tok',
108
+ noteCard: {
109
+ noteId: 'note-red',
110
+ displayTitle: 'rednote note',
111
+ user: { userId: 'user-red' },
112
+ },
113
+ },
114
+ ],
115
+ ],
116
+ }, 'fallback-user', 'www.rednote.com');
117
+ expect(rows[0]?.url).toBe('https://www.rednote.com/user/profile/user-red/note-red?xsec_token=tok&xsec_source=pc_user');
118
+ });
99
119
  });
@@ -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',
@@ -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
  });
package/dist/src/cli.js CHANGED
@@ -600,7 +600,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
600
600
  // All commands wrapped in browserAction() for consistent error handling.
601
601
  const browser = program
602
602
  .command('browser')
603
- .option('--session <name>', 'Browser session to use')
603
+ .requiredOption('--session <name>', 'Browser session to use (required)')
604
604
  .option('--window <mode>', 'Browser window mode: foreground or background')
605
605
  .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
606
606
  const originalBrowserDescription = browser.description();
@@ -723,7 +723,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
723
723
  };
724
724
  }
725
725
  browser.command('bind')
726
- .option('--session <name>', 'Browser session name to bind')
726
+ .option('--session <name>', 'Browser session name to bind (required)')
727
727
  .description('Bind the current Chrome tab/window to a browser session')
728
728
  .action(async (optsOrCommand, maybeCommand) => {
729
729
  const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
@@ -754,7 +754,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
754
754
  }
755
755
  });
756
756
  browser.command('unbind')
757
- .option('--session <name>', 'Browser session name to detach')
757
+ .option('--session <name>', 'Browser session name to detach (required)')
758
758
  .description('Detach a bound browser session without closing the user tab/window')
759
759
  .action(async (optsOrCommand, maybeCommand) => {
760
760
  const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
@@ -354,6 +354,8 @@ describe('createProgram root help descriptions', () => {
354
354
  name: 'session',
355
355
  flags: '--session <name>',
356
356
  takes_value: 'required',
357
+ required: true,
358
+ help: expect.stringContaining('required'),
357
359
  }),
358
360
  expect.objectContaining({
359
361
  name: 'window',
@@ -896,10 +898,13 @@ describe('browser tab targeting commands', () => {
896
898
  });
897
899
  it('requires an explicit session for browser commands', async () => {
898
900
  const program = createProgram('', '');
899
- await program.parseAsync(['node', 'opencli', 'browser', 'state']);
901
+ program.exitOverride((err) => { throw err; });
902
+ program.commands.find(cmd => cmd.name() === 'browser')?.exitOverride((err) => { throw err; });
903
+ await expect(program.parseAsync(['node', 'opencli', 'browser', 'state'])).rejects.toMatchObject({
904
+ code: 'commander.missingMandatoryOptionValue',
905
+ });
900
906
  expect(mockBrowserConnect).not.toHaveBeenCalled();
901
- expect(stderrSpy.mock.calls.flat().join('')).toContain('--session <name> is required');
902
- expect(process.exitCode).toBeDefined();
907
+ expect(stderrSpy.mock.calls.flat().join('')).toContain("required option '--session <name>' not specified");
903
908
  });
904
909
  it('runs browser commands against an explicit session', async () => {
905
910
  const program = createProgram('', '');
@@ -14,6 +14,7 @@ import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
14
14
  import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
15
15
  import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
16
16
  const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
17
+ const DOCTOR_SESSION = '__doctor__';
17
18
  /** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
18
19
  function parseSemver(v) {
19
20
  const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
@@ -50,7 +51,11 @@ export async function checkConnectivity(opts) {
50
51
  const start = Date.now();
51
52
  try {
52
53
  const bridge = new BrowserBridge();
53
- const page = await bridge.connect({ timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS });
54
+ const page = await bridge.connect({
55
+ timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS,
56
+ session: DOCTOR_SESSION,
57
+ surface: 'browser',
58
+ });
54
59
  try {
55
60
  // Try a simple eval to verify end-to-end connectivity.
56
61
  await page.evaluate('1 + 1');
@@ -173,6 +173,8 @@ describe('doctor report rendering', () => {
173
173
  const closeWindow = vi.fn().mockResolvedValue(undefined);
174
174
  mockConnect.mockImplementationOnce(async (opts) => {
175
175
  timeoutSeen = opts?.timeout;
176
+ expect(opts?.session).toBe('__doctor__');
177
+ expect(opts?.surface).toBe('browser');
176
178
  return {
177
179
  evaluate: vi.fn().mockResolvedValue(2),
178
180
  closeWindow,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.7.17",
3
+ "version": "1.7.18",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },