@jackwener/opencli 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -26
- package/README.zh-CN.md +1 -0
- package/SKILL.md +10 -4
- package/dist/cli-manifest.json +195 -22
- package/dist/clis/linkedin/search.d.ts +1 -0
- package/dist/clis/linkedin/search.js +366 -0
- package/dist/clis/reddit/read.d.ts +1 -0
- package/dist/clis/reddit/read.js +184 -0
- package/dist/clis/youtube/transcript-group.d.ts +44 -0
- package/dist/clis/youtube/transcript-group.js +226 -0
- package/dist/clis/youtube/transcript-group.test.d.ts +1 -0
- package/dist/clis/youtube/transcript-group.test.js +99 -0
- package/dist/clis/youtube/transcript.d.ts +1 -0
- package/dist/clis/youtube/transcript.js +264 -0
- package/dist/clis/youtube/utils.d.ts +8 -0
- package/dist/clis/youtube/utils.js +28 -0
- package/dist/clis/youtube/video.d.ts +1 -0
- package/dist/clis/youtube/video.js +114 -0
- package/dist/doctor.d.ts +29 -2
- package/dist/doctor.js +122 -55
- package/dist/doctor.test.js +42 -1
- package/dist/main.js +2 -1
- package/package.json +1 -1
- package/src/clis/linkedin/search.ts +416 -0
- package/src/clis/reddit/read.ts +186 -0
- package/src/clis/youtube/transcript-group.test.ts +108 -0
- package/src/clis/youtube/transcript-group.ts +287 -0
- package/src/clis/youtube/transcript.ts +280 -0
- package/src/clis/youtube/utils.ts +28 -0
- package/src/clis/youtube/video.ts +116 -0
- package/src/doctor.test.ts +46 -1
- package/src/doctor.ts +149 -53
- package/src/main.ts +2 -1
- package/dist/clis/reddit/read.yaml +0 -76
- package/src/clis/reddit/read.yaml +0 -76
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared YouTube utilities — URL parsing, video ID extraction, etc.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract a YouTube video ID from a URL or bare video ID string.
|
|
7
|
+
* Supports: watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/
|
|
8
|
+
*/
|
|
9
|
+
export function parseVideoId(input: string): string {
|
|
10
|
+
if (!input.startsWith('http')) return input;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(input);
|
|
14
|
+
if (parsed.searchParams.has('v')) {
|
|
15
|
+
return parsed.searchParams.get('v')!;
|
|
16
|
+
}
|
|
17
|
+
if (parsed.hostname === 'youtu.be') {
|
|
18
|
+
return parsed.pathname.slice(1).split('/')[0];
|
|
19
|
+
}
|
|
20
|
+
// Handle /shorts/xxx, /embed/xxx, /live/xxx, /v/xxx
|
|
21
|
+
const pathMatch = parsed.pathname.match(/^\/(shorts|embed|live|v)\/([^/?]+)/);
|
|
22
|
+
if (pathMatch) return pathMatch[2];
|
|
23
|
+
} catch {
|
|
24
|
+
// Not a valid URL — treat entire input as video ID
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return input;
|
|
28
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '../../registry.js';
|
|
5
|
+
import { parseVideoId } from './utils.js';
|
|
6
|
+
|
|
7
|
+
cli({
|
|
8
|
+
site: 'youtube',
|
|
9
|
+
name: 'video',
|
|
10
|
+
description: 'Get YouTube video metadata (title, views, description, etc.)',
|
|
11
|
+
domain: 'www.youtube.com',
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', required: true, help: 'YouTube video URL or video ID' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['field', 'value'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const videoId = parseVideoId(kwargs.url);
|
|
19
|
+
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
20
|
+
await page.goto(videoUrl);
|
|
21
|
+
await page.wait(3);
|
|
22
|
+
|
|
23
|
+
const data = await page.evaluate(`
|
|
24
|
+
(async () => {
|
|
25
|
+
const player = window.ytInitialPlayerResponse;
|
|
26
|
+
const yt = window.ytInitialData;
|
|
27
|
+
if (!player) return { error: 'ytInitialPlayerResponse not found' };
|
|
28
|
+
|
|
29
|
+
const details = player.videoDetails || {};
|
|
30
|
+
const microformat = player.microformat?.playerMicroformatRenderer || {};
|
|
31
|
+
|
|
32
|
+
// Try to get full description from ytInitialData
|
|
33
|
+
let fullDescription = details.shortDescription || '';
|
|
34
|
+
try {
|
|
35
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
36
|
+
?.results?.results?.contents;
|
|
37
|
+
if (contents) {
|
|
38
|
+
for (const c of contents) {
|
|
39
|
+
const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
|
|
40
|
+
if (desc) { fullDescription = desc; break; }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
// Get like count if available
|
|
46
|
+
let likes = '';
|
|
47
|
+
try {
|
|
48
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
49
|
+
?.results?.results?.contents;
|
|
50
|
+
if (contents) {
|
|
51
|
+
for (const c of contents) {
|
|
52
|
+
const buttons = c.videoPrimaryInfoRenderer?.videoActions
|
|
53
|
+
?.menuRenderer?.topLevelButtons;
|
|
54
|
+
if (buttons) {
|
|
55
|
+
for (const b of buttons) {
|
|
56
|
+
const toggle = b.segmentedLikeDislikeButtonViewModel
|
|
57
|
+
?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
|
|
58
|
+
?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
|
|
59
|
+
if (toggle?.title) { likes = toggle.title; break; }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
// Get publish date
|
|
67
|
+
const publishDate = microformat.publishDate
|
|
68
|
+
|| microformat.uploadDate
|
|
69
|
+
|| details.publishDate || '';
|
|
70
|
+
|
|
71
|
+
// Get category
|
|
72
|
+
const category = microformat.category || '';
|
|
73
|
+
|
|
74
|
+
// Get channel subscriber count if available
|
|
75
|
+
let subscribers = '';
|
|
76
|
+
try {
|
|
77
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
78
|
+
?.results?.results?.contents;
|
|
79
|
+
if (contents) {
|
|
80
|
+
for (const c of contents) {
|
|
81
|
+
const owner = c.videoSecondaryInfoRenderer?.owner
|
|
82
|
+
?.videoOwnerRenderer?.subscriberCountText?.simpleText;
|
|
83
|
+
if (owner) { subscribers = owner; break; }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
title: details.title || '',
|
|
90
|
+
channel: details.author || '',
|
|
91
|
+
channelId: details.channelId || '',
|
|
92
|
+
videoId: details.videoId || '',
|
|
93
|
+
views: details.viewCount || '',
|
|
94
|
+
likes,
|
|
95
|
+
subscribers,
|
|
96
|
+
duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
|
|
97
|
+
publishDate,
|
|
98
|
+
category,
|
|
99
|
+
description: fullDescription,
|
|
100
|
+
keywords: (details.keywords || []).join(', '),
|
|
101
|
+
isLive: details.isLiveContent || false,
|
|
102
|
+
thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
|
|
103
|
+
};
|
|
104
|
+
})()
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
|
|
108
|
+
if (data.error) throw new Error(data.error);
|
|
109
|
+
|
|
110
|
+
// Return as field/value pairs for table display
|
|
111
|
+
return Object.entries(data).map(([field, value]) => ({
|
|
112
|
+
field,
|
|
113
|
+
value: String(value),
|
|
114
|
+
}));
|
|
115
|
+
},
|
|
116
|
+
});
|
package/src/doctor.test.ts
CHANGED
|
@@ -81,7 +81,7 @@ describe('json token helpers', () => {
|
|
|
81
81
|
},
|
|
82
82
|
}), 'abc123');
|
|
83
83
|
const parsed = JSON.parse(next);
|
|
84
|
-
expect(parsed.mcp.playwright.
|
|
84
|
+
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
85
85
|
});
|
|
86
86
|
});
|
|
87
87
|
|
|
@@ -94,6 +94,8 @@ describe('doctor report rendering', () => {
|
|
|
94
94
|
envFingerprint: 'fp1',
|
|
95
95
|
extensionToken: 'abc123',
|
|
96
96
|
extensionFingerprint: 'fp1',
|
|
97
|
+
extensionInstalled: true,
|
|
98
|
+
extensionBrowsers: ['Chrome'],
|
|
97
99
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
98
100
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
99
101
|
recommendedToken: 'abc123',
|
|
@@ -102,6 +104,7 @@ describe('doctor report rendering', () => {
|
|
|
102
104
|
issues: [],
|
|
103
105
|
}));
|
|
104
106
|
|
|
107
|
+
expect(text).toContain('[OK] Extension installed (Chrome)');
|
|
105
108
|
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
106
109
|
expect(text).toContain('[OK] /tmp/mcp.json');
|
|
107
110
|
expect(text).toContain('configured (fp1)');
|
|
@@ -113,6 +116,8 @@ describe('doctor report rendering', () => {
|
|
|
113
116
|
envFingerprint: 'fp1',
|
|
114
117
|
extensionToken: null,
|
|
115
118
|
extensionFingerprint: null,
|
|
119
|
+
extensionInstalled: false,
|
|
120
|
+
extensionBrowsers: [],
|
|
116
121
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
117
122
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
118
123
|
recommendedToken: 'abc123',
|
|
@@ -121,10 +126,50 @@ describe('doctor report rendering', () => {
|
|
|
121
126
|
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
122
127
|
}));
|
|
123
128
|
|
|
129
|
+
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
|
124
130
|
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
125
131
|
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
126
132
|
expect(text).toContain('configured (fp2)');
|
|
127
133
|
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
128
134
|
});
|
|
135
|
+
|
|
136
|
+
it('renders connectivity OK when live test succeeds', () => {
|
|
137
|
+
const text = strip(renderBrowserDoctorReport({
|
|
138
|
+
envToken: 'abc123',
|
|
139
|
+
envFingerprint: 'fp1',
|
|
140
|
+
extensionToken: 'abc123',
|
|
141
|
+
extensionFingerprint: 'fp1',
|
|
142
|
+
extensionInstalled: true,
|
|
143
|
+
extensionBrowsers: ['Chrome'],
|
|
144
|
+
shellFiles: [],
|
|
145
|
+
configs: [],
|
|
146
|
+
recommendedToken: 'abc123',
|
|
147
|
+
recommendedFingerprint: 'fp1',
|
|
148
|
+
connectivity: { ok: true, durationMs: 1234 },
|
|
149
|
+
warnings: [],
|
|
150
|
+
issues: [],
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('renders connectivity WARN when not tested', () => {
|
|
157
|
+
const text = strip(renderBrowserDoctorReport({
|
|
158
|
+
envToken: 'abc123',
|
|
159
|
+
envFingerprint: 'fp1',
|
|
160
|
+
extensionToken: 'abc123',
|
|
161
|
+
extensionFingerprint: 'fp1',
|
|
162
|
+
extensionInstalled: true,
|
|
163
|
+
extensionBrowsers: ['Chrome'],
|
|
164
|
+
shellFiles: [],
|
|
165
|
+
configs: [],
|
|
166
|
+
recommendedToken: 'abc123',
|
|
167
|
+
recommendedFingerprint: 'fp1',
|
|
168
|
+
warnings: [],
|
|
169
|
+
issues: [],
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
|
173
|
+
});
|
|
129
174
|
});
|
|
130
175
|
|
package/src/doctor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
7
|
import chalk from 'chalk';
|
|
@@ -16,6 +16,7 @@ const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'
|
|
|
16
16
|
export type DoctorOptions = {
|
|
17
17
|
fix?: boolean;
|
|
18
18
|
yes?: boolean;
|
|
19
|
+
live?: boolean;
|
|
19
20
|
shellRc?: string;
|
|
20
21
|
configPaths?: string[];
|
|
21
22
|
token?: string;
|
|
@@ -41,16 +42,25 @@ export type McpConfigStatus = {
|
|
|
41
42
|
parseError?: string;
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
export type ConnectivityResult = {
|
|
46
|
+
ok: boolean;
|
|
47
|
+
error?: string;
|
|
48
|
+
durationMs: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
44
51
|
export type DoctorReport = {
|
|
45
52
|
cliVersion?: string;
|
|
46
53
|
envToken: string | null;
|
|
47
54
|
envFingerprint: string | null;
|
|
48
55
|
extensionToken: string | null;
|
|
49
56
|
extensionFingerprint: string | null;
|
|
57
|
+
extensionInstalled: boolean;
|
|
58
|
+
extensionBrowsers: string[];
|
|
50
59
|
shellFiles: ShellFileStatus[];
|
|
51
60
|
configs: McpConfigStatus[];
|
|
52
61
|
recommendedToken: string | null;
|
|
53
62
|
recommendedFingerprint: string | null;
|
|
63
|
+
connectivity?: ConnectivityResult;
|
|
54
64
|
warnings: string[];
|
|
55
65
|
issues: string[];
|
|
56
66
|
};
|
|
@@ -147,7 +157,7 @@ function readJsonConfigToken(content: string): string | null {
|
|
|
147
157
|
function readTokenFromJsonObject(parsed: any): string | null {
|
|
148
158
|
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
149
159
|
if (typeof direct === 'string' && direct) return direct;
|
|
150
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.
|
|
160
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
151
161
|
if (typeof opencode === 'string' && opencode) return opencode;
|
|
152
162
|
return null;
|
|
153
163
|
}
|
|
@@ -168,8 +178,8 @@ export function upsertJsonConfigToken(content: string, token: string): string {
|
|
|
168
178
|
enabled: true,
|
|
169
179
|
type: 'local',
|
|
170
180
|
};
|
|
171
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
172
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
181
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
|
182
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
173
183
|
}
|
|
174
184
|
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
175
185
|
}
|
|
@@ -256,8 +266,11 @@ function readConfigStatus(filePath: string): McpConfigStatus {
|
|
|
256
266
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
257
267
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
258
268
|
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
269
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
270
|
+
* extension ID near base64url token values. This works reliably across
|
|
271
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
272
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
273
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
261
274
|
*/
|
|
262
275
|
export function discoverExtensionToken(): string | null {
|
|
263
276
|
const home = os.homedir();
|
|
@@ -286,7 +299,6 @@ export function discoverExtensionToken(): string | null {
|
|
|
286
299
|
}
|
|
287
300
|
|
|
288
301
|
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
289
|
-
// Token is 43 chars of base64url (from 32 random bytes)
|
|
290
302
|
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
291
303
|
|
|
292
304
|
for (const base of bases) {
|
|
@@ -294,14 +306,6 @@ export function discoverExtensionToken(): string | null {
|
|
|
294
306
|
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
295
307
|
if (!fileExists(dir)) continue;
|
|
296
308
|
|
|
297
|
-
// Fast path: use strings + grep to find candidate files and extract token
|
|
298
|
-
if (platform !== 'win32') {
|
|
299
|
-
const token = extractTokenViaStrings(dir, tokenRe);
|
|
300
|
-
if (token) return token;
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Slow path (Windows): read binary files directly
|
|
305
309
|
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
306
310
|
if (token) return token;
|
|
307
311
|
}
|
|
@@ -310,39 +314,20 @@ export function discoverExtensionToken(): string | null {
|
|
|
310
314
|
return null;
|
|
311
315
|
}
|
|
312
316
|
|
|
313
|
-
function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
|
|
314
|
-
try {
|
|
315
|
-
// Single shell pipeline: for each LevelDB file, extract strings, find lines
|
|
316
|
-
// after the extension ID, and filter for base64url token pattern.
|
|
317
|
-
//
|
|
318
|
-
// LevelDB `strings` output for the extension's auth-token entry:
|
|
319
|
-
// auth-token ← key name
|
|
320
|
-
// 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
|
|
321
|
-
// hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
|
|
322
|
-
//
|
|
323
|
-
// We get the line immediately after any EXTENSION_ID mention and check
|
|
324
|
-
// if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
|
|
325
|
-
const shellDir = dir.replace(/'/g, "'\\''");
|
|
326
|
-
const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
|
|
327
|
-
`[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
|
|
328
|
-
`grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
329
|
-
`grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
330
|
-
`grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
|
|
331
|
-
`done 2>/dev/null`;
|
|
332
|
-
const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
333
|
-
|
|
334
|
-
// Take the first non-empty line
|
|
335
|
-
for (const line of result.split('\n')) {
|
|
336
|
-
const token = line.trim();
|
|
337
|
-
if (token && validateBase64urlToken(token)) return token;
|
|
338
|
-
}
|
|
339
|
-
} catch {}
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
317
|
function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
|
|
318
|
+
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
319
|
+
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
320
|
+
// search for a short prefix of the extension ID that reliably appears as
|
|
321
|
+
// contiguous bytes, then scan a window around each match for a base64url
|
|
322
|
+
// token value.
|
|
323
|
+
//
|
|
324
|
+
// Observed LevelDB layout near the auth-token entry:
|
|
325
|
+
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
326
|
+
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
327
|
+
//
|
|
328
|
+
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
344
329
|
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
345
|
-
const
|
|
330
|
+
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
346
331
|
|
|
347
332
|
let files: string[];
|
|
348
333
|
try {
|
|
@@ -351,7 +336,7 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
|
|
351
336
|
.map(f => path.join(dir, f));
|
|
352
337
|
} catch { return null; }
|
|
353
338
|
|
|
354
|
-
// Sort by mtime descending
|
|
339
|
+
// Sort by mtime descending so we find the freshest token first
|
|
355
340
|
files.sort((a, b) => {
|
|
356
341
|
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
|
|
357
342
|
});
|
|
@@ -360,14 +345,30 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
|
|
|
360
345
|
let data: Buffer;
|
|
361
346
|
try { data = fs.readFileSync(file); } catch { continue; }
|
|
362
347
|
|
|
363
|
-
// Quick check:
|
|
364
|
-
|
|
365
|
-
if (extPos === -1) continue;
|
|
366
|
-
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
|
367
|
-
if (keyPos === -1) continue;
|
|
348
|
+
// Quick check: file must contain at least the prefix
|
|
349
|
+
if (data.indexOf(extIdPrefix) === -1) continue;
|
|
368
350
|
|
|
369
|
-
//
|
|
351
|
+
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
352
|
+
// for base64url tokens within a 500-byte window
|
|
370
353
|
let idx = 0;
|
|
354
|
+
while (true) {
|
|
355
|
+
const pos = data.indexOf(extIdPrefix, idx);
|
|
356
|
+
if (pos === -1) break;
|
|
357
|
+
|
|
358
|
+
const scanStart = pos;
|
|
359
|
+
const scanEnd = Math.min(data.length, pos + 500);
|
|
360
|
+
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
361
|
+
const m = window.match(tokenRe);
|
|
362
|
+
if (m && validateBase64urlToken(m[1])) {
|
|
363
|
+
// Make sure this isn't another extension ID that happens to match
|
|
364
|
+
if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
|
|
365
|
+
}
|
|
366
|
+
idx = pos + 1;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
370
|
+
const keyBuf = Buffer.from('auth-token');
|
|
371
|
+
idx = 0;
|
|
371
372
|
while (true) {
|
|
372
373
|
const kp = data.indexOf(keyBuf, idx);
|
|
373
374
|
if (kp === -1) break;
|
|
@@ -393,6 +394,69 @@ function validateBase64urlToken(token: string): boolean {
|
|
|
393
394
|
}
|
|
394
395
|
|
|
395
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
399
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
400
|
+
*/
|
|
401
|
+
export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
|
|
402
|
+
const home = os.homedir();
|
|
403
|
+
const platform = os.platform();
|
|
404
|
+
const browserDirs: Array<{ name: string; base: string }> = [];
|
|
405
|
+
|
|
406
|
+
if (platform === 'darwin') {
|
|
407
|
+
browserDirs.push(
|
|
408
|
+
{ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
|
|
409
|
+
{ name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
|
|
410
|
+
{ name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
|
|
411
|
+
{ name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
|
|
412
|
+
);
|
|
413
|
+
} else if (platform === 'linux') {
|
|
414
|
+
browserDirs.push(
|
|
415
|
+
{ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
|
|
416
|
+
{ name: 'Chromium', base: path.join(home, '.config', 'chromium') },
|
|
417
|
+
{ name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
|
|
418
|
+
);
|
|
419
|
+
} else if (platform === 'win32') {
|
|
420
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
421
|
+
browserDirs.push(
|
|
422
|
+
{ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
|
|
423
|
+
{ name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
428
|
+
const foundBrowsers: string[] = [];
|
|
429
|
+
|
|
430
|
+
for (const { name, base } of browserDirs) {
|
|
431
|
+
for (const profile of profiles) {
|
|
432
|
+
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
433
|
+
if (fileExists(extDir)) {
|
|
434
|
+
foundBrowsers.push(name);
|
|
435
|
+
break; // one match per browser is enough
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
445
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
446
|
+
*/
|
|
447
|
+
export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
|
|
448
|
+
const timeout = opts?.timeout ?? 8;
|
|
449
|
+
const start = Date.now();
|
|
450
|
+
try {
|
|
451
|
+
const mcp = new PlaywrightMCP();
|
|
452
|
+
await mcp.connect({ timeout });
|
|
453
|
+
await mcp.close();
|
|
454
|
+
return { ok: true, durationMs: Date.now() - start };
|
|
455
|
+
} catch (err: any) {
|
|
456
|
+
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
396
460
|
export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
|
|
397
461
|
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
398
462
|
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
@@ -418,24 +482,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
|
|
|
418
482
|
const uniqueTokens = [...new Set(allTokens)];
|
|
419
483
|
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
420
484
|
|
|
485
|
+
// Check extension installation
|
|
486
|
+
const extInstall = checkExtensionInstalled();
|
|
487
|
+
|
|
488
|
+
// Connectivity test (only when --live)
|
|
489
|
+
let connectivity: ConnectivityResult | undefined;
|
|
490
|
+
if (opts.live) {
|
|
491
|
+
connectivity = await checkTokenConnectivity();
|
|
492
|
+
}
|
|
493
|
+
|
|
421
494
|
const report: DoctorReport = {
|
|
422
495
|
cliVersion: opts.cliVersion,
|
|
423
496
|
envToken,
|
|
424
497
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
425
498
|
extensionToken,
|
|
426
499
|
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
500
|
+
extensionInstalled: extInstall.installed,
|
|
501
|
+
extensionBrowsers: extInstall.browsers,
|
|
427
502
|
shellFiles,
|
|
428
503
|
configs,
|
|
429
504
|
recommendedToken,
|
|
430
505
|
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
506
|
+
connectivity,
|
|
431
507
|
warnings: [],
|
|
432
508
|
issues: [],
|
|
433
509
|
};
|
|
434
510
|
|
|
511
|
+
if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
435
512
|
if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
436
513
|
if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
|
|
437
514
|
if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
438
515
|
if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
516
|
+
if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
439
517
|
for (const config of configs) {
|
|
440
518
|
if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
441
519
|
}
|
|
@@ -456,6 +534,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
456
534
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
457
535
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
458
536
|
|
|
537
|
+
const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
538
|
+
const installDetail = report.extensionInstalled
|
|
539
|
+
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
540
|
+
: 'Extension not installed in any browser';
|
|
541
|
+
lines.push(statusLine(installStatus, installDetail));
|
|
542
|
+
|
|
459
543
|
const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
460
544
|
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
461
545
|
|
|
@@ -489,6 +573,18 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
|
|
|
489
573
|
}
|
|
490
574
|
if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
491
575
|
lines.push('');
|
|
576
|
+
|
|
577
|
+
// Connectivity result
|
|
578
|
+
if (report.connectivity) {
|
|
579
|
+
const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
|
580
|
+
const connDetail = report.connectivity.ok
|
|
581
|
+
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
582
|
+
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
|
583
|
+
lines.push(statusLine(connStatus, connDetail));
|
|
584
|
+
} else {
|
|
585
|
+
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
|
586
|
+
}
|
|
587
|
+
|
|
492
588
|
lines.push(statusLine(
|
|
493
589
|
hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
|
|
494
590
|
`Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
|
package/src/main.ts
CHANGED
|
@@ -100,12 +100,13 @@ program.command('doctor')
|
|
|
100
100
|
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
|
101
101
|
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
|
102
102
|
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
|
103
|
+
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
|
103
104
|
.option('--shell-rc <path>', 'Shell startup file to update')
|
|
104
105
|
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
|
105
106
|
.action(async (opts) => {
|
|
106
107
|
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
|
107
108
|
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
|
|
108
|
-
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
109
|
+
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
109
110
|
console.log(renderBrowserDoctorReport(report));
|
|
110
111
|
if (opts.fix) {
|
|
111
112
|
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
site: reddit
|
|
2
|
-
name: read
|
|
3
|
-
description: Read a Reddit post and its comments
|
|
4
|
-
domain: reddit.com
|
|
5
|
-
strategy: cookie
|
|
6
|
-
browser: true
|
|
7
|
-
|
|
8
|
-
args:
|
|
9
|
-
post_id:
|
|
10
|
-
type: string
|
|
11
|
-
required: true
|
|
12
|
-
description: "Post ID (e.g. 1abc123) or full URL"
|
|
13
|
-
sort:
|
|
14
|
-
type: string
|
|
15
|
-
default: best
|
|
16
|
-
description: "Comment sort: best, top, new, controversial, old, qa"
|
|
17
|
-
limit:
|
|
18
|
-
type: int
|
|
19
|
-
default: 25
|
|
20
|
-
description: Number of top-level comments to fetch
|
|
21
|
-
|
|
22
|
-
columns: [type, author, score, text]
|
|
23
|
-
|
|
24
|
-
pipeline:
|
|
25
|
-
- navigate: https://www.reddit.com
|
|
26
|
-
- evaluate: |
|
|
27
|
-
(async () => {
|
|
28
|
-
let postId = ${{ args.post_id | json }};
|
|
29
|
-
const urlMatch = postId.match(/comments\/([a-z0-9]+)/);
|
|
30
|
-
if (urlMatch) postId = urlMatch[1];
|
|
31
|
-
|
|
32
|
-
const sort = ${{ args.sort | json }};
|
|
33
|
-
const limit = ${{ args.limit }};
|
|
34
|
-
const res = await fetch('/comments/' + postId + '.json?sort=' + sort + '&limit=' + limit + '&raw_json=1', {
|
|
35
|
-
credentials: 'include'
|
|
36
|
-
});
|
|
37
|
-
const data = await res.json();
|
|
38
|
-
if (!Array.isArray(data) || data.length < 1) return [];
|
|
39
|
-
|
|
40
|
-
const results = [];
|
|
41
|
-
|
|
42
|
-
// First element: post itself
|
|
43
|
-
const post = data[0]?.data?.children?.[0]?.data;
|
|
44
|
-
if (post) {
|
|
45
|
-
let body = post.selftext || '';
|
|
46
|
-
if (body.length > 2000) body = body.slice(0, 2000) + '\n... [truncated]';
|
|
47
|
-
results.push({
|
|
48
|
-
type: '📰 POST',
|
|
49
|
-
author: post.author,
|
|
50
|
-
score: post.score,
|
|
51
|
-
text: post.title + (body ? '\n\n' + body : '') + (post.url && !post.is_self ? '\n🔗 ' + post.url : ''),
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Second element: comments
|
|
56
|
-
const comments = data[1]?.data?.children || [];
|
|
57
|
-
for (const c of comments) {
|
|
58
|
-
if (c.kind !== 't1') continue;
|
|
59
|
-
const d = c.data;
|
|
60
|
-
let body = d.body || '';
|
|
61
|
-
if (body.length > 500) body = body.slice(0, 500) + '...';
|
|
62
|
-
results.push({
|
|
63
|
-
type: '💬 COMMENT',
|
|
64
|
-
author: d.author || '[deleted]',
|
|
65
|
-
score: d.score || 0,
|
|
66
|
-
text: body,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return results;
|
|
71
|
-
})()
|
|
72
|
-
- map:
|
|
73
|
-
type: ${{ item.type }}
|
|
74
|
-
author: ${{ item.author }}
|
|
75
|
-
score: ${{ item.score }}
|
|
76
|
-
text: ${{ item.text }}
|