@jackwener/opencli 1.7.5 → 1.7.6
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 +5 -2
- package/README.zh-CN.md +5 -2
- package/cli-manifest.json +77 -1
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +21 -1
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/utils.js +84 -1
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/base-page.d.ts +13 -3
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +12 -3
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +76 -3
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +630 -125
- package/dist/src/cli.test.js +794 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/types.d.ts +18 -3
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent cache for browser network captures.
|
|
3
|
+
*
|
|
4
|
+
* The live capture buffer (JS interceptor / daemon ring) can be cleared
|
|
5
|
+
* by navigation or lost between CLI invocations. Agents still need
|
|
6
|
+
* stable references to request bodies after running other commands,
|
|
7
|
+
* so every `browser network` call snapshots its results to disk.
|
|
8
|
+
*
|
|
9
|
+
* Layout: <cacheDir>/browser-network/<workspace>.json
|
|
10
|
+
* Entries expire after DEFAULT_TTL_MS (24h).
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_TTL_MS: number;
|
|
13
|
+
export interface CachedNetworkEntry {
|
|
14
|
+
key: string;
|
|
15
|
+
url: string;
|
|
16
|
+
method: string;
|
|
17
|
+
status: number;
|
|
18
|
+
/** Full body size in chars (may exceed stored body length when truncated). */
|
|
19
|
+
size: number;
|
|
20
|
+
ct: string;
|
|
21
|
+
body: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Truncation signals use snake_case so `--raw` (which emits cache entries
|
|
24
|
+
* verbatim) matches the agent-facing contract used by list / --detail.
|
|
25
|
+
*/
|
|
26
|
+
body_truncated?: boolean;
|
|
27
|
+
body_full_size?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface NetworkCacheFile {
|
|
30
|
+
version: 1;
|
|
31
|
+
workspace: string;
|
|
32
|
+
savedAt: string;
|
|
33
|
+
entries: CachedNetworkEntry[];
|
|
34
|
+
}
|
|
35
|
+
export declare function getCachePath(workspace: string, baseDir?: string): string;
|
|
36
|
+
export declare function saveNetworkCache(workspace: string, entries: CachedNetworkEntry[], baseDir?: string): void;
|
|
37
|
+
export interface LoadOptions {
|
|
38
|
+
baseDir?: string;
|
|
39
|
+
ttlMs?: number;
|
|
40
|
+
now?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface LoadResult {
|
|
43
|
+
status: 'ok' | 'missing' | 'expired' | 'corrupt';
|
|
44
|
+
file?: NetworkCacheFile;
|
|
45
|
+
ageMs?: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function loadNetworkCache(workspace: string, opts?: LoadOptions): LoadResult;
|
|
48
|
+
export declare function findEntry(file: NetworkCacheFile, key: string): CachedNetworkEntry | null;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent cache for browser network captures.
|
|
3
|
+
*
|
|
4
|
+
* The live capture buffer (JS interceptor / daemon ring) can be cleared
|
|
5
|
+
* by navigation or lost between CLI invocations. Agents still need
|
|
6
|
+
* stable references to request bodies after running other commands,
|
|
7
|
+
* so every `browser network` call snapshots its results to disk.
|
|
8
|
+
*
|
|
9
|
+
* Layout: <cacheDir>/browser-network/<workspace>.json
|
|
10
|
+
* Entries expire after DEFAULT_TTL_MS (24h).
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
function getDefaultCacheDir() {
|
|
17
|
+
return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
|
|
18
|
+
}
|
|
19
|
+
export function getCachePath(workspace, baseDir = getDefaultCacheDir()) {
|
|
20
|
+
const safe = workspace.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
|
21
|
+
return path.join(baseDir, 'browser-network', `${safe}.json`);
|
|
22
|
+
}
|
|
23
|
+
export function saveNetworkCache(workspace, entries, baseDir) {
|
|
24
|
+
const target = getCachePath(workspace, baseDir);
|
|
25
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
26
|
+
const payload = {
|
|
27
|
+
version: 1,
|
|
28
|
+
workspace,
|
|
29
|
+
savedAt: new Date().toISOString(),
|
|
30
|
+
entries,
|
|
31
|
+
};
|
|
32
|
+
fs.writeFileSync(target, JSON.stringify(payload), 'utf-8');
|
|
33
|
+
}
|
|
34
|
+
export function loadNetworkCache(workspace, opts = {}) {
|
|
35
|
+
const target = getCachePath(workspace, opts.baseDir);
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(target, 'utf-8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { status: 'missing' };
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
const obj = JSON.parse(raw);
|
|
46
|
+
if (!obj || obj.version !== 1 || !Array.isArray(obj.entries)) {
|
|
47
|
+
return { status: 'corrupt' };
|
|
48
|
+
}
|
|
49
|
+
parsed = obj;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { status: 'corrupt' };
|
|
53
|
+
}
|
|
54
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
55
|
+
const now = opts.now ?? Date.now();
|
|
56
|
+
const savedAt = Date.parse(parsed.savedAt);
|
|
57
|
+
if (!Number.isFinite(savedAt))
|
|
58
|
+
return { status: 'corrupt' };
|
|
59
|
+
const ageMs = now - savedAt;
|
|
60
|
+
if (ageMs > ttl)
|
|
61
|
+
return { status: 'expired', file: parsed, ageMs };
|
|
62
|
+
return { status: 'ok', file: parsed, ageMs };
|
|
63
|
+
}
|
|
64
|
+
export function findEntry(file, key) {
|
|
65
|
+
return file.entries.find((e) => e.key === key) ?? null;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { DEFAULT_TTL_MS, findEntry, getCachePath, loadNetworkCache, saveNetworkCache, } from './network-cache.js';
|
|
6
|
+
function makeEntry(key, body = { ok: true }) {
|
|
7
|
+
return { key, url: `https://x.com/${key}`, method: 'GET', status: 200, size: 2, ct: 'application/json', body };
|
|
8
|
+
}
|
|
9
|
+
describe('network-cache', () => {
|
|
10
|
+
let baseDir;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-netcache-'));
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(baseDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
it('sanitizes workspace names into safe filenames', () => {
|
|
18
|
+
const p = getCachePath('browser:default', baseDir);
|
|
19
|
+
expect(path.basename(p)).toBe('browser_default.json');
|
|
20
|
+
});
|
|
21
|
+
it('round-trips entries through save + load', () => {
|
|
22
|
+
saveNetworkCache('ws', [makeEntry('UserTweets'), makeEntry('UserByScreenName')], baseDir);
|
|
23
|
+
const res = loadNetworkCache('ws', { baseDir });
|
|
24
|
+
expect(res.status).toBe('ok');
|
|
25
|
+
expect(res.file?.entries).toHaveLength(2);
|
|
26
|
+
expect(res.file?.entries[0].key).toBe('UserTweets');
|
|
27
|
+
});
|
|
28
|
+
it('reports missing when cache file does not exist', () => {
|
|
29
|
+
expect(loadNetworkCache('nope', { baseDir }).status).toBe('missing');
|
|
30
|
+
});
|
|
31
|
+
it('reports expired when the cache is older than ttl', () => {
|
|
32
|
+
saveNetworkCache('ws', [makeEntry('A')], baseDir);
|
|
33
|
+
const future = Date.now() + DEFAULT_TTL_MS + 60_000;
|
|
34
|
+
const res = loadNetworkCache('ws', { baseDir, now: future });
|
|
35
|
+
expect(res.status).toBe('expired');
|
|
36
|
+
expect(res.file?.entries).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
it('reports corrupt for malformed json', () => {
|
|
39
|
+
const file = getCachePath('ws', baseDir);
|
|
40
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
fs.writeFileSync(file, '{not json');
|
|
42
|
+
expect(loadNetworkCache('ws', { baseDir }).status).toBe('corrupt');
|
|
43
|
+
});
|
|
44
|
+
it('reports corrupt for wrong schema version', () => {
|
|
45
|
+
const file = getCachePath('ws', baseDir);
|
|
46
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
47
|
+
fs.writeFileSync(file, JSON.stringify({ version: 0, entries: [] }));
|
|
48
|
+
expect(loadNetworkCache('ws', { baseDir }).status).toBe('corrupt');
|
|
49
|
+
});
|
|
50
|
+
it('findEntry returns matching entry or null', () => {
|
|
51
|
+
const file = {
|
|
52
|
+
version: 1, workspace: 'ws', savedAt: new Date().toISOString(),
|
|
53
|
+
entries: [makeEntry('A'), makeEntry('B')],
|
|
54
|
+
};
|
|
55
|
+
expect(findEntry(file, 'B')?.key).toBe('B');
|
|
56
|
+
expect(findEntry(file, 'missing')).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable keys for network capture entries.
|
|
3
|
+
*
|
|
4
|
+
* Agents reference entries by key (e.g. `UserTweets`, `GET api.x.com/1.1/home`)
|
|
5
|
+
* instead of array index, so the mapping survives new captures.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* GraphQL (URL contains `/graphql/`): key = operationName derived from URL path
|
|
9
|
+
* (the segment after a 22-char query id, or the last segment)
|
|
10
|
+
* Everything else: key = `METHOD host+pathname`
|
|
11
|
+
*
|
|
12
|
+
* On collision assignKeys suffixes duplicates as `base#2`, `base#3`, ... —
|
|
13
|
+
* the first occurrence stays bare (there is no `#1`).
|
|
14
|
+
*/
|
|
15
|
+
export interface KeyableRequest {
|
|
16
|
+
url: string;
|
|
17
|
+
method: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function deriveKey(req: KeyableRequest): string;
|
|
20
|
+
export declare function assignKeys<T extends KeyableRequest>(requests: T[]): Array<T & {
|
|
21
|
+
key: string;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable keys for network capture entries.
|
|
3
|
+
*
|
|
4
|
+
* Agents reference entries by key (e.g. `UserTweets`, `GET api.x.com/1.1/home`)
|
|
5
|
+
* instead of array index, so the mapping survives new captures.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* GraphQL (URL contains `/graphql/`): key = operationName derived from URL path
|
|
9
|
+
* (the segment after a 22-char query id, or the last segment)
|
|
10
|
+
* Everything else: key = `METHOD host+pathname`
|
|
11
|
+
*
|
|
12
|
+
* On collision assignKeys suffixes duplicates as `base#2`, `base#3`, ... —
|
|
13
|
+
* the first occurrence stays bare (there is no `#1`).
|
|
14
|
+
*/
|
|
15
|
+
export function deriveKey(req) {
|
|
16
|
+
const parsed = safeParseUrl(req.url);
|
|
17
|
+
if (!parsed)
|
|
18
|
+
return `${req.method.toUpperCase()} ${truncate(req.url, 120)}`;
|
|
19
|
+
const path = parsed.pathname;
|
|
20
|
+
if (path.includes('/graphql/')) {
|
|
21
|
+
const op = graphqlOperationName(path);
|
|
22
|
+
if (op)
|
|
23
|
+
return op;
|
|
24
|
+
}
|
|
25
|
+
return `${req.method.toUpperCase()} ${parsed.host}${path}`;
|
|
26
|
+
}
|
|
27
|
+
export function assignKeys(requests) {
|
|
28
|
+
const counts = new Map();
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const req of requests) {
|
|
31
|
+
const base = deriveKey(req);
|
|
32
|
+
const n = counts.get(base) ?? 0;
|
|
33
|
+
counts.set(base, n + 1);
|
|
34
|
+
const key = n === 0 ? base : `${base}#${n + 1}`;
|
|
35
|
+
out.push({ ...req, key });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function graphqlOperationName(pathname) {
|
|
40
|
+
// Patterns we've seen in the wild:
|
|
41
|
+
// /i/api/graphql/<queryId>/UserTweets
|
|
42
|
+
// /graphql/<queryId>/SomeOp
|
|
43
|
+
// /graphql/SomeOp (rare, no id)
|
|
44
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
45
|
+
const idx = segments.indexOf('graphql');
|
|
46
|
+
if (idx < 0)
|
|
47
|
+
return null;
|
|
48
|
+
const tail = segments.slice(idx + 1);
|
|
49
|
+
if (tail.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
if (tail.length === 1)
|
|
52
|
+
return tail[0];
|
|
53
|
+
// tail[0] is usually a query id; the operation name is the next segment.
|
|
54
|
+
return tail[1] || tail[0];
|
|
55
|
+
}
|
|
56
|
+
function safeParseUrl(url) {
|
|
57
|
+
try {
|
|
58
|
+
return new URL(url);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function truncate(s, max) {
|
|
65
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { assignKeys, deriveKey } from './network-key.js';
|
|
3
|
+
describe('deriveKey', () => {
|
|
4
|
+
it('extracts operationName from Twitter-style graphql URLs', () => {
|
|
5
|
+
expect(deriveKey({
|
|
6
|
+
method: 'GET',
|
|
7
|
+
url: 'https://x.com/i/api/graphql/6fWQaBPK51aGyC_VC7t9GQ/UserTweets?variables=...',
|
|
8
|
+
})).toBe('UserTweets');
|
|
9
|
+
});
|
|
10
|
+
it('handles graphql URLs without a query id', () => {
|
|
11
|
+
expect(deriveKey({
|
|
12
|
+
method: 'POST',
|
|
13
|
+
url: 'https://example.com/graphql/MyOp?vars=1',
|
|
14
|
+
})).toBe('MyOp');
|
|
15
|
+
});
|
|
16
|
+
it('uses METHOD host+pathname for REST calls', () => {
|
|
17
|
+
expect(deriveKey({
|
|
18
|
+
method: 'get',
|
|
19
|
+
url: 'https://api.example.com/v1/users?page=1',
|
|
20
|
+
})).toBe('GET api.example.com/v1/users');
|
|
21
|
+
});
|
|
22
|
+
it('falls back to truncated raw url when URL parsing fails', () => {
|
|
23
|
+
const key = deriveKey({ method: 'GET', url: 'not-a-valid-url' });
|
|
24
|
+
expect(key.startsWith('GET ')).toBe(true);
|
|
25
|
+
expect(key).toContain('not-a-valid-url');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('assignKeys', () => {
|
|
29
|
+
it('disambiguates collisions with #N suffixes', () => {
|
|
30
|
+
const out = assignKeys([
|
|
31
|
+
{ url: 'https://x.com/i/api/graphql/a/UserTweets', method: 'GET' },
|
|
32
|
+
{ url: 'https://x.com/i/api/graphql/b/UserTweets', method: 'GET' },
|
|
33
|
+
{ url: 'https://api.example.com/v1/u', method: 'GET' },
|
|
34
|
+
{ url: 'https://api.example.com/v1/u', method: 'GET' },
|
|
35
|
+
{ url: 'https://api.example.com/v1/u', method: 'GET' },
|
|
36
|
+
]);
|
|
37
|
+
expect(out.map(o => o.key)).toEqual([
|
|
38
|
+
'UserTweets',
|
|
39
|
+
'UserTweets#2',
|
|
40
|
+
'GET api.example.com/v1/u',
|
|
41
|
+
'GET api.example.com/v1/u#2',
|
|
42
|
+
'GET api.example.com/v1/u#3',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
it('preserves extra fields on each request', () => {
|
|
46
|
+
const out = assignKeys([{ url: 'https://a.com/x', method: 'GET', status: 200 }]);
|
|
47
|
+
expect(out[0]).toMatchObject({ status: 200, key: 'GET a.com/x' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape-based field filter for `browser network --filter <fields>`.
|
|
3
|
+
*
|
|
4
|
+
* Agents know what fields a target request's body should contain
|
|
5
|
+
* (e.g. "author, text, likes") but not which of the captured requests
|
|
6
|
+
* carries that body. This module lets the network command filter
|
|
7
|
+
* entries down to those whose inferred shape exposes every requested
|
|
8
|
+
* field name as some path segment.
|
|
9
|
+
*
|
|
10
|
+
* Matching is "any-segment" (not last-segment-only): a field matches
|
|
11
|
+
* if it equals any segment name of any path in the shape map. This
|
|
12
|
+
* keeps nested-container fields (e.g. `legacy`, `author` used as an
|
|
13
|
+
* object key with further nesting) findable.
|
|
14
|
+
*/
|
|
15
|
+
import type { Shape } from './shape.js';
|
|
16
|
+
export interface ParsedFilter {
|
|
17
|
+
/** Deduped, order-preserving, trimmed non-empty field names. */
|
|
18
|
+
fields: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FilterParseError {
|
|
21
|
+
/** `invalid_filter` structured error reason for agents. */
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse `--filter` argument value. Splits on `,`, trims, drops empties,
|
|
26
|
+
* and dedupes (first-seen wins). Returns `FilterParseError` when the
|
|
27
|
+
* result is empty after cleaning — which means the caller passed only
|
|
28
|
+
* whitespace, commas, or an empty string.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseFilter(raw: string): ParsedFilter | FilterParseError;
|
|
31
|
+
/**
|
|
32
|
+
* Extract named segments from a shape path. Drops the leading `$`,
|
|
33
|
+
* strips `[N]` array indices, and unwraps `["key"]` bracket-quoted
|
|
34
|
+
* keys back to their raw string.
|
|
35
|
+
*
|
|
36
|
+
* Examples:
|
|
37
|
+
* `$` → []
|
|
38
|
+
* `$.data.items[0].author` → ['data','items','author']
|
|
39
|
+
* `$.data.user["nick name"]` → ['data','user','nick name']
|
|
40
|
+
* `$.rows[0][1]` → ['rows']
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractSegments(path: string): string[];
|
|
43
|
+
/**
|
|
44
|
+
* Collect the set of segment names used anywhere in a shape map.
|
|
45
|
+
* The returned set is what we test field membership against.
|
|
46
|
+
*/
|
|
47
|
+
export declare function collectShapeSegments(shape: Shape): Set<string>;
|
|
48
|
+
/**
|
|
49
|
+
* True iff every field in `fields` equals some segment name in `shape`.
|
|
50
|
+
* AND semantics: all fields must be present.
|
|
51
|
+
*/
|
|
52
|
+
export declare function shapeMatchesFilter(shape: Shape, fields: string[]): boolean;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `--filter` argument value. Splits on `,`, trims, drops empties,
|
|
3
|
+
* and dedupes (first-seen wins). Returns `FilterParseError` when the
|
|
4
|
+
* result is empty after cleaning — which means the caller passed only
|
|
5
|
+
* whitespace, commas, or an empty string.
|
|
6
|
+
*/
|
|
7
|
+
export function parseFilter(raw) {
|
|
8
|
+
if (typeof raw !== 'string') {
|
|
9
|
+
return { reason: `--filter value must be a non-empty comma-separated field list` };
|
|
10
|
+
}
|
|
11
|
+
const parts = raw.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
12
|
+
if (parts.length === 0) {
|
|
13
|
+
return { reason: `--filter value must be a non-empty comma-separated field list (got "${raw}")` };
|
|
14
|
+
}
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
const fields = [];
|
|
17
|
+
for (const p of parts) {
|
|
18
|
+
if (!seen.has(p)) {
|
|
19
|
+
seen.add(p);
|
|
20
|
+
fields.push(p);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { fields };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract named segments from a shape path. Drops the leading `$`,
|
|
27
|
+
* strips `[N]` array indices, and unwraps `["key"]` bracket-quoted
|
|
28
|
+
* keys back to their raw string.
|
|
29
|
+
*
|
|
30
|
+
* Examples:
|
|
31
|
+
* `$` → []
|
|
32
|
+
* `$.data.items[0].author` → ['data','items','author']
|
|
33
|
+
* `$.data.user["nick name"]` → ['data','user','nick name']
|
|
34
|
+
* `$.rows[0][1]` → ['rows']
|
|
35
|
+
*/
|
|
36
|
+
export function extractSegments(path) {
|
|
37
|
+
if (!path || path === '$')
|
|
38
|
+
return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
// Start past the leading `$`; if path doesn't start with `$` treat
|
|
41
|
+
// it as a raw segment list (keeps us robust to unexpected input).
|
|
42
|
+
let i = path.startsWith('$') ? 1 : 0;
|
|
43
|
+
while (i < path.length) {
|
|
44
|
+
const c = path[i];
|
|
45
|
+
if (c === '.') {
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === '[') {
|
|
50
|
+
// Either `[N]` (numeric) or `["key"]` (quoted key). Handle both.
|
|
51
|
+
const end = path.indexOf(']', i);
|
|
52
|
+
if (end === -1)
|
|
53
|
+
break;
|
|
54
|
+
const inner = path.slice(i + 1, end);
|
|
55
|
+
i = end + 1;
|
|
56
|
+
if (inner.length >= 2 && inner.startsWith('"') && inner.endsWith('"')) {
|
|
57
|
+
try {
|
|
58
|
+
out.push(JSON.parse(inner));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
out.push(inner.slice(1, -1));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// numeric index: drop
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// Bare identifier: read up to next `.` or `[`
|
|
68
|
+
let j = i;
|
|
69
|
+
while (j < path.length && path[j] !== '.' && path[j] !== '[')
|
|
70
|
+
j++;
|
|
71
|
+
out.push(path.slice(i, j));
|
|
72
|
+
i = j;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Collect the set of segment names used anywhere in a shape map.
|
|
78
|
+
* The returned set is what we test field membership against.
|
|
79
|
+
*/
|
|
80
|
+
export function collectShapeSegments(shape) {
|
|
81
|
+
const acc = new Set();
|
|
82
|
+
for (const p of Object.keys(shape)) {
|
|
83
|
+
for (const seg of extractSegments(p))
|
|
84
|
+
acc.add(seg);
|
|
85
|
+
}
|
|
86
|
+
return acc;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* True iff every field in `fields` equals some segment name in `shape`.
|
|
90
|
+
* AND semantics: all fields must be present.
|
|
91
|
+
*/
|
|
92
|
+
export function shapeMatchesFilter(shape, fields) {
|
|
93
|
+
if (fields.length === 0)
|
|
94
|
+
return true;
|
|
95
|
+
const segs = collectShapeSegments(shape);
|
|
96
|
+
for (const f of fields) {
|
|
97
|
+
if (!segs.has(f))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { collectShapeSegments, extractSegments, parseFilter, shapeMatchesFilter, } from './shape-filter.js';
|
|
3
|
+
describe('parseFilter', () => {
|
|
4
|
+
it('splits comma-separated fields, trims, and drops empty tokens', () => {
|
|
5
|
+
const r = parseFilter('author, text , likes');
|
|
6
|
+
expect(r).toEqual({ fields: ['author', 'text', 'likes'] });
|
|
7
|
+
});
|
|
8
|
+
it('dedupes while preserving first-seen order', () => {
|
|
9
|
+
const r = parseFilter('a,b,a,c,b');
|
|
10
|
+
expect(r).toEqual({ fields: ['a', 'b', 'c'] });
|
|
11
|
+
});
|
|
12
|
+
it('rejects empty string as invalid_filter', () => {
|
|
13
|
+
const r = parseFilter('');
|
|
14
|
+
expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
|
|
15
|
+
});
|
|
16
|
+
it('rejects whitespace-only as invalid_filter', () => {
|
|
17
|
+
const r = parseFilter(' ');
|
|
18
|
+
expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
|
|
19
|
+
});
|
|
20
|
+
it('rejects commas-only as invalid_filter', () => {
|
|
21
|
+
const r = parseFilter(',,,');
|
|
22
|
+
expect(r).toMatchObject({ reason: expect.stringContaining('non-empty') });
|
|
23
|
+
});
|
|
24
|
+
it('accepts a single field', () => {
|
|
25
|
+
expect(parseFilter('author')).toEqual({ fields: ['author'] });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('extractSegments', () => {
|
|
29
|
+
it('returns empty for root', () => {
|
|
30
|
+
expect(extractSegments('$')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it('splits dotted path and drops $', () => {
|
|
33
|
+
expect(extractSegments('$.data.user.name')).toEqual(['data', 'user', 'name']);
|
|
34
|
+
});
|
|
35
|
+
it('drops numeric array indices', () => {
|
|
36
|
+
expect(extractSegments('$.items[0].author')).toEqual(['items', 'author']);
|
|
37
|
+
expect(extractSegments('$.rows[0][12]')).toEqual(['rows']);
|
|
38
|
+
});
|
|
39
|
+
it('unwraps bracket-quoted keys', () => {
|
|
40
|
+
expect(extractSegments('$.data["weird key"]')).toEqual(['data', 'weird key']);
|
|
41
|
+
});
|
|
42
|
+
it('handles bracket-quoted keys at root', () => {
|
|
43
|
+
expect(extractSegments('$["123bad"]')).toEqual(['123bad']);
|
|
44
|
+
});
|
|
45
|
+
it('mixes bracket keys and dot segments', () => {
|
|
46
|
+
expect(extractSegments('$.data.user["nick name"].age'))
|
|
47
|
+
.toEqual(['data', 'user', 'nick name', 'age']);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('collectShapeSegments', () => {
|
|
51
|
+
it('collects every segment name from every path in a shape', () => {
|
|
52
|
+
const shape = {
|
|
53
|
+
'$': 'object',
|
|
54
|
+
'$.data': 'object',
|
|
55
|
+
'$.data.items': 'array(3)',
|
|
56
|
+
'$.data.items[0]': 'object',
|
|
57
|
+
'$.data.items[0].author': 'string',
|
|
58
|
+
'$.data.items[0].text': 'string',
|
|
59
|
+
};
|
|
60
|
+
const segs = collectShapeSegments(shape);
|
|
61
|
+
expect(segs.has('data')).toBe(true);
|
|
62
|
+
expect(segs.has('items')).toBe(true);
|
|
63
|
+
expect(segs.has('author')).toBe(true);
|
|
64
|
+
expect(segs.has('text')).toBe(true);
|
|
65
|
+
expect(segs.has('$')).toBe(false);
|
|
66
|
+
expect(segs.has('[0]')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it('returns an empty set for an empty shape', () => {
|
|
69
|
+
expect(collectShapeSegments({}).size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('shapeMatchesFilter', () => {
|
|
73
|
+
const shape = {
|
|
74
|
+
'$': 'object',
|
|
75
|
+
'$.data': 'object',
|
|
76
|
+
'$.data.items': 'array(1)',
|
|
77
|
+
'$.data.items[0].author': 'string',
|
|
78
|
+
'$.data.items[0].text': 'string',
|
|
79
|
+
'$.data.items[0].likes': 'number',
|
|
80
|
+
};
|
|
81
|
+
it('returns true when every field matches some path segment (AND)', () => {
|
|
82
|
+
expect(shapeMatchesFilter(shape, ['author', 'text', 'likes'])).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it('matches nested container names, not just leaves (any-segment rule)', () => {
|
|
85
|
+
// `data` and `items` are container segments, not leaves; still must match.
|
|
86
|
+
expect(shapeMatchesFilter(shape, ['data', 'items'])).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it('returns false when any field is missing', () => {
|
|
89
|
+
expect(shapeMatchesFilter(shape, ['author', 'missing'])).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
it('is case-sensitive', () => {
|
|
92
|
+
expect(shapeMatchesFilter(shape, ['Author'])).toBe(false);
|
|
93
|
+
expect(shapeMatchesFilter(shape, ['author'])).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
it('empty filter list vacuously matches', () => {
|
|
96
|
+
expect(shapeMatchesFilter(shape, [])).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it('rejects requests whose shape has no body (empty shape)', () => {
|
|
99
|
+
expect(shapeMatchesFilter({}, ['author'])).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON shape inference for browser network response previews.
|
|
3
|
+
*
|
|
4
|
+
* Produces a flat path → type descriptor map so agents can understand
|
|
5
|
+
* response structure without paying the token cost of the full body.
|
|
6
|
+
*
|
|
7
|
+
* Descriptors:
|
|
8
|
+
* string | number | boolean | null primitives
|
|
9
|
+
* string(len=N) strings longer than sampleStringLen
|
|
10
|
+
* array(0) | array(N) array at depth cap or summarized
|
|
11
|
+
* object | object(empty) objects at depth cap or summarized
|
|
12
|
+
* (truncated) output size budget exceeded
|
|
13
|
+
*/
|
|
14
|
+
export interface InferShapeOptions {
|
|
15
|
+
/** Max path depth to descend into (default 6) */
|
|
16
|
+
maxDepth?: number;
|
|
17
|
+
/** Byte budget for the serialized output; truncates when exceeded (default 2048) */
|
|
18
|
+
maxBytes?: number;
|
|
19
|
+
/** Strings longer than this get summarized as `string(len=N)` (default 80) */
|
|
20
|
+
sampleStringLen?: number;
|
|
21
|
+
}
|
|
22
|
+
export type Shape = Record<string, string>;
|
|
23
|
+
export declare function inferShape(value: unknown, opts?: InferShapeOptions): Shape;
|