@jackwener/opencli 1.7.5 → 1.7.7
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 +22 -10
- package/README.zh-CN.md +18 -9
- package/cli-manifest.json +401 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/bilibili/video.js +68 -0
- package/clis/bilibili/video.test.js +132 -0
- package/clis/chatgpt/image.js +1 -1
- package/clis/deepseek/ask.js +37 -11
- package/clis/deepseek/ask.test.js +165 -0
- package/clis/deepseek/utils.js +192 -24
- package/clis/deepseek/utils.test.js +145 -0
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- 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/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +35 -2
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +219 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -0
- package/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/clis/youtube/channel.js +35 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -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 +23 -5
- 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/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +936 -141
- package/dist/src/cli.test.js +1051 -1
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/types.d.ts +18 -3
- package/package.json +5 -1
|
@@ -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;
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
const ROOT = '$';
|
|
15
|
+
export function inferShape(value, opts = {}) {
|
|
16
|
+
const maxDepth = opts.maxDepth ?? 6;
|
|
17
|
+
const maxBytes = opts.maxBytes ?? 2048;
|
|
18
|
+
const sampleStringLen = opts.sampleStringLen ?? 80;
|
|
19
|
+
const out = {};
|
|
20
|
+
let bytes = 2; // account for `{}` braces when serialized
|
|
21
|
+
let truncated = false;
|
|
22
|
+
const add = (path, desc) => {
|
|
23
|
+
if (truncated)
|
|
24
|
+
return false;
|
|
25
|
+
const entryBytes = JSON.stringify(path).length + JSON.stringify(desc).length + 2; // ":" + ","
|
|
26
|
+
if (bytes + entryBytes > maxBytes) {
|
|
27
|
+
out['(truncated)'] = `reached ${maxBytes}B budget`;
|
|
28
|
+
truncated = true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
out[path] = desc;
|
|
32
|
+
bytes += entryBytes;
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
const walk = (node, path, depth) => {
|
|
36
|
+
if (truncated)
|
|
37
|
+
return;
|
|
38
|
+
if (node === null) {
|
|
39
|
+
add(path, 'null');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const t = typeof node;
|
|
43
|
+
if (t === 'string') {
|
|
44
|
+
const s = node;
|
|
45
|
+
add(path, s.length > sampleStringLen ? `string(len=${s.length})` : 'string');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (t === 'number' || t === 'boolean') {
|
|
49
|
+
add(path, t);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (t === 'undefined' || t === 'function' || t === 'symbol' || t === 'bigint') {
|
|
53
|
+
add(path, t);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(node)) {
|
|
57
|
+
if (node.length === 0) {
|
|
58
|
+
add(path, 'array(0)');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (depth >= maxDepth) {
|
|
62
|
+
add(path, `array(${node.length})`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!add(path, `array(${node.length})`))
|
|
66
|
+
return;
|
|
67
|
+
walk(node[0], `${path}[0]`, depth + 1);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// plain object
|
|
71
|
+
const obj = node;
|
|
72
|
+
const keys = Object.keys(obj);
|
|
73
|
+
if (keys.length === 0) {
|
|
74
|
+
add(path, 'object(empty)');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (depth >= maxDepth) {
|
|
78
|
+
add(path, `object(keys=${keys.length})`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!add(path, 'object'))
|
|
82
|
+
return;
|
|
83
|
+
for (const k of keys) {
|
|
84
|
+
if (truncated)
|
|
85
|
+
return;
|
|
86
|
+
const childPath = isSafeIdent(k) ? `${path}.${k}` : `${path}[${JSON.stringify(k)}]`;
|
|
87
|
+
walk(obj[k], childPath, depth + 1);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(value, ROOT, 0);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function isSafeIdent(key) {
|
|
94
|
+
return /^[A-Za-z_$][\w$]*$/.test(key);
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { inferShape } from './shape.js';
|
|
3
|
+
describe('inferShape', () => {
|
|
4
|
+
it('describes primitives at root', () => {
|
|
5
|
+
expect(inferShape('hello')).toEqual({ $: 'string' });
|
|
6
|
+
expect(inferShape(42)).toEqual({ $: 'number' });
|
|
7
|
+
expect(inferShape(true)).toEqual({ $: 'boolean' });
|
|
8
|
+
expect(inferShape(null)).toEqual({ $: 'null' });
|
|
9
|
+
});
|
|
10
|
+
it('summarizes long strings with their length', () => {
|
|
11
|
+
const long = 'x'.repeat(200);
|
|
12
|
+
expect(inferShape(long, { sampleStringLen: 80 })).toEqual({ $: 'string(len=200)' });
|
|
13
|
+
});
|
|
14
|
+
it('walks nested objects and emits dotted paths', () => {
|
|
15
|
+
const shape = inferShape({ user: { id: 1, name: 'bob' } });
|
|
16
|
+
expect(shape).toEqual({
|
|
17
|
+
$: 'object',
|
|
18
|
+
'$.user': 'object',
|
|
19
|
+
'$.user.id': 'number',
|
|
20
|
+
'$.user.name': 'string',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it('quotes unsafe keys using bracket notation', () => {
|
|
24
|
+
const shape = inferShape({ 'weird key': 1, '123bad': 2 });
|
|
25
|
+
expect(shape['$["weird key"]']).toBe('number');
|
|
26
|
+
expect(shape['$["123bad"]']).toBe('number');
|
|
27
|
+
});
|
|
28
|
+
it('samples the first array element and reports length', () => {
|
|
29
|
+
const shape = inferShape({ items: [{ a: 1 }, { a: 2 }, { a: 3 }] });
|
|
30
|
+
expect(shape['$.items']).toBe('array(3)');
|
|
31
|
+
expect(shape['$.items[0]']).toBe('object');
|
|
32
|
+
expect(shape['$.items[0].a']).toBe('number');
|
|
33
|
+
});
|
|
34
|
+
it('marks empty containers explicitly', () => {
|
|
35
|
+
const shape = inferShape({ arr: [], obj: {} });
|
|
36
|
+
expect(shape['$.arr']).toBe('array(0)');
|
|
37
|
+
expect(shape['$.obj']).toBe('object(empty)');
|
|
38
|
+
});
|
|
39
|
+
it('collapses subtrees past maxDepth', () => {
|
|
40
|
+
const deep = { a: { b: { c: { d: { e: { f: 'too deep' } } } } } };
|
|
41
|
+
const shape = inferShape(deep, { maxDepth: 2 });
|
|
42
|
+
expect(shape['$.a.b']).toMatch(/^object/);
|
|
43
|
+
expect(shape['$.a.b.c']).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
it('truncates when the byte budget is exhausted', () => {
|
|
46
|
+
const wide = {};
|
|
47
|
+
for (let i = 0; i < 500; i++)
|
|
48
|
+
wide[`field_${i}`] = i;
|
|
49
|
+
const shape = inferShape(wide, { maxBytes: 256 });
|
|
50
|
+
expect(shape['(truncated)']).toMatch(/256B/);
|
|
51
|
+
expect(Object.keys(shape).length).toBeLessThan(500);
|
|
52
|
+
});
|
|
53
|
+
it('stops descending into an array once the budget is hit by its own descriptor', () => {
|
|
54
|
+
// Budget just large enough for `$` + one deep array descriptor, not its element.
|
|
55
|
+
const shape = inferShape({ items: [{ deep: 1 }] }, { maxBytes: 40 });
|
|
56
|
+
expect(shape['$.items[0]']).toBeUndefined();
|
|
57
|
+
expect(shape['(truncated)']).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
it('handles the Twitter UserTweets payload envelope', () => {
|
|
60
|
+
const payload = {
|
|
61
|
+
data: {
|
|
62
|
+
user: {
|
|
63
|
+
result: {
|
|
64
|
+
rest_id: '42',
|
|
65
|
+
timeline_v2: {
|
|
66
|
+
timeline: {
|
|
67
|
+
instructions: [
|
|
68
|
+
{ type: 'TimelinePinEntry', entries: [] },
|
|
69
|
+
{ entries: [{ entryId: 'tweet-1', content: { entryType: 'TimelineTimelineItem' } }] },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const shape = inferShape(payload, { maxDepth: 10 });
|
|
78
|
+
expect(shape['$.data.user.result.rest_id']).toBe('string');
|
|
79
|
+
expect(shape['$.data.user.result.timeline_v2.timeline.instructions']).toBe('array(2)');
|
|
80
|
+
expect(shape['$.data.user.result.timeline_v2.timeline.instructions[0]']).toBe('object');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -5,18 +5,31 @@
|
|
|
5
5
|
* goes through the unified resolver. When resolution fails, one of these
|
|
6
6
|
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
7
|
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*
|
|
9
|
+
* Numeric-ref codes (from snapshot indices):
|
|
10
|
+
* - not_found: the ref no longer exists in the DOM
|
|
11
|
+
* - stale_ref: the ref still exists but points to a different element
|
|
12
|
+
*
|
|
13
|
+
* CSS-selector codes (from `--selector <css>` entrypoints):
|
|
14
|
+
* - invalid_selector: selector syntax rejected by querySelectorAll
|
|
15
|
+
* - selector_not_found: 0 matches
|
|
16
|
+
* - selector_ambiguous: >1 matches for a write op without --nth
|
|
17
|
+
* - selector_nth_out_of_range: --nth beyond matches_n
|
|
8
18
|
*/
|
|
9
|
-
export type TargetErrorCode = 'not_found' | '
|
|
19
|
+
export type TargetErrorCode = 'not_found' | 'stale_ref' | 'invalid_selector' | 'selector_not_found' | 'selector_ambiguous' | 'selector_nth_out_of_range';
|
|
10
20
|
export interface TargetErrorInfo {
|
|
11
21
|
code: TargetErrorCode;
|
|
12
22
|
message: string;
|
|
13
23
|
hint: string;
|
|
14
24
|
candidates?: string[];
|
|
25
|
+
/** CSS-path match count, when the error was raised mid-resolution */
|
|
26
|
+
matches_n?: number;
|
|
15
27
|
}
|
|
16
28
|
export declare class TargetError extends Error {
|
|
17
29
|
readonly code: TargetErrorCode;
|
|
18
30
|
readonly hint: string;
|
|
19
31
|
readonly candidates?: string[];
|
|
32
|
+
readonly matches_n?: number;
|
|
20
33
|
constructor(info: TargetErrorInfo);
|
|
21
34
|
/** Serialize for structured output to AI agents */
|
|
22
35
|
toJSON(): TargetErrorInfo;
|
|
@@ -5,17 +5,29 @@
|
|
|
5
5
|
* goes through the unified resolver. When resolution fails, one of these
|
|
6
6
|
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
7
|
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*
|
|
9
|
+
* Numeric-ref codes (from snapshot indices):
|
|
10
|
+
* - not_found: the ref no longer exists in the DOM
|
|
11
|
+
* - stale_ref: the ref still exists but points to a different element
|
|
12
|
+
*
|
|
13
|
+
* CSS-selector codes (from `--selector <css>` entrypoints):
|
|
14
|
+
* - invalid_selector: selector syntax rejected by querySelectorAll
|
|
15
|
+
* - selector_not_found: 0 matches
|
|
16
|
+
* - selector_ambiguous: >1 matches for a write op without --nth
|
|
17
|
+
* - selector_nth_out_of_range: --nth beyond matches_n
|
|
8
18
|
*/
|
|
9
19
|
export class TargetError extends Error {
|
|
10
20
|
code;
|
|
11
21
|
hint;
|
|
12
22
|
candidates;
|
|
23
|
+
matches_n;
|
|
13
24
|
constructor(info) {
|
|
14
25
|
super(info.message);
|
|
15
26
|
this.name = 'TargetError';
|
|
16
27
|
this.code = info.code;
|
|
17
28
|
this.hint = info.hint;
|
|
18
29
|
this.candidates = info.candidates;
|
|
30
|
+
this.matches_n = info.matches_n;
|
|
19
31
|
}
|
|
20
32
|
/** Serialize for structured output to AI agents */
|
|
21
33
|
toJSON() {
|
|
@@ -24,6 +36,7 @@ export class TargetError extends Error {
|
|
|
24
36
|
message: this.message,
|
|
25
37
|
hint: this.hint,
|
|
26
38
|
...(this.candidates && { candidates: this.candidates }),
|
|
39
|
+
...(this.matches_n !== undefined && { matches_n: this.matches_n }),
|
|
27
40
|
};
|
|
28
41
|
}
|
|
29
42
|
}
|
|
@@ -14,16 +14,47 @@ describe('TargetError', () => {
|
|
|
14
14
|
expect(err.hint).toContain('fresh snapshot');
|
|
15
15
|
expect(err.candidates).toBeUndefined();
|
|
16
16
|
});
|
|
17
|
-
it('creates
|
|
17
|
+
it('creates selector_ambiguous error with candidates + matches_n', () => {
|
|
18
18
|
const err = new TargetError({
|
|
19
|
-
code: '
|
|
19
|
+
code: 'selector_ambiguous',
|
|
20
20
|
message: 'CSS selector ".btn" matched 3 elements',
|
|
21
|
-
hint: 'Use a more specific selector.',
|
|
21
|
+
hint: 'Use a more specific selector, or pass --nth.',
|
|
22
22
|
candidates: ['<button> "Login"', '<button> "Sign Up"', '<button> "Cancel"'],
|
|
23
|
+
matches_n: 3,
|
|
23
24
|
});
|
|
24
|
-
expect(err.code).toBe('
|
|
25
|
+
expect(err.code).toBe('selector_ambiguous');
|
|
25
26
|
expect(err.candidates).toHaveLength(3);
|
|
26
27
|
expect(err.candidates[0]).toContain('Login');
|
|
28
|
+
expect(err.matches_n).toBe(3);
|
|
29
|
+
});
|
|
30
|
+
it('creates invalid_selector error', () => {
|
|
31
|
+
const err = new TargetError({
|
|
32
|
+
code: 'invalid_selector',
|
|
33
|
+
message: 'Invalid CSS selector: >>> (unexpected token)',
|
|
34
|
+
hint: 'Check the selector syntax.',
|
|
35
|
+
});
|
|
36
|
+
expect(err.code).toBe('invalid_selector');
|
|
37
|
+
expect(err.message).toContain('Invalid CSS selector');
|
|
38
|
+
});
|
|
39
|
+
it('creates selector_not_found error with matches_n=0', () => {
|
|
40
|
+
const err = new TargetError({
|
|
41
|
+
code: 'selector_not_found',
|
|
42
|
+
message: 'CSS selector ".missing" matched 0 elements',
|
|
43
|
+
hint: 'Check the page or use browser find.',
|
|
44
|
+
matches_n: 0,
|
|
45
|
+
});
|
|
46
|
+
expect(err.code).toBe('selector_not_found');
|
|
47
|
+
expect(err.matches_n).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
it('creates selector_nth_out_of_range error', () => {
|
|
50
|
+
const err = new TargetError({
|
|
51
|
+
code: 'selector_nth_out_of_range',
|
|
52
|
+
message: 'matched 3 elements, but --nth=5 is out of range',
|
|
53
|
+
hint: 'Use --nth between 0 and 2.',
|
|
54
|
+
matches_n: 3,
|
|
55
|
+
});
|
|
56
|
+
expect(err.code).toBe('selector_nth_out_of_range');
|
|
57
|
+
expect(err.matches_n).toBe(3);
|
|
27
58
|
});
|
|
28
59
|
it('creates stale_ref error', () => {
|
|
29
60
|
const err = new TargetError({
|
|
@@ -36,17 +67,19 @@ describe('TargetError', () => {
|
|
|
36
67
|
});
|
|
37
68
|
it('serializes to JSON for structured output', () => {
|
|
38
69
|
const err = new TargetError({
|
|
39
|
-
code: '
|
|
70
|
+
code: 'selector_ambiguous',
|
|
40
71
|
message: 'matched 3',
|
|
41
72
|
hint: 'be specific',
|
|
42
73
|
candidates: ['a', 'b'],
|
|
74
|
+
matches_n: 3,
|
|
43
75
|
});
|
|
44
76
|
const json = err.toJSON();
|
|
45
77
|
expect(json).toEqual({
|
|
46
|
-
code: '
|
|
78
|
+
code: 'selector_ambiguous',
|
|
47
79
|
message: 'matched 3',
|
|
48
80
|
hint: 'be specific',
|
|
49
81
|
candidates: ['a', 'b'],
|
|
82
|
+
matches_n: 3,
|
|
50
83
|
});
|
|
51
84
|
});
|
|
52
85
|
it('omits candidates from JSON when not present', () => {
|