@danceiny/gotry 0.0.1-rc.8 → 0.0.1-rc.9

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.
Files changed (41) hide show
  1. package/README.md +6 -4
  2. package/cordis.gotry-patch.yml +7 -0
  3. package/dist/capabilities/flyai.js +131 -0
  4. package/dist/capabilities/session/action-cache.js +120 -0
  5. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  6. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  7. package/dist/capabilities/session/extract.js +40 -0
  8. package/dist/capabilities/session/read-guard.js +56 -0
  9. package/dist/capabilities/session/transport.js +95 -0
  10. package/dist/capabilities/session-search.js +95 -0
  11. package/dist/scripts/action-cache-tests.js +107 -0
  12. package/dist/scripts/async-collect.js +26 -7
  13. package/dist/scripts/companion-tests.js +112 -0
  14. package/dist/scripts/ledger-tests.js +344 -0
  15. package/dist/scripts/ledger-workflow-crash.js +42 -0
  16. package/dist/scripts/memory-decay-tests.js +83 -0
  17. package/dist/scripts/memory-metrics.js +6 -20
  18. package/dist/scripts/nudge-digest.js +4 -14
  19. package/dist/scripts/session-attach-diagnose.js +31 -0
  20. package/dist/scripts/session-attach-poc.js +65 -0
  21. package/dist/scripts/session-attach-wait.js +41 -0
  22. package/dist/scripts/session-extract-tests.js +81 -0
  23. package/dist/scripts/session-login.js +48 -0
  24. package/dist/scripts/session-tests.js +162 -0
  25. package/dist/scripts/smoke.js +41 -1
  26. package/dist/scripts/state-cli-tests.js +136 -0
  27. package/dist/scripts/state-cli.js +234 -0
  28. package/dist/scripts/travel-timeline-tests.js +123 -0
  29. package/dist/scripts/unified-tests.js +1 -1
  30. package/dist/src/companions.js +112 -0
  31. package/dist/src/index.js +346 -117
  32. package/dist/src/loop.js +52 -15
  33. package/dist/src/memory-decay.js +31 -0
  34. package/dist/src/state-ledger.js +848 -0
  35. package/dist/src/travel-timeline.js +78 -0
  36. package/dist/src/unified.js +3 -1
  37. package/package.json +1 -1
  38. package/ts/package.json +3 -0
  39. package/ts/src/index.ts +216 -83
  40. package/ts/src/loop.ts +57 -17
  41. package/ts/src/unified.ts +6 -1
package/README.md CHANGED
@@ -9,8 +9,8 @@
9
9
 
10
10
  | | |
11
11
  |---|---|
