@jackwener/opencli 1.5.1 → 1.5.2
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/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +28 -2
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +4 -5
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/v2ex/hot.yaml +3 -17
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/daemon.js +1 -0
- package/dist/doctor.js +7 -3
- package/dist/execution.js +0 -6
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +1 -1
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +25 -2
- package/src/browser.test.ts +6 -0
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/v2ex/hot.yaml +3 -17
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/daemon.ts +1 -0
- package/src/doctor.ts +9 -5
- package/src/execution.ts +0 -9
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
package/dist/browser/discover.js
CHANGED
|
@@ -15,13 +15,17 @@ export async function checkDaemonStatus(opts) {
|
|
|
15
15
|
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
16
16
|
const controller = new AbortController();
|
|
17
17
|
const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
20
|
+
headers: { 'X-OpenCLI': '1' },
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
});
|
|
23
|
+
const data = await res.json();
|
|
24
|
+
return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
25
29
|
}
|
|
26
30
|
catch {
|
|
27
31
|
return { running: false, extensionConnected: false };
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapsho
|
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
|
13
13
|
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
|
14
14
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
15
|
+
import { isRetryableSettleError } from './page.js';
|
|
15
16
|
import { withTimeoutMs } from '../runtime.js';
|
|
16
17
|
export declare const __test__: {
|
|
17
18
|
extractTabEntries: typeof extractTabEntries;
|
|
@@ -20,4 +21,5 @@ export declare const __test__: {
|
|
|
20
21
|
withTimeoutMs: typeof withTimeoutMs;
|
|
21
22
|
selectCDPTarget: (targets: import("./cdp.js").CDPTarget[]) => import("./cdp.js").CDPTarget | undefined;
|
|
22
23
|
scoreCDPTarget: (target: import("./cdp.js").CDPTarget, preferredPattern?: RegExp) => number;
|
|
24
|
+
isRetryableSettleError: typeof isRetryableSettleError;
|
|
23
25
|
};
|
package/dist/browser/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapsho
|
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
|
13
13
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
14
|
import { __test__ as cdpTest } from './cdp.js';
|
|
15
|
+
import { isRetryableSettleError } from './page.js';
|
|
15
16
|
import { withTimeoutMs } from '../runtime.js';
|
|
16
17
|
export const __test__ = {
|
|
17
18
|
extractTabEntries,
|
|
@@ -20,4 +21,5 @@ export const __test__ = {
|
|
|
20
21
|
withTimeoutMs,
|
|
21
22
|
selectCDPTarget: cdpTest.selectCDPTarget,
|
|
22
23
|
scoreCDPTarget: cdpTest.scoreCDPTarget,
|
|
24
|
+
isRetryableSettleError,
|
|
23
25
|
};
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* chrome-extension:// tab that can't be debugged.
|
|
11
11
|
*/
|
|
12
12
|
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
|
13
|
+
export declare function isRetryableSettleError(err: unknown): boolean;
|
|
13
14
|
/**
|
|
14
15
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
15
16
|
*/
|
package/dist/browser/page.js
CHANGED
|
@@ -16,6 +16,11 @@ import { saveBase64ToFile } from '../utils.js';
|
|
|
16
16
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
17
17
|
import { generateStealthJs } from './stealth.js';
|
|
18
18
|
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
19
|
+
export function isRetryableSettleError(err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
return message.includes('Inspected target navigated or closed')
|
|
22
|
+
|| (message.includes('-32000') && message.toLowerCase().includes('target'));
|
|
23
|
+
}
|
|
19
24
|
/**
|
|
20
25
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
21
26
|
*/
|
|
@@ -63,10 +68,31 @@ export class Page {
|
|
|
63
68
|
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
64
69
|
if (options?.waitUntil !== 'none') {
|
|
65
70
|
const maxMs = options?.settleMs ?? 1000;
|
|
66
|
-
|
|
71
|
+
const settleOpts = {
|
|
67
72
|
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
|
68
73
|
...this._cmdOpts(),
|
|
69
|
-
}
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
await sendCommand('exec', settleOpts);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (!isRetryableSettleError(err))
|
|
80
|
+
throw err;
|
|
81
|
+
// SPA client-side redirects can invalidate the CDP target after
|
|
82
|
+
// chrome.tabs reports 'complete'. Wait briefly for the new document
|
|
83
|
+
// to load, then retry the settle probe once.
|
|
84
|
+
try {
|
|
85
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
86
|
+
await sendCommand('exec', settleOpts);
|
|
87
|
+
}
|
|
88
|
+
catch (retryErr) {
|
|
89
|
+
if (!isRetryableSettleError(retryErr))
|
|
90
|
+
throw retryErr;
|
|
91
|
+
// Retry also failed — give up silently. Settle is best-effort
|
|
92
|
+
// after successful navigation; the next real command will surface
|
|
93
|
+
// any persistent target error immediately.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
70
96
|
}
|
|
71
97
|
}
|
|
72
98
|
async getCurrentUrl() {
|
package/dist/browser.test.js
CHANGED
|
@@ -31,6 +31,11 @@ describe('browser helpers', () => {
|
|
|
31
31
|
it('times out slow promises', async () => {
|
|
32
32
|
await expect(__test__.withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
|
|
33
33
|
});
|
|
34
|
+
it('retries settle only for target-invalidated errors', () => {
|
|
35
|
+
expect(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
|
|
36
|
+
expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
|
|
37
|
+
expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
|
|
38
|
+
});
|
|
34
39
|
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
|
35
40
|
const target = __test__.selectCDPTarget([
|
|
36
41
|
{
|
package/dist/cli-manifest.json
CHANGED
|
@@ -10900,7 +10900,7 @@
|
|
|
10900
10900
|
"description": "V2EX 热门话题",
|
|
10901
10901
|
"domain": "www.v2ex.com",
|
|
10902
10902
|
"strategy": "public",
|
|
10903
|
-
"browser":
|
|
10903
|
+
"browser": false,
|
|
10904
10904
|
"args": [
|
|
10905
10905
|
{
|
|
10906
10906
|
"name": "limit",
|
|
@@ -10918,10 +10918,9 @@
|
|
|
10918
10918
|
],
|
|
10919
10919
|
"pipeline": [
|
|
10920
10920
|
{
|
|
10921
|
-
"
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
"evaluate": "(async () => {\n const response = await fetch('/api/topics/hot.json', {\n credentials: 'include',\n headers: {\n accept: 'application/json, text/plain, */*',\n 'x-requested-with': 'XMLHttpRequest',\n },\n });\n if (!response.ok) {\n throw new Error(`V2EX hot API request failed: ${response.status}`);\n }\n return await response.json();\n})()\n"
|
|
10921
|
+
"fetch": {
|
|
10922
|
+
"url": "https://www.v2ex.com/api/topics/hot.json"
|
|
10923
|
+
}
|
|
10925
10924
|
},
|
|
10926
10925
|
{
|
|
10927
10926
|
"map": {
|
|
@@ -31,13 +31,14 @@ describe('apple-podcasts search command', () => {
|
|
|
31
31
|
});
|
|
32
32
|
expect(fetchMock).toHaveBeenCalledWith('https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5');
|
|
33
33
|
expect(result).toEqual([
|
|
34
|
-
{
|
|
34
|
+
expect.objectContaining({
|
|
35
35
|
id: 42,
|
|
36
36
|
title: 'Machine Learning Guide',
|
|
37
37
|
author: 'OpenCLI',
|
|
38
38
|
episodes: 12,
|
|
39
39
|
genre: 'Technology',
|
|
40
|
-
|
|
40
|
+
url: '',
|
|
41
|
+
}),
|
|
41
42
|
]);
|
|
42
43
|
});
|
|
43
44
|
});
|
|
@@ -45,6 +46,26 @@ describe('apple-podcasts top command', () => {
|
|
|
45
46
|
beforeEach(() => {
|
|
46
47
|
vi.restoreAllMocks();
|
|
47
48
|
});
|
|
49
|
+
it('adds a timeout signal to chart fetches', async () => {
|
|
50
|
+
const cmd = getRegistry().get('apple-podcasts/top');
|
|
51
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
52
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
53
|
+
ok: true,
|
|
54
|
+
json: () => Promise.resolve({
|
|
55
|
+
feed: {
|
|
56
|
+
results: [
|
|
57
|
+
{ id: '100', name: 'Top Show', artistName: 'Host A' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
63
|
+
await cmd.func(null, { country: 'US', limit: 1 });
|
|
64
|
+
const [, options] = fetchMock.mock.calls[0] ?? [];
|
|
65
|
+
expect(options).toBeDefined();
|
|
66
|
+
expect(options.signal).toBeDefined();
|
|
67
|
+
expect(options.signal).toHaveProperty('aborted', false);
|
|
68
|
+
});
|
|
48
69
|
it('uses the canonical Apple charts host and maps ranked results', async () => {
|
|
49
70
|
const cmd = getRegistry().get('apple-podcasts/top');
|
|
50
71
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -61,7 +82,9 @@ describe('apple-podcasts top command', () => {
|
|
|
61
82
|
});
|
|
62
83
|
vi.stubGlobal('fetch', fetchMock);
|
|
63
84
|
const result = await cmd.func(null, { country: 'US', limit: 2 });
|
|
64
|
-
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json'
|
|
85
|
+
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
|
|
86
|
+
signal: expect.any(Object),
|
|
87
|
+
}));
|
|
65
88
|
expect(result).toEqual([
|
|
66
89
|
{ rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
|
|
67
90
|
{ rank: 2, title: 'Second Show', author: 'Host B', id: '101' },
|
|
@@ -2,6 +2,7 @@ import { cli, Strategy } from '../../registry.js';
|
|
|
2
2
|
import { CliError } from '../../errors.js';
|
|
3
3
|
// Apple Marketing Tools RSS API — public, no key required
|
|
4
4
|
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
|
|
5
|
+
const CHARTS_TIMEOUT_MS = 15_000;
|
|
5
6
|
cli({
|
|
6
7
|
site: 'apple-podcasts',
|
|
7
8
|
name: 'top',
|
|
@@ -19,7 +20,9 @@ cli({
|
|
|
19
20
|
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
|
20
21
|
let resp;
|
|
21
22
|
try {
|
|
22
|
-
resp = await fetch(url
|
|
23
|
+
resp = await fetch(url, {
|
|
24
|
+
signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
|
|
25
|
+
});
|
|
23
26
|
}
|
|
24
27
|
catch (error) {
|
|
25
28
|
const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
|
package/dist/clis/v2ex/hot.yaml
CHANGED
|
@@ -3,7 +3,7 @@ name: hot
|
|
|
3
3
|
description: V2EX 热门话题
|
|
4
4
|
domain: www.v2ex.com
|
|
5
5
|
strategy: public
|
|
6
|
-
browser:
|
|
6
|
+
browser: false
|
|
7
7
|
|
|
8
8
|
args:
|
|
9
9
|
limit:
|
|
@@ -12,22 +12,8 @@ args:
|
|
|
12
12
|
description: Number of topics
|
|
13
13
|
|
|
14
14
|
pipeline:
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
- evaluate: |
|
|
18
|
-
(async () => {
|
|
19
|
-
const response = await fetch('/api/topics/hot.json', {
|
|
20
|
-
credentials: 'include',
|
|
21
|
-
headers: {
|
|
22
|
-
accept: 'application/json, text/plain, */*',
|
|
23
|
-
'x-requested-with': 'XMLHttpRequest',
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
throw new Error(`V2EX hot API request failed: ${response.status}`);
|
|
28
|
-
}
|
|
29
|
-
return await response.json();
|
|
30
|
-
})()
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/topics/hot.json
|
|
31
17
|
|
|
32
18
|
- map:
|
|
33
19
|
rank: ${{ index + 1 }}
|
|
@@ -1,5 +1,120 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
2
3
|
import { fetchPrivateApi } from './utils.js';
|
|
4
|
+
const WEREAD_DOMAIN = 'weread.qq.com';
|
|
5
|
+
const WEREAD_SHELF_URL = `https://${WEREAD_DOMAIN}/web/shelf`;
|
|
6
|
+
function normalizeShelfLimit(limit) {
|
|
7
|
+
if (!Number.isFinite(limit))
|
|
8
|
+
return 0;
|
|
9
|
+
return Math.max(0, Math.trunc(limit));
|
|
10
|
+
}
|
|
11
|
+
function normalizePrivateApiRows(data, limit) {
|
|
12
|
+
const books = data?.books ?? [];
|
|
13
|
+
return books.slice(0, limit).map((item) => ({
|
|
14
|
+
title: item.bookInfo?.title ?? item.title ?? '',
|
|
15
|
+
author: item.bookInfo?.author ?? item.author ?? '',
|
|
16
|
+
// TODO: readingProgress field name from community docs, verify with real API response
|
|
17
|
+
progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
|
|
18
|
+
bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
function normalizeWebShelfRows(snapshot, limit) {
|
|
22
|
+
if (limit <= 0)
|
|
23
|
+
return [];
|
|
24
|
+
const bookById = new Map();
|
|
25
|
+
for (const book of snapshot.rawBooks) {
|
|
26
|
+
const bookId = String(book?.bookId || '').trim();
|
|
27
|
+
if (!bookId)
|
|
28
|
+
continue;
|
|
29
|
+
bookById.set(bookId, book);
|
|
30
|
+
}
|
|
31
|
+
const orderedBookIds = snapshot.shelfIndexes
|
|
32
|
+
.filter((entry) => String(entry?.role || 'book') === 'book')
|
|
33
|
+
.sort((left, right) => Number(left?.idx ?? Number.MAX_SAFE_INTEGER) - Number(right?.idx ?? Number.MAX_SAFE_INTEGER))
|
|
34
|
+
.map((entry) => String(entry?.bookId || '').trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
const fallbackOrder = snapshot.rawBooks
|
|
37
|
+
.map((book) => String(book?.bookId || '').trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
const orderedUniqueBookIds = Array.from(new Set([
|
|
40
|
+
...orderedBookIds,
|
|
41
|
+
...fallbackOrder,
|
|
42
|
+
]));
|
|
43
|
+
return orderedUniqueBookIds
|
|
44
|
+
.map((bookId) => {
|
|
45
|
+
const book = bookById.get(bookId);
|
|
46
|
+
if (!book)
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
title: String(book.title || '').trim(),
|
|
50
|
+
author: String(book.author || '').trim(),
|
|
51
|
+
progress: '-',
|
|
52
|
+
bookId,
|
|
53
|
+
};
|
|
54
|
+
})
|
|
55
|
+
.filter((item) => Boolean(item && (item.title || item.bookId)))
|
|
56
|
+
.slice(0, limit);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the structured shelf cache from the web shelf page.
|
|
60
|
+
* The page hydrates localStorage with raw book data plus shelf ordering.
|
|
61
|
+
*/
|
|
62
|
+
async function loadWebShelfSnapshot(page) {
|
|
63
|
+
await page.goto(WEREAD_SHELF_URL);
|
|
64
|
+
const cookies = await page.getCookies({ domain: WEREAD_DOMAIN });
|
|
65
|
+
const currentVid = String(cookies.find((cookie) => cookie.name === 'wr_vid')?.value || '').trim();
|
|
66
|
+
if (!currentVid) {
|
|
67
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
68
|
+
}
|
|
69
|
+
const rawBooksKey = `shelf:rawBooks:${currentVid}`;
|
|
70
|
+
const shelfIndexesKey = `shelf:shelfIndexes:${currentVid}`;
|
|
71
|
+
const result = await page.evaluate(`
|
|
72
|
+
(() => new Promise((resolve) => {
|
|
73
|
+
const deadline = Date.now() + 5000;
|
|
74
|
+
const rawBooksKey = ${JSON.stringify(rawBooksKey)};
|
|
75
|
+
const shelfIndexesKey = ${JSON.stringify(shelfIndexesKey)};
|
|
76
|
+
|
|
77
|
+
const readJson = (raw) => {
|
|
78
|
+
if (typeof raw !== 'string') return null;
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const poll = () => {
|
|
87
|
+
const rawBooksRaw = localStorage.getItem(rawBooksKey);
|
|
88
|
+
const shelfIndexesRaw = localStorage.getItem(shelfIndexesKey);
|
|
89
|
+
const rawBooks = readJson(rawBooksRaw);
|
|
90
|
+
const shelfIndexes = readJson(shelfIndexesRaw);
|
|
91
|
+
const cacheFound = Array.isArray(rawBooks);
|
|
92
|
+
|
|
93
|
+
if (cacheFound || Date.now() >= deadline) {
|
|
94
|
+
resolve({
|
|
95
|
+
cacheFound,
|
|
96
|
+
rawBooks: Array.isArray(rawBooks) ? rawBooks : [],
|
|
97
|
+
shelfIndexes: Array.isArray(shelfIndexes) ? shelfIndexes : [],
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setTimeout(poll, 100);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
poll();
|
|
106
|
+
}))
|
|
107
|
+
`);
|
|
108
|
+
if (!result || typeof result !== 'object') {
|
|
109
|
+
return { cacheFound: false, rawBooks: [], shelfIndexes: [] };
|
|
110
|
+
}
|
|
111
|
+
const snapshot = result;
|
|
112
|
+
return {
|
|
113
|
+
cacheFound: snapshot.cacheFound === true,
|
|
114
|
+
rawBooks: Array.isArray(snapshot.rawBooks) ? snapshot.rawBooks : [],
|
|
115
|
+
shelfIndexes: Array.isArray(snapshot.shelfIndexes) ? snapshot.shelfIndexes : [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
3
118
|
cli({
|
|
4
119
|
site: 'weread',
|
|
5
120
|
name: 'shelf',
|
|
@@ -11,14 +126,22 @@ cli({
|
|
|
11
126
|
],
|
|
12
127
|
columns: ['title', 'author', 'progress', 'bookId'],
|
|
13
128
|
func: async (page, args) => {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
129
|
+
const limit = normalizeShelfLimit(Number(args.limit));
|
|
130
|
+
if (limit <= 0)
|
|
131
|
+
return [];
|
|
132
|
+
try {
|
|
133
|
+
const data = await fetchPrivateApi(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
|
|
134
|
+
return normalizePrivateApiRows(data, limit);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!(error instanceof CliError) || error.code !== 'AUTH_REQUIRED') {
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
const snapshot = await loadWebShelfSnapshot(page);
|
|
141
|
+
if (!snapshot.cacheFound) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
return normalizeWebShelfRows(snapshot, limit);
|
|
145
|
+
}
|
|
23
146
|
},
|
|
24
147
|
});
|
|
@@ -9,9 +9,13 @@ import { CliError } from '../../errors.js';
|
|
|
9
9
|
const WEB_API = 'https://weread.qq.com/web';
|
|
10
10
|
const API = 'https://i.weread.qq.com';
|
|
11
11
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
12
|
+
const WEREAD_AUTH_ERRCODES = new Set([-2010, -2012]);
|
|
12
13
|
function buildCookieHeader(cookies) {
|
|
13
14
|
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ');
|
|
14
15
|
}
|
|
16
|
+
function isAuthErrorResponse(resp, data) {
|
|
17
|
+
return resp.status === 401 || WEREAD_AUTH_ERRCODES.has(Number(data?.errcode));
|
|
18
|
+
}
|
|
15
19
|
/**
|
|
16
20
|
* Fetch a public WeRead web endpoint (Node.js direct fetch).
|
|
17
21
|
* Used by search and ranking commands (browser: false).
|
|
@@ -69,7 +73,7 @@ export async function fetchPrivateApi(page, path, params) {
|
|
|
69
73
|
catch {
|
|
70
74
|
throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
|
|
71
75
|
}
|
|
72
|
-
if (resp
|
|
76
|
+
if (isAuthErrorResponse(resp, data)) {
|
|
73
77
|
throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
|
|
74
78
|
}
|
|
75
79
|
if (!resp.ok) {
|
package/dist/daemon.js
CHANGED
|
@@ -175,6 +175,7 @@ const wss = new WebSocketServer({
|
|
|
175
175
|
wss.on('connection', (ws) => {
|
|
176
176
|
console.error('[daemon] Extension connected');
|
|
177
177
|
extensionWs = ws;
|
|
178
|
+
extensionVersion = null; // cleared until hello message arrives
|
|
178
179
|
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
179
180
|
let missedPongs = 0;
|
|
180
181
|
const heartbeatInterval = setInterval(() => {
|
package/dist/doctor.js
CHANGED
|
@@ -65,9 +65,13 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
65
65
|
if (connectivity && !connectivity.ok) {
|
|
66
66
|
issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
67
67
|
}
|
|
68
|
-
if (status.extensionVersion && opts.cliVersion
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
if (status.extensionVersion && opts.cliVersion) {
|
|
69
|
+
const extMajor = status.extensionVersion.split('.')[0];
|
|
70
|
+
const cliMajor = opts.cliVersion.split('.')[0];
|
|
71
|
+
if (extMajor !== cliMajor) {
|
|
72
|
+
issues.push(`Extension major version mismatch: extension v${status.extensionVersion} ≠ CLI v${opts.cliVersion}\n` +
|
|
73
|
+
' Download the latest extension from: https://github.com/jackwener/opencli/releases');
|
|
74
|
+
}
|
|
71
75
|
}
|
|
72
76
|
return {
|
|
73
77
|
cliVersion: opts.cliVersion,
|
package/dist/execution.js
CHANGED
|
@@ -17,8 +17,6 @@ import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
|
17
17
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
18
18
|
import { emitHook } from './hooks.js';
|
|
19
19
|
import { checkDaemonStatus } from './browser/discover.js';
|
|
20
|
-
import { PKG_VERSION } from './version.js';
|
|
21
|
-
import chalk from 'chalk';
|
|
22
20
|
const _loadedModules = new Set();
|
|
23
21
|
export function coerceAndValidateArgs(cmdArgs, kwargs) {
|
|
24
22
|
const result = { ...kwargs };
|
|
@@ -161,10 +159,6 @@ export async function executeCommand(cmd, rawKwargs, debug = false) {
|
|
|
161
159
|
' 2. chrome://extensions → Developer Mode → Load unpacked\n' +
|
|
162
160
|
' Then run: opencli doctor');
|
|
163
161
|
}
|
|
164
|
-
// ── Version mismatch: warn but don't block ──
|
|
165
|
-
if (status.extensionVersion && status.extensionVersion !== PKG_VERSION) {
|
|
166
|
-
process.stderr.write(chalk.yellow(`⚠ Extension v${status.extensionVersion} ≠ CLI v${PKG_VERSION} — consider updating the extension.\n`));
|
|
167
|
-
}
|
|
168
162
|
ensureRequiredEnv(cmd);
|
|
169
163
|
const BrowserFactory = getBrowserFactory();
|
|
170
164
|
result = await browserSession(BrowserFactory, async (page) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
describe('extension manifest regression', () => {
|
|
5
|
+
it('keeps host permissions required by chrome.cookies.getAll', async () => {
|
|
6
|
+
const manifestPath = path.resolve(process.cwd(), 'extension', 'manifest.json');
|
|
7
|
+
const raw = await fs.readFile(manifestPath, 'utf8');
|
|
8
|
+
const manifest = JSON.parse(raw);
|
|
9
|
+
expect(manifest.permissions).toContain('cookies');
|
|
10
|
+
expect(manifest.host_permissions).toContain('<all_urls>');
|
|
11
|
+
});
|
|
12
|
+
});
|