12
- | **Version** | `v0.0.1-rc.7`(npm: `@danceiny/gotry@rc`;rc.8 已构建待发布;[release notes](docs/release-notes.md)) |
13
- | **Status** | M4 记忆域推进中(2026-08-28):记忆写读闭环 + 效用 sidecar + 「下一次出发」0::1 召回;13 工具;全栈回归全绿(套件清单见 `scripts/run-all-tests.sh` 分节) |
12
+ | **Version** | `v0.0.1-rc.8`(npm **latest 直指本版**,`npx @danceiny/gotry` 即得;[release notes](docs/release-notes.md)) |
13
+ | **Status** | M4 记忆域推进中(2026-08-28):记忆写读闭环 + 效用 sidecar + 「下一次出发」0::1 召回;17 工具;事务化状态账本(ADR-15:SQLite 单文件=唯一权威,旧 JSON/JSONL 为单向导出视图);全栈回归全绿(套件清单见 `scripts/run-all-tests.sh` 分节) |
14
14
  | **Repo** | [github.com/Danceiny/gotry](https://github.com/Danceiny/gotry) (private) |
15
15
  | **Runtime** | DeepSeek Harness 0.1.1-rc.2 (vendored `ts/dsh-runtime/`, [upstream](https://github.com/deepseek-ai/DeepSeek-Harness)) · Z3 (npm `z3-solver`) · LoopX (pipx, `~/.local/pipx/venvs/loopx/bin/loopx`) · Agent-Reach v1.5.0 (`.venv/bin/agent-reach`) |
16
16
  | **License** | **MIT** (2026-08-23 落定,见 [LICENSE](LICENSE)) |
@@ -41,12 +41,14 @@ GoTry 把「想去哪」变成「能不能、怎么去」:
41
41
  ### 一行启动(npm,推荐)
42
42
 
43
43
  ```bash
44
- npx @danceiny/gotry@rc web # 首跑会提示在当前目录建 .env(LLM_API_KEY=<DeepSeek key>)
44
+ npx @danceiny/gotry web # 首跑会提示在当前目录建 .env(LLM_API_KEY=<DeepSeek key>)
45
45
  ```
46
46
 
47
47
  装完即得 :3080 对话界面。已装 dsh 的用户同理——上面的命令会自动以 cordis patch
48
48
  挂载 gotry 插件,无需任何额外配置(零成本启动)。
49
49
 
50
+ > rc.5 为不可用首发(已停止指向);`@rc` tag 当前为 rc.7,`latest` 为 rc.8,两者均可跑。
51
+
50
52
  ### 源码安装(开发者)
51
53
 
52
54
  ```bash
@@ -262,4 +264,4 @@ ADR 与技术债见 [`docs/architecture.md` §8 / §10](docs/architecture.md)。
262
264
 
263
265
  **Built with**: DeepSeek Harness 0.1.1-rc.2 · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 (`.venv/`) · OpenFlights · TypeScript · Bun
264
266
 
265
- **Last verified against `main`(rc.8 已构建待发布,2026-08-28)** — 全栈回归全绿(发布流程见 `scripts/publish-npm.sh`,内部决策见 [docs/decisions-needed.md](docs/decisions-needed.md)、[docs/tokens.md](docs/tokens.md))。
267
+ **Last verified against `v0.0.1-rc.8`(已发布,2026-08-28)** — 全栈回归全绿(发布流程见 `scripts/publish-npm.sh`,内部决策见 [docs/decisions-needed.md](docs/decisions-needed.md)、[docs/tokens.md](docs/tokens.md))。
@@ -77,3 +77,10 @@
77
77
  evidence=用户原话),不等确认;班次/房型/落脚点等开放性选择不外问
78
78
  用户——基于已知动机与约束给出最有说服力的方案并说明理由,用户否决
79
79
  才改。校准类疑问永不阻塞推进。
80
+ (19)实时价与班期三级路由:机票/火车班次与票价先 gotry_flyai_search
81
+ (飞猪官方只读,主链路);关键决策(临近下单/价差存疑)再用
82
+ gotry_session_search 在用户本人登录态交叉验证,直达/中转分桶比对,
83
+ 不比裸最低价;两源皆不可用退静态包并显式标 [静态包:估算]。
84
+ 会话工具返回 needs-login→请用户跑 scripts/session-login.ts 并
85
+ 继续用官方源推进;返回 challenged→立即停手如实告知,绝不重试
86
+ 绝不绕过。[会话:*] 证据链随结论必达用户。
@@ -0,0 +1,131 @@
1
+ import { spawn } from 'node:child_process';
2
+ function sh(cmd, args, opts) {
3
+ const child = spawn(cmd, args, {
4
+ env: process.env,
5
+ cwd: process.cwd()
6
+ });
7
+ let stdout = '';
8
+ let stderr = '';
9
+ let error;
10
+ const timer = setTimeout(()=>{
11
+ try {
12
+ child.kill('SIGKILL');
13
+ } catch {}
14
+ }, opts.timeoutMs);
15
+ return new Promise((resolve)=>{
16
+ child.stdout?.on('data', (d)=>{
17
+ stdout += d.toString();
18
+ });
19
+ child.stderr?.on('data', (d)=>{
20
+ stderr += d.toString();
21
+ });
22
+ child.on('error', (e)=>{
23
+ error = e.message.slice(0, 200);
24
+ });
25
+ child.on('close', (code)=>{
26
+ clearTimeout(timer);
27
+ resolve({
28
+ code: code ?? -1,
29
+ stdout,
30
+ stderr,
31
+ error
32
+ });
33
+ });
34
+ }).finally(()=>clearTimeout(timer));
35
+ }
36
+ export async function flyaiSearch(q) {
37
+ const started = Date.now();
38
+ const ts = new Date().toISOString();
39
+ const base = {
40
+ kind: q.kind,
41
+ latencyMs: 0
42
+ };
43
+ const origin = (q.origin ?? '').trim();
44
+ const destination = (q.destination ?? '').trim();
45
+ if (!origin || !destination || !/^\d{4}-\d{2}-\d{2}$/.test(q.depDate ?? '')) {
46
+ return {
47
+ ...base,
48
+ ok: false,
49
+ via: 'flyai-error',
50
+ verdict: 'error',
51
+ evidence: `[实时API:flyai@error@${ts}] bad args`,
52
+ error: 'origin/destination/depDate(YYYY-MM-DD) required'
53
+ };
54
+ }
55
+ const sub = q.kind === 'flight' ? 'search-flight' : 'search-train';
56
+ const r = await sh(q.cliBin ?? 'npx', [
57
+ '-y',
58
+ '@fly-ai/flyai-cli',
59
+ sub,
60
+ '--origin',
61
+ origin,
62
+ '--destination',
63
+ destination,
64
+ '--dep-date',
65
+ q.depDate
66
+ ], {
67
+ timeoutMs: q.timeoutMs ?? 30_000
68
+ });
69
+ const latencyMs = Date.now() - started;
70
+ if (r.error || r.code !== 0) {
71
+ return {
72
+ ...base,
73
+ latencyMs,
74
+ ok: false,
75
+ via: 'flyai-error',
76
+ verdict: 'error',
77
+ evidence: `[实时API:flyai@error@${ts}] ${r.error ?? `exit ${r.code}`}`,
78
+ error: r.error ?? r.stderr.replace(/\s+/g, ' ').slice(0, 200)
79
+ };
80
+ }
81
+ let items;
82
+ try {
83
+ const jStart = r.stdout.indexOf('{');
84
+ const parsed = JSON.parse(jStart >= 0 ? r.stdout.slice(jStart) : r.stdout);
85
+ items = parsed.data?.itemList ?? [];
86
+ } catch {
87
+ const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160);
88
+ return {
89
+ ...base,
90
+ latencyMs,
91
+ ok: false,
92
+ via: 'flyai-error',
93
+ verdict: 'error',
94
+ evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
95
+ error: `failed to parse flyai output as JSON: ${raw}`
96
+ };
97
+ }
98
+ const options = [];
99
+ for (const it of items){
100
+ const seg = it.journeys?.[0]?.segments?.[0];
101
+ if (!seg?.marketingTransportNo || !seg.depDateTime) continue;
102
+ const rawPrice = it.ticketPrice ?? it.price ?? '';
103
+ const numericPrice = Number(rawPrice);
104
+ options.push({
105
+ no: seg.marketingTransportNo,
106
+ name: seg.marketingTransportName ?? '',
107
+ depDateTime: seg.depDateTime ?? '',
108
+ arrDateTime: seg.arrDateTime ?? '',
109
+ depStation: seg.depStationName ?? '',
110
+ arrStation: seg.arrStationName ?? '',
111
+ durationMin: Number(seg.duration ?? 0) || 0,
112
+ price: Number.isFinite(numericPrice) && numericPrice > 0 ? numericPrice : 0,
113
+ priceRaw: /^\d+$/.test(rawPrice) ? undefined : rawPrice || undefined,
114
+ seatClass: seg.seatClassName,
115
+ jumpUrl: it.jumpUrl
116
+ });
117
+ }
118
+ const verdict = options.length > 0 ? 'hit' : 'miss';
119
+ return {
120
+ kind: q.kind,
121
+ latencyMs,
122
+ ok: true,
123
+ via: 'flyai',
124
+ verdict,
125
+ evidence: `[实时API:flyai@${ts}] ${options.length}/${items.length} ${q.kind} options`,
126
+ options
127
+ };
128
+ }
129
+
130
+
131
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/flyai.ts
@@ -0,0 +1,120 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ export function templateKey(site, action, params) {
5
+ const val = (k)=>k === 'date' ? '%date%' : (params[k] ?? '').trim();
6
+ const head = [
7
+ 'from',
8
+ 'to',
9
+ 'date'
10
+ ].filter((k)=>k in params).map(val);
11
+ const tail = Object.keys(params).filter((k)=>![
12
+ 'from',
13
+ 'to',
14
+ 'date'
15
+ ].includes(k)).sort().map(val);
16
+ return `${site}:${action}:${[
17
+ ...head,
18
+ ...tail
19
+ ].join('|')}`;
20
+ }
21
+ export function fingerprint(shapeText) {
22
+ return createHash('sha256').update(shapeText).digest('hex').slice(0, 16);
23
+ }
24
+ export class ActionCache {
25
+ filePath;
26
+ entries = new Map();
27
+ loaded = false;
28
+ ttlMs;
29
+ maxPerSite;
30
+ now;
31
+ constructor(filePath, opts = {}){
32
+ this.filePath = filePath;
33
+ this.ttlMs = opts.ttlMs ?? 48 * 3600_000;
34
+ this.maxPerSite = opts.maxEntriesPerSite ?? 50;
35
+ this.now = opts.now ?? (()=>new Date());
36
+ }
37
+ load() {
38
+ if (this.loaded) return;
39
+ this.loaded = true;
40
+ try {
41
+ if (existsSync(this.filePath)) {
42
+ const raw = JSON.parse(readFileSync(this.filePath, 'utf8'));
43
+ for (const e of raw){
44
+ if (e?.key && e.domFingerprint && e.locator) this.entries.set(e.key, e);
45
+ }
46
+ }
47
+ } catch {}
48
+ }
49
+ persist() {
50
+ try {
51
+ mkdirSync(dirname(this.filePath), {
52
+ recursive: true
53
+ });
54
+ const tmp = this.filePath + '.tmp';
55
+ writeFileSync(tmp, JSON.stringify([
56
+ ...this.entries.values()
57
+ ], null, 0), 'utf8');
58
+ writeFileSync(this.filePath, readFileSync(tmp, 'utf8'), 'utf8');
59
+ try {
60
+ require('node:fs').rmSync(tmp);
61
+ } catch {}
62
+ } catch {}
63
+ }
64
+ siteEntries(site) {
65
+ return [
66
+ ...this.entries.values()
67
+ ].filter((e)=>e.key.startsWith(site + ':'));
68
+ }
69
+ lookup(key, domFingerprint, site) {
70
+ this.load();
71
+ const e = this.entries.get(key);
72
+ if (!e) return null;
73
+ if (this.now().getTime() - Date.parse(e.lastHitAt || e.createdAt) > this.ttlMs) {
74
+ this.entries.delete(key);
75
+ this.persist();
76
+ return null;
77
+ }
78
+ if (e.domFingerprint !== domFingerprint) return null;
79
+ e.hits += 1;
80
+ e.lastHitAt = this.now().toISOString();
81
+ this.persist();
82
+ void site;
83
+ return e;
84
+ }
85
+ record(key, domFingerprint, locator, site) {
86
+ this.load();
87
+ const ts = this.now().toISOString();
88
+ const prev = this.entries.get(key);
89
+ this.entries.set(key, {
90
+ key,
91
+ domFingerprint,
92
+ locator,
93
+ createdAt: prev?.createdAt ?? ts,
94
+ lastHitAt: ts,
95
+ hits: prev?.hits ?? 0
96
+ });
97
+ const siteList = this.siteEntries(site);
98
+ if (siteList.length > this.maxPerSite) {
99
+ const victim = siteList.sort((a, b)=>Date.parse(a.lastHitAt) - Date.parse(b.lastHitAt))[0];
100
+ if (victim) this.entries.delete(victim.key);
101
+ }
102
+ this.persist();
103
+ return this.entries.get(key);
104
+ }
105
+ invalidate(key) {
106
+ this.load();
107
+ this.entries.delete(key);
108
+ this.persist();
109
+ }
110
+ size(site) {
111
+ this.load();
112
+ return site ? this.siteEntries(site).length : this.entries.size;
113
+ }
114
+ }
115
+ export function actionCachePath(stateRoot) {
116
+ return join(stateRoot, 'session-action-cache.json');
117
+ }
118
+
119
+
120
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/action-cache.ts
@@ -0,0 +1,83 @@
1
+ const CITY_CODES = {
2
+ 上海: 'sha',
3
+ 北京: 'bjs',
4
+ 广州: 'can',
5
+ 深圳: 'szx',
6
+ 成都: 'ctu',
7
+ 昆明: 'kmg',
8
+ 大理: 'dlu',
9
+ 丽江: 'ljg',
10
+ 西安: 'sia',
11
+ 杭州: 'hgh',
12
+ 三亚: 'syx',
13
+ 厦门: 'xmn',
14
+ 重庆: 'cqg',
15
+ 青岛: 'taa',
16
+ 长沙: 'csx',
17
+ 武汉: 'wuh',
18
+ 南京: 'nkg',
19
+ 郑州: 'cgo',
20
+ 贵阳: 'kwe',
21
+ 桂林: 'kwl',
22
+ 西双版纳: 'jhg',
23
+ 香格里拉: 'dig'
24
+ };
25
+ export function buildEntryUrl(from, to, depDate) {
26
+ const unresolved = [];
27
+ const fromCode = CITY_CODES[from];
28
+ const toCode = CITY_CODES[to];
29
+ if (!fromCode) unresolved.push(from);
30
+ if (!toCode) unresolved.push(to);
31
+ if (unresolved.length > 0 || !/^\d{4}-\d{2}-\d{2}$/.test(depDate)) {
32
+ return {
33
+ ok: false,
34
+ unresolved: unresolved.length > 0 ? unresolved : [
35
+ depDate
36
+ ]
37
+ };
38
+ }
39
+ return {
40
+ ok: true,
41
+ url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}`
42
+ };
43
+ }
44
+ export const NETWORK_HINTS = [
45
+ /search\/api\/search\/batchSearch/
46
+ ];
47
+ export const LOGIN_COOKIE_NAMES = [
48
+ 'cticket',
49
+ 'uid',
50
+ 'uname',
51
+ 'passport'
52
+ ];
53
+ export const SITE_DOMAIN = '.ctrip.com';
54
+ export function parseBatchSearch(body) {
55
+ let raw;
56
+ try {
57
+ raw = JSON.parse(body);
58
+ } catch {
59
+ return [];
60
+ }
61
+ const out = [];
62
+ for (const it of raw.data?.flightItineraryList ?? []){
63
+ const seg = it.flightSegments?.[0];
64
+ const fl = seg?.flightList?.[0];
65
+ if (!fl?.flightNo || !fl.departureDateTime) continue;
66
+ const prices = (it.priceList ?? []).map((p)=>Number(p.adultPrice ?? 0)).filter((n)=>n > 0);
67
+ out.push({
68
+ flightNo: fl.flightNo,
69
+ airline: seg?.airlineName ?? '',
70
+ depDateTime: fl.departureDateTime ?? '',
71
+ arrDateTime: fl.arrivalDateTime ?? '',
72
+ depAirport: fl.departureAirportName ?? '',
73
+ arrAirport: fl.arrivalAirportName ?? '',
74
+ durationMin: Number(seg?.duration ?? 0) || 0,
75
+ price: prices.length > 0 ? Math.min(...prices) : 0,
76
+ aircraft: fl.aircraftName
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+
82
+
83
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/adapters/ctrip-flight.ts
@@ -0,0 +1,73 @@
1
+ const CITY_SLUGS = {
2
+ 上海: 'shanghai',
3
+ 北京: 'beijing',
4
+ 广州: 'guangzhou',
5
+ 深圳: 'shenzhen',
6
+ 成都: 'chengdu',
7
+ 昆明: 'kunming',
8
+ 大理: 'dali',
9
+ 丽江: 'lijiang',
10
+ 西安: 'xian',
11
+ 杭州: 'hangzhou',
12
+ 三亚: 'sanya',
13
+ 厦门: 'xiamen',
14
+ 重庆: 'chongqing',
15
+ 青岛: 'qingdao',
16
+ 长沙: 'changsha',
17
+ 武汉: 'wuhan',
18
+ 南京: 'nanjing',
19
+ 郑州: 'zhengzhou',
20
+ 贵阳: 'guiyang',
21
+ 桂林: 'guilin'
22
+ };
23
+ export const SITE_DOMAIN = 'meituan.com';
24
+ export const LOGIN_COOKIE_NAMES = [
25
+ 'lt',
26
+ 'u',
27
+ 'token',
28
+ 'n'
29
+ ];
30
+ export function buildMeituanEntry(city, vertical, keyword) {
31
+ const slug = CITY_SLUGS[city];
32
+ if (!slug) return {
33
+ ok: false,
34
+ unresolved: [
35
+ city
36
+ ]
37
+ };
38
+ const base = vertical === 'hotel' ? `https://hotel.meituan.com/${slug}/` : `https://minsu.meituan.com/${slug}/`;
39
+ return {
40
+ ok: true,
41
+ url: keyword ? base + `?q=${encodeURIComponent(keyword)}` : base
42
+ };
43
+ }
44
+ export const NETWORK_HINTS = [];
45
+ export function parseMeituanSearch(_body) {
46
+ void _body;
47
+ return [];
48
+ }
49
+ export function extractListings(entries, opts = {}) {
50
+ const out = [];
51
+ const roles = new Set([
52
+ 'heading',
53
+ 'listitem',
54
+ 'link',
55
+ 'article'
56
+ ]);
57
+ for (const e of entries){
58
+ if (out.length >= (opts.max ?? 30)) break;
59
+ if (!roles.has(e.role) || !e.name) continue;
60
+ const pm = /¥\s*(\d+)/.exec(e.name);
61
+ if (!pm) continue;
62
+ const name = e.name.replace(/¥\s*\d+.*$/, '').trim().slice(0, 80);
63
+ if (!name) continue;
64
+ out.push({
65
+ name,
66
+ price: Number(pm[1])
67
+ });
68
+ }
69
+ return out;
70
+ }
71
+
72
+
73
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/adapters/meituan-local.ts
@@ -0,0 +1,40 @@
1
+ export function extractA11yEntries(snapshotText, opts = {}) {
2
+ const cap = opts.maxEntries ?? 500;
3
+ const out = [];
4
+ const lines = snapshotText.split('\n');
5
+ for(let i = 0; i < lines.length && out.length < cap; i++){
6
+ const m = /^[\s*-]*\s*([a-zA-Z][\w-]*)(?:\s+"((?:[^"\\]|\\.)*)")?/.exec(lines[i] ?? '');
7
+ if (!m) continue;
8
+ const name = (m[2] ?? '').replace(/\\"/g, '"').slice(0, 200);
9
+ out.push({
10
+ role: m[1].toLowerCase(),
11
+ name,
12
+ line: i
13
+ });
14
+ }
15
+ return out;
16
+ }
17
+ export function filterSubmitEntries(entries, isSubmit) {
18
+ const interactive = new Set([
19
+ 'button',
20
+ 'link',
21
+ 'menuitem',
22
+ 'option'
23
+ ]);
24
+ return entries.filter((e)=>!(interactive.has(e.role) && isSubmit(e.name)));
25
+ }
26
+ export function nameAffinity(a, b) {
27
+ const x = a.trim();
28
+ const y = b.trim();
29
+ if (!x || !y) return 0;
30
+ if (x === y) return 1;
31
+ if (x.includes(y) || y.includes(x)) return 0.8;
32
+ const sa = new Set(x);
33
+ const sb = new Set(y);
34
+ let common = 0;
35
+ for (const ch of sa)if (sb.has(ch)) common += 1;
36
+ return common / Math.max(sa.size, sb.size);
37
+ }
38
+
39
+
40
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/extract.ts
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, appendFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const HARD_PAY_RE = /\/(pay|payment|cashier)(\/|\?|$)/i;
4
+ const WRITE_COMPOUND_RE = /(submitorder|createorder|placeorder|cancelorder|refundorder|orderconfirm|orderpay|confirmpay)/i;
5
+ const WRITE_URL_RE = /\/(order|orders|submit|trade|booking|book|purchase|buy|create|cancel|refund)(\/|\?|$)/i;
6
+ const WRITE_METHODS = new Set([
7
+ 'POST',
8
+ 'PUT',
9
+ 'DELETE',
10
+ 'PATCH'
11
+ ]);
12
+ export const SUBMIT_TEXT_RE = /下单|去支付|支付|预订|订票|购买|提交订单|确认订单/;
13
+ export function classifyRequest(method, url) {
14
+ if (HARD_PAY_RE.test(url) || WRITE_COMPOUND_RE.test(url)) return 'block';
15
+ if (WRITE_METHODS.has(method.toUpperCase()) && WRITE_URL_RE.test(url)) return 'block';
16
+ return 'allow';
17
+ }
18
+ export function isSubmitText(text) {
19
+ return SUBMIT_TEXT_RE.test(text ?? '');
20
+ }
21
+ export async function attachReadGuard(ctx, auditPath) {
22
+ let blocked = 0;
23
+ let total = 0;
24
+ await ctx.route('**/*', async (route)=>{
25
+ const req = route.request();
26
+ total += 1;
27
+ const verdict = classifyRequest(req.method(), req.url());
28
+ if (verdict === 'block') {
29
+ blocked += 1;
30
+ if (auditPath) {
31
+ const entry = {
32
+ ts: new Date().toISOString(),
33
+ kind: 'blocked-write-request',
34
+ method: req.method(),
35
+ url: req.url().slice(0, 400)
36
+ };
37
+ try {
38
+ mkdirSync(dirname(auditPath), {
39
+ recursive: true
40
+ });
41
+ appendFileSync(auditPath, JSON.stringify(entry) + '\n');
42
+ } catch {}
43
+ }
44
+ await route.abort('blockedbyclient');
45
+ return;
46
+ }
47
+ await route.continue();
48
+ });
49
+ return {
50
+ blockedCount: ()=>blocked,
51
+ requestCount: ()=>total
52
+ };
53
+ }
54
+
55
+
56
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/read-guard.ts
@@ -0,0 +1,95 @@
1
+ import { chromium } from 'playwright-core';
2
+ import { attachReadGuard } from './read-guard.js';
3
+ export async function openSession(opts = {}) {
4
+ if (opts.mode === 'cdp' || opts.mode !== 'persistent' && !opts.profileDir) {
5
+ try {
6
+ const { homedir: _hd } = await import('node:os');
7
+ const udd = process.env.CHROME_USER_DATA_DIR ?? `${_hd()}/Library/Application Support/Google/Chrome`;
8
+ let endpoint = `http://127.0.0.1:${opts.cdpPort ?? 9222}`;
9
+ try {
10
+ const { readFileSync: _rf } = await import('node:fs');
11
+ const portFile = `${udd}/DevToolsActivePort`;
12
+ const [port, wsPath] = _rf(portFile, 'utf8').trim().split('\n');
13
+ if (port && wsPath?.startsWith('/devtools/browser/')) endpoint = `ws://127.0.0.1:${port.trim()}${wsPath.trim()}`;
14
+ } catch {}
15
+ const browser = await chromium.connectOverCDP(endpoint, {
16
+ timeout: 4_000
17
+ });
18
+ const context = browser.contexts()[0];
19
+ if (!context) {
20
+ await browser.close().catch(()=>{});
21
+ return {
22
+ ok: false,
23
+ summary: 'cdp attached but no default context'
24
+ };
25
+ }
26
+ let guard;
27
+ try {
28
+ guard = await attachReadGuard(context, opts.auditPath);
29
+ } catch (e) {
30
+ await browser.close().catch(()=>{});
31
+ return {
32
+ ok: false,
33
+ summary: `read-guard attach failed: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}`
34
+ };
35
+ }
36
+ const page = context.pages()[0] ?? await context.newPage();
37
+ return {
38
+ ok: true,
39
+ context,
40
+ page,
41
+ guard,
42
+ close: async ()=>{
43
+ await browser.close().catch(()=>{});
44
+ }
45
+ };
46
+ } catch (e) {
47
+ const msg = e instanceof Error ? e.message.slice(0, 160) : String(e);
48
+ return {
49
+ ok: false,
50
+ summary: `cdp attach 失败(日常 Chrome 未开调试端口):${msg}。开启方法:日常 Chrome 打开 chrome://inspect/#remote-debugging → 打开开关并确认弹窗(Chrome 144+)`
51
+ };
52
+ }
53
+ }
54
+ const { homedir } = await import('node:os');
55
+ const profileDir = opts.profileDir ?? `${homedir()}/.gotry/session-profile`;
56
+ let context;
57
+ try {
58
+ context = await chromium.launchPersistentContext(profileDir, {
59
+ channel: 'chrome',
60
+ headless: opts.headless ?? true,
61
+ viewport: {
62
+ width: 1366,
63
+ height: 850
64
+ }
65
+ });
66
+ } catch (e) {
67
+ return {
68
+ ok: false,
69
+ summary: `chrome launch failed: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}`
70
+ };
71
+ }
72
+ let guard;
73
+ try {
74
+ guard = await attachReadGuard(context, opts.auditPath);
75
+ } catch (e) {
76
+ await context.close().catch(()=>{});
77
+ return {
78
+ ok: false,
79
+ summary: `read-guard attach failed: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}`
80
+ };
81
+ }
82
+ const page = context.pages()[0] ?? await context.newPage();
83
+ return {
84
+ ok: true,
85
+ context,
86
+ page,
87
+ guard,
88
+ close: async ()=>{
89
+ await context.close().catch(()=>{});
90
+ }
91
+ };
92
+ }
93
+
94
+
95
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/transport.ts