@danceiny/gotry 0.0.1-rc.10
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/LICENSE +21 -0
- package/README.md +267 -0
- package/bin/gotry-inner.js +214 -0
- package/bin/gotry-stdio-ask.js +59 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +86 -0
- package/data/flights_2026.json +218 -0
- package/data/golden_erhai.json +92 -0
- package/data/golden_trip_2026.json +75 -0
- package/data/hotels_2026.json +151 -0
- package/data/openflights-skeleton.json +895 -0
- package/data/time-slot-eval.json +384 -0
- package/data/yunnan-pack.json +202 -0
- package/dist/capabilities/agent-reach-bridge.py +90 -0
- package/dist/capabilities/agent-reach-deep.js +172 -0
- package/dist/capabilities/agent-reach.js +204 -0
- package/dist/capabilities/anything.js +139 -0
- package/dist/capabilities/flyai.js +131 -0
- package/dist/capabilities/hbcli.js +143 -0
- package/dist/capabilities/incident-log.js +100 -0
- package/dist/capabilities/opensky.js +74 -0
- package/dist/capabilities/session/action-cache.js +120 -0
- package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
- package/dist/capabilities/session/adapters/meituan-local.js +73 -0
- package/dist/capabilities/session/extract.js +40 -0
- package/dist/capabilities/session/read-guard.js +99 -0
- package/dist/capabilities/session/transport.js +101 -0
- package/dist/capabilities/session-search.js +93 -0
- package/dist/capabilities/weather.js +180 -0
- package/dist/scripts/action-cache-tests.js +107 -0
- package/dist/scripts/agent-reach-deep-tests.js +54 -0
- package/dist/scripts/agent-reach-tests.js +38 -0
- package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
- package/dist/scripts/anything-tests.js +95 -0
- package/dist/scripts/async-collect.js +37 -0
- package/dist/scripts/companion-tests.js +112 -0
- package/dist/scripts/diff-test.js +39 -0
- package/dist/scripts/engine-run.js +14 -0
- package/dist/scripts/engine-tests.js +45 -0
- package/dist/scripts/hbcli-tests.js +101 -0
- package/dist/scripts/incident-tests.js +96 -0
- package/dist/scripts/journey-tests.js +45 -0
- package/dist/scripts/ledger-tests.js +365 -0
- package/dist/scripts/ledger-workflow-crash.js +42 -0
- package/dist/scripts/memory-capture-tests.js +77 -0
- package/dist/scripts/memory-decay-tests.js +83 -0
- package/dist/scripts/memory-metrics.js +24 -0
- package/dist/scripts/nudge-digest.js +83 -0
- package/dist/scripts/opensky-check.js +20 -0
- package/dist/scripts/opensky-tests.js +52 -0
- package/dist/scripts/probe-poi-tests.js +88 -0
- package/dist/scripts/publish-preverify.js +68 -0
- package/dist/scripts/replay-async.js +44 -0
- package/dist/scripts/replay-real.js +38 -0
- package/dist/scripts/replay.js +116 -0
- package/dist/scripts/session-attach-diagnose.js +27 -0
- package/dist/scripts/session-attach-poc.js +65 -0
- package/dist/scripts/session-extract-tests.js +81 -0
- package/dist/scripts/session-tests.js +162 -0
- package/dist/scripts/skeleton-check.js +37 -0
- package/dist/scripts/skeleton-integration-test.js +22 -0
- package/dist/scripts/skills-contract-tests.js +85 -0
- package/dist/scripts/smoke.js +328 -0
- package/dist/scripts/state-cli-tests.js +136 -0
- package/dist/scripts/state-cli.js +248 -0
- package/dist/scripts/time-eval-tests.js +318 -0
- package/dist/scripts/travel-timeline-tests.js +123 -0
- package/dist/scripts/unified-tests.js +49 -0
- package/dist/scripts/weather-tests.js +48 -0
- package/dist/src/bridge.js +34 -0
- package/dist/src/companions.js +112 -0
- package/dist/src/contracts.js +64 -0
- package/dist/src/dsh-llm.js +174 -0
- package/dist/src/engine.js +331 -0
- package/dist/src/index.js +1239 -0
- package/dist/src/journey.js +147 -0
- package/dist/src/loop.js +360 -0
- package/dist/src/memory-capture.js +40 -0
- package/dist/src/memory-decay.js +31 -0
- package/dist/src/memory-utility.js +55 -0
- package/dist/src/mock-llm.js +103 -0
- package/dist/src/model.js +134 -0
- package/dist/src/slot-spec.js +165 -0
- package/dist/src/state-ledger.js +901 -0
- package/dist/src/time-anchor.js +100 -0
- package/dist/src/tool-packet.js +12 -0
- package/dist/src/travel-slots.js +144 -0
- package/dist/src/travel-timeline.js +78 -0
- package/dist/src/unified.js +538 -0
- package/dist/src/wish-pool.js +27 -0
- package/package.json +84 -0
- package/ts/capabilities/agent-reach-deep.ts +114 -0
- package/ts/capabilities/agent-reach.ts +158 -0
- package/ts/capabilities/anything.ts +201 -0
- package/ts/capabilities/flyai.ts +151 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +148 -0
- package/ts/capabilities/opensky.ts +121 -0
- package/ts/capabilities/session/action-cache.ts +148 -0
- package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
- package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
- package/ts/capabilities/session/extract.ts +54 -0
- package/ts/capabilities/session/read-guard.ts +141 -0
- package/ts/capabilities/session/transport.ts +114 -0
- package/ts/capabilities/session-search.ts +129 -0
- package/ts/capabilities/weather.ts +176 -0
- package/ts/package.json +27 -0
- package/ts/scripts/async-collect.ts +50 -0
- package/ts/scripts/skeleton-check.ts +49 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/scripts/state-cli.ts +201 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/companions.ts +113 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +155 -0
- package/ts/src/index.ts +968 -0
- package/ts/src/loop.ts +411 -0
- package/ts/src/memory-capture.ts +59 -0
- package/ts/src/memory-decay.ts +55 -0
- package/ts/src/memory-utility.ts +70 -0
- package/ts/src/mock-llm.ts +100 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/slot-spec.ts +172 -0
- package/ts/src/state-ledger.ts +849 -0
- package/ts/src/time-anchor.ts +134 -0
- package/ts/src/tool-packet.ts +32 -0
- package/ts/src/travel-slots.ts +219 -0
- package/ts/src/travel-timeline.ts +83 -0
- package/ts/src/unified.ts +582 -0
- package/ts/src/wish-pool.ts +66 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 美团本地适配器骨架(民宿/门票——官方通道盲区,RFC §3.1)。
|
|
3
|
+
*
|
|
4
|
+
* 实测边界(2026-08-28 tick):匿名实例 hotel.meituan.com 直接 403——三站中最强反爬,
|
|
5
|
+
* **登录态是硬前置**(founder 纠偏「不能匿名实例」在美团侧是 403 级事实,非策略偏好)。
|
|
6
|
+
* 本骨架:entry 城市拼音表 + 登录票据名单 + networkHint 占位;live 接口形状待用户登录态
|
|
7
|
+
* (profile 内用户自登美团)后由心跳轮实测回填——在此之前 parse 返回空、verdict=miss。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 城市拼音表(美团 URL 路径段;与 ctrip-flight 城市码表同界:词表外 unresolved 不猜) */
|
|
11
|
+
const CITY_SLUGS: Record<string, string> = {
|
|
12
|
+
上海: 'shanghai', 北京: 'beijing', 广州: 'guangzhou', 深圳: 'shenzhen', 成都: 'chengdu',
|
|
13
|
+
昆明: 'kunming', 大理: 'dali', 丽江: 'lijiang', 西安: 'xian', 杭州: 'hangzhou',
|
|
14
|
+
三亚: 'sanya', 厦门: 'xiamen', 重庆: 'chongqing', 青岛: 'qingdao', 长沙: 'changsha',
|
|
15
|
+
武汉: 'wuhan', 南京: 'nanjing', 郑州: 'zhengzhou', 贵阳: 'guiyang', 桂林: 'guilin',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SITE_DOMAIN = 'meituan.com'
|
|
19
|
+
/** 登录票据 cookie 名单(公开常识:lt=login ticket/u=uid/token;登录后校准,同 ctrip 侧做法) */
|
|
20
|
+
export const LOGIN_COOKIE_NAMES = ['lt', 'u', 'token', 'n']
|
|
21
|
+
|
|
22
|
+
export type MeituanVertical = 'hotel' | 'minsu'
|
|
23
|
+
|
|
24
|
+
export interface MeituanEntry {
|
|
25
|
+
ok: boolean
|
|
26
|
+
url?: string
|
|
27
|
+
unresolved?: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildMeituanEntry(city: string, vertical: MeituanVertical, keyword?: string): MeituanEntry {
|
|
31
|
+
const slug = CITY_SLUGS[city]
|
|
32
|
+
if (!slug) return { ok: false, unresolved: [city] }
|
|
33
|
+
const base = vertical === 'hotel' ? `https://hotel.meituan.com/${slug}/` : `https://minsu.meituan.com/${slug}/`
|
|
34
|
+
return { ok: true, url: keyword ? base + `?q=${encodeURIComponent(keyword)}` : base }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** networkHint 占位:登录态实测后回填真实搜索接口 pattern(当前为空=嗅探层不命中,a11y 兜底接管) */
|
|
38
|
+
export const NETWORK_HINTS: RegExp[] = []
|
|
39
|
+
|
|
40
|
+
export interface MeituanListingOption {
|
|
41
|
+
name: string
|
|
42
|
+
price: number
|
|
43
|
+
/** 元数据行(位置/评分/房型,来自 a11y 条目) */
|
|
44
|
+
meta?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** XHR JSON 解析占位:接口形状实测回填前恒返空(调用方走 a11y 兜底) */
|
|
48
|
+
export function parseMeituanSearch(_body: string): MeituanListingOption[] {
|
|
49
|
+
void _body
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* a11y 兜底:从快照条目抽「名称+价格」候选(纯函数;供 extract.ts 条目流消费)。
|
|
55
|
+
* 形状规则:名称=非空 heading/listitem/link;价格=条目名内 ¥\d+(宽松元级);不做语义猜测。
|
|
56
|
+
*/
|
|
57
|
+
export function extractListings(entries: Array<{ role: string; name: string }>, opts: { max?: number } = {}): MeituanListingOption[] {
|
|
58
|
+
const out: MeituanListingOption[] = []
|
|
59
|
+
const roles = new Set(['heading', 'listitem', 'link', 'article'])
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (out.length >= (opts.max ?? 30)) break
|
|
62
|
+
if (!roles.has(e.role) || !e.name) continue
|
|
63
|
+
const pm = /¥\s*(\d+)/.exec(e.name)
|
|
64
|
+
if (!pm) continue
|
|
65
|
+
const name = e.name.replace(/¥\s*\d+.*$/, '').trim().slice(0, 80)
|
|
66
|
+
if (!name) continue
|
|
67
|
+
out.push({ name, price: Number(pm[1]) })
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a11y 快照兜底抽取器(RFC §2.3 第②层:站点无干净 XHR JSON 时,从可访问性树快照抽结构化条目)。
|
|
3
|
+
* 纯函数:输入 Playwright ariaSnapshot/domSnapshot 文本,输出归一化条目(role/name/ref),
|
|
4
|
+
* 供确定性规则或 LLM 按 schema 消费;不做任何页面交互。
|
|
5
|
+
*
|
|
6
|
+
* 辑律(ADR-12 同款边界):抽取只认结构(行首 role 与缩进),不做开放式语义猜测;
|
|
7
|
+
* 页面文本是不可信输入(RFC §3.5)——本层只归形状,语义判定在上游 schema 处。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface A11yEntry {
|
|
11
|
+
/** 行首 role,如 button / textbox / link / heading / listitem */
|
|
12
|
+
role: string
|
|
13
|
+
/** 括号内 accessible name(去引号,截断 200) */
|
|
14
|
+
name: string
|
|
15
|
+
/** 快照行号(0 基,回指原快照) */
|
|
16
|
+
line: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Playwright aria snapshot 行形如:
|
|
21
|
+
* `- button "搜索" [ref=s1e5]`
|
|
22
|
+
* `- heading "上海到丽江" [level=1]`
|
|
23
|
+
* 提取 role/name;无 name 的行(name='')保留占位(计数/结构比对仍可用)。
|
|
24
|
+
*/
|
|
25
|
+
export function extractA11yEntries(snapshotText: string, opts: { maxEntries?: number } = {}): A11yEntry[] {
|
|
26
|
+
const cap = opts.maxEntries ?? 500
|
|
27
|
+
const out: A11yEntry[] = []
|
|
28
|
+
const lines = snapshotText.split('\n')
|
|
29
|
+
for (let i = 0; i < lines.length && out.length < cap; i++) {
|
|
30
|
+
const m = /^[\s*-]*\s*([a-zA-Z][\w-]*)(?:\s+"((?:[^"\\]|\\.)*)")?/.exec(lines[i] ?? '')
|
|
31
|
+
if (!m) continue
|
|
32
|
+
const name = (m[2] ?? '').replace(/\\"/g, '"').slice(0, 200)
|
|
33
|
+
out.push({ role: m[1]!.toLowerCase(), name, line: i })
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** DOM 提交件过滤入口(RFC §3.3-②):从条目集中剔除命中提交黑名单的可交互件,返回保留集 */
|
|
39
|
+
export function filterSubmitEntries<T extends { role: string; name: string }>(entries: T[], isSubmit: (name: string) => boolean): T[] {
|
|
40
|
+
const interactive = new Set(['button', 'link', 'menuitem', 'option'])
|
|
41
|
+
return entries.filter((e) => !(interactive.has(e.role) && isSubmit(e.name)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 名称相似度粗筛(自愈定位的候选排序底座;0=不同串,1=包含) */
|
|
45
|
+
export function nameAffinity(a: string, b: string): number {
|
|
46
|
+
const x = a.trim(); const y = b.trim()
|
|
47
|
+
if (!x || !y) return 0
|
|
48
|
+
if (x === y) return 1
|
|
49
|
+
if (x.includes(y) || y.includes(x)) return 0.8
|
|
50
|
+
const sa = new Set(x); const sb = new Set(y)
|
|
51
|
+
let common = 0
|
|
52
|
+
for (const ch of sa) if (sb.has(ch)) common += 1
|
|
53
|
+
return common / Math.max(sa.size, sb.size)
|
|
54
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReadGuard:会话检索面的只读物理隔离(RFC user-session-data-rfc.md §3.3)。
|
|
3
|
+
*
|
|
4
|
+
* 写不是「被禁止的行为」,是「不存在的原语」——WriteGate(L0-L4)的检索态前置:
|
|
5
|
+
* ① 网络层:route 拦截——命中硬支付模式(任意方法)或写方法+写URL模式 → abort + 审计落盘;
|
|
6
|
+
* ② DOM 层:提交类按钮文本黑名单,从快照/断言文本中剔除(模型看不到可点的提交件);
|
|
7
|
+
* ③ fail-closed:guard 未装上,session transport 不允许导航(openSession 装不上即整会话失败)。
|
|
8
|
+
*
|
|
9
|
+
* 审计:append-only JSONL(session-incidents.jsonl,对齐 incident-log 惯例),隔离 stateRoot。
|
|
10
|
+
* 注意:搜索接口多为 POST(如携程 batchSearch),因此写判定必须「方法 × URL 模式」双因子,
|
|
11
|
+
* 不能一刀切禁 POST——batchSearch 不含写词,放行。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, appendFileSync } from 'node:fs'
|
|
15
|
+
import { dirname } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** 硬支付模式:任意方法(含 GET)都视为写——进支付/收银面本身就是风险 */
|
|
18
|
+
const HARD_PAY_RE = /\/(pay|payment|cashier)(\/|\?|$)/i
|
|
19
|
+
/** 驼峰复合写词(OTA 常见 /submitOrder、/createOrder):段式正则漏接,单列 */
|
|
20
|
+
const WRITE_COMPOUND_RE = /(submitorder|createorder|placeorder|cancelorder|refundorder|orderconfirm|orderpay|confirmpay)/i
|
|
21
|
+
/** 写词 URL 模式:仅在写方法(POST/PUT/DELETE/PATCH)下触发 */
|
|
22
|
+
const WRITE_URL_RE = /\/(order|orders|submit|trade|booking|book|purchase|buy|create|cancel|refund)(\/|\?|$)/i
|
|
23
|
+
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH'])
|
|
24
|
+
/** DOM 提交件文本黑名单(§3.3-②):命中即从可交互面剔除 */
|
|
25
|
+
export const SUBMIT_TEXT_RE = /下单|去支付|支付|预订|订票|购买|提交订单|确认订单/
|
|
26
|
+
|
|
27
|
+
/** 网络层写判定(纯函数,测试锚点) */
|
|
28
|
+
export function classifyRequest(method: string, url: string): 'allow' | 'block' {
|
|
29
|
+
if (HARD_PAY_RE.test(url) || WRITE_COMPOUND_RE.test(url)) return 'block'
|
|
30
|
+
if (WRITE_METHODS.has(method.toUpperCase()) && WRITE_URL_RE.test(url)) return 'block'
|
|
31
|
+
return 'allow'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** DOM 层提交件过滤(纯函数,测试锚点):true=剔除 */
|
|
35
|
+
export function isSubmitText(text: string): boolean {
|
|
36
|
+
return SUBMIT_TEXT_RE.test(text ?? '')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GuardAuditEntry {
|
|
40
|
+
ts: string
|
|
41
|
+
kind: 'blocked-write-request'
|
|
42
|
+
method: string
|
|
43
|
+
url: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReadGuardHandle {
|
|
47
|
+
/** 被拦截的写请求数(测试断言锚点) */
|
|
48
|
+
blockedCount(): number
|
|
49
|
+
/** 经手的请求总数 */
|
|
50
|
+
requestCount(): number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RoutableContext {
|
|
54
|
+
route(glob: string, handler: (route: { request(): { method(): string; url(): string }; abort(code?: string): Promise<void>; continue(): Promise<void> }) => Promise<void>): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 装上网络层守卫(fail-closed 的「closed」在调用方:openSession 装不上 guard 即失败,不给无 guard 的会话)。
|
|
59
|
+
* 审计落 auditPath(JSONL append);auditPath 缺省不落盘(仅内存计数)。
|
|
60
|
+
*/
|
|
61
|
+
export async function attachReadGuard(ctx: RoutableContext, auditPath?: string): Promise<ReadGuardHandle> {
|
|
62
|
+
let blocked = 0
|
|
63
|
+
let total = 0
|
|
64
|
+
await ctx.route('**/*', async (route) => {
|
|
65
|
+
const req = route.request()
|
|
66
|
+
total += 1
|
|
67
|
+
const verdict = classifyRequest(req.method(), req.url())
|
|
68
|
+
if (verdict === 'block') {
|
|
69
|
+
blocked += 1
|
|
70
|
+
if (auditPath) {
|
|
71
|
+
const entry: GuardAuditEntry = { ts: new Date().toISOString(), kind: 'blocked-write-request', method: req.method(), url: req.url().slice(0, 400) }
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(auditPath), { recursive: true })
|
|
74
|
+
appendFileSync(auditPath, JSON.stringify(entry) + '\n')
|
|
75
|
+
} catch { /* 审计失败不阻塞拦截本身 */ }
|
|
76
|
+
}
|
|
77
|
+
await route.abort('blockedbyclient')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
await route.continue()
|
|
81
|
+
})
|
|
82
|
+
return {
|
|
83
|
+
blockedCount: () => blocked,
|
|
84
|
+
requestCount: () => total,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ============ puppeteer-core 适配(2026-08-28 传输层定案:Chrome 144+ 调试服务与 playwright 不兼容) ============ */
|
|
89
|
+
|
|
90
|
+
interface PpRequest {
|
|
91
|
+
method(): string
|
|
92
|
+
url(): string
|
|
93
|
+
continue(): Promise<void>
|
|
94
|
+
abort(code?: string): Promise<void>
|
|
95
|
+
}
|
|
96
|
+
interface PpPage {
|
|
97
|
+
setRequestInterception(on: boolean): Promise<void>
|
|
98
|
+
on(event: 'request', handler: (req: PpRequest) => void): void
|
|
99
|
+
}
|
|
100
|
+
interface PpBrowser {
|
|
101
|
+
pages(): Promise<PpPage[]>
|
|
102
|
+
on(event: string, handler: (target: { type(): string; page(): Promise<PpPage> }) => void): unknown
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* puppeteer 版守卫:对既有页面与新建页面全部开启请求拦截,写请求 abort + 审计。
|
|
107
|
+
* 与 playwright 版共用 classifyRequest 判定与审计条目(单一事实源)。
|
|
108
|
+
*/
|
|
109
|
+
export async function attachReadGuardPuppeteer(browser: PpBrowser, auditPath?: string): Promise<ReadGuardHandle> {
|
|
110
|
+
let blocked = 0
|
|
111
|
+
let total = 0
|
|
112
|
+
const wire = (page: PpPage): void => {
|
|
113
|
+
void (async () => {
|
|
114
|
+
try {
|
|
115
|
+
await page.setRequestInterception(true)
|
|
116
|
+
page.on('request', async (req) => {
|
|
117
|
+
total += 1
|
|
118
|
+
const verdict = classifyRequest(req.method(), req.url())
|
|
119
|
+
if (verdict === 'block') {
|
|
120
|
+
blocked += 1
|
|
121
|
+
if (auditPath) {
|
|
122
|
+
const entry: GuardAuditEntry = { ts: new Date().toISOString(), kind: 'blocked-write-request', method: req.method(), url: req.url().slice(0, 400) }
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(dirname(auditPath), { recursive: true })
|
|
125
|
+
appendFileSync(auditPath, JSON.stringify(entry) + '\n')
|
|
126
|
+
} catch { /* 审计失败不阻塞拦截本身 */ }
|
|
127
|
+
}
|
|
128
|
+
await req.abort('blockedbyclient').catch(() => { /* 目标已销毁等竞态 */ })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
await req.continue().catch(() => { /* 竞态容忍 */ })
|
|
132
|
+
})
|
|
133
|
+
} catch { /* 已关闭的 target 容忍 */ }
|
|
134
|
+
})()
|
|
135
|
+
}
|
|
136
|
+
for (const p of await browser.pages()) wire(p)
|
|
137
|
+
browser.on('targetcreated', (t) => {
|
|
138
|
+
if (t.type() === 'page') void t.page().then(wire).catch(() => { /* ignore */ })
|
|
139
|
+
})
|
|
140
|
+
return { blockedCount: () => blocked, requestCount: () => total }
|
|
141
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionTransport(会话检索传输层,RFC §3.2;2026-08-28 定案 puppeteer-core):
|
|
3
|
+
* - mode=cdp(默认):attach 用户日常 Chrome——Chrome 144+ 调试服务(chrome://inspect 开关)
|
|
4
|
+
* 与 playwright 不兼容(握手后 CDP 初始化悬挂,实测三轮),与 puppeteer 完全兼容(实测 2256 cookies)。
|
|
5
|
+
* 发现走 user-data-dir 的 DevToolsActivePort 文件(HTTP 发现面已硬化 404;Origin 头=403,安全设计)。
|
|
6
|
+
* close() 只 disconnect,绝不关用户浏览器。
|
|
7
|
+
* - mode=persistent(测试/后备):puppeteer.launch 专用 profile(测试用 mktemp 隔离匿名态)。
|
|
8
|
+
* fail-closed(RFC §3.3-④):guard 装不上 = 整个会话打不开——不存在「无守卫的会话」这一形态。
|
|
9
|
+
* 永不抛错:任何启动失败降级为 TransportFailure,由 session-search 编排层消费。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { attachReadGuardPuppeteer, type ReadGuardHandle } from './read-guard.ts'
|
|
15
|
+
import type { Browser, Page } from 'puppeteer-core'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* puppeteer-core 为可选依赖(D-14:产品运行时零硬依赖,缺则优雅降级)。
|
|
19
|
+
* 顶层动态 import——npm 形态若未装 puppeteer-core,openSession 返回 TransportFailure
|
|
20
|
+
* 而非在模块加载期炸掉整个插件(2026-08-28 发布面实测:静态 import 会拖垮 index.ts 加载链)。
|
|
21
|
+
*/
|
|
22
|
+
async function loadPuppeteer(): Promise<typeof import('puppeteer-core') | { missing: true }> {
|
|
23
|
+
try {
|
|
24
|
+
return await import('puppeteer-core')
|
|
25
|
+
} catch {
|
|
26
|
+
return { missing: true }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TransportFailure {
|
|
31
|
+
ok: false
|
|
32
|
+
summary: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SessionTransport {
|
|
36
|
+
ok: true
|
|
37
|
+
browser: Browser
|
|
38
|
+
page: Page
|
|
39
|
+
guard: ReadGuardHandle
|
|
40
|
+
close(): Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TransportOptions {
|
|
44
|
+
/** 传输模式:cdp=attach 用户日常 Chrome(默认,登录态=用户本人,founder 2026-08-28 定案);persistent=专用 profile(测试/后备) */
|
|
45
|
+
mode?: 'cdp' | 'persistent'
|
|
46
|
+
/** persistent 模式专用 profile 目录(测试用 mktemp 隔离匿名态;缺省 tmpdir) */
|
|
47
|
+
profileDir?: string
|
|
48
|
+
/** persistent 模式 headless(默认 true);cdp 模式恒为用户可见窗口 */
|
|
49
|
+
headless?: boolean
|
|
50
|
+
/** ReadGuard 审计落盘路径(缺省仅内存计数) */
|
|
51
|
+
auditPath?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function devtoolsWsEndpoint(): { ws: string } | { err: string } {
|
|
55
|
+
try {
|
|
56
|
+
const udd = process.env.CHROME_USER_DATA_DIR ?? `${homedir()}/Library/Application Support/Google/Chrome`
|
|
57
|
+
const [port, wsPath] = readFileSync(`${udd}/DevToolsActivePort`, 'utf8').trim().split('\n')
|
|
58
|
+
if (!port || !wsPath?.startsWith('/devtools/browser/')) {
|
|
59
|
+
return { err: `DevToolsActivePort 内容异常: ${String(port)}/${String(wsPath)}` }
|
|
60
|
+
}
|
|
61
|
+
return { ws: `ws://127.0.0.1:${port.trim()}${wsPath.trim()}` }
|
|
62
|
+
} catch {
|
|
63
|
+
return { err: '日常 Chrome 未开调试端口。开启方法:Chrome 打开 chrome://inspect/#remote-debugging → 打开开关(Chrome 144+)' }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function openSession(opts: TransportOptions = {}): Promise<SessionTransport | TransportFailure> {
|
|
68
|
+
const pp = await loadPuppeteer()
|
|
69
|
+
if ('missing' in pp) {
|
|
70
|
+
return { ok: false, summary: 'puppeteer-core 未安装(可选依赖):会话检索不可用,官方通道 gotry_flyai_search 仍可用。安装:npm i -D puppeteer-core' }
|
|
71
|
+
}
|
|
72
|
+
const puppeteer = pp.default
|
|
73
|
+
const useCdp = opts.mode === 'cdp' || (opts.mode !== 'persistent' && !opts.profileDir)
|
|
74
|
+
let browser: Browser
|
|
75
|
+
let isCdp = false
|
|
76
|
+
if (useCdp) {
|
|
77
|
+
const d = devtoolsWsEndpoint()
|
|
78
|
+
if ('err' in d) return { ok: false, summary: d.err }
|
|
79
|
+
try {
|
|
80
|
+
browser = await puppeteer.connect({ browserWSEndpoint: d.ws, defaultViewport: null })
|
|
81
|
+
isCdp = true
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const msg = e instanceof Error ? e.message : (e as { message?: string })?.message ?? JSON.stringify(e).slice(0, 200)
|
|
84
|
+
return { ok: false, summary: `cdp attach 失败:${String(msg).split('\n')[0]}` }
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
const { tmpdir } = await import('node:os')
|
|
88
|
+
const profileDir = opts.profileDir ?? `${tmpdir()}/gotry-session-profile`
|
|
89
|
+
try {
|
|
90
|
+
browser = await puppeteer.launch({ channel: 'chrome', headless: opts.headless ?? true, userDataDir: profileDir })
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { ok: false, summary: `chrome launch failed: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}` }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// fail-closed:guard 装不上即断开返回失败——不给无守卫的导航面
|
|
96
|
+
let guard: ReadGuardHandle
|
|
97
|
+
try {
|
|
98
|
+
guard = await attachReadGuardPuppeteer(browser as unknown as Parameters<typeof attachReadGuardPuppeteer>[0], opts.auditPath)
|
|
99
|
+
} catch (e) {
|
|
100
|
+
await (isCdp ? browser.disconnect() : browser.close()).catch(() => { /* ignore */ })
|
|
101
|
+
return { ok: false, summary: `read-guard attach failed: ${e instanceof Error ? e.message.split('\n')[0] : String(e)}` }
|
|
102
|
+
}
|
|
103
|
+
const page = (await browser.pages())[0] ?? (await browser.newPage())
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
browser,
|
|
107
|
+
page,
|
|
108
|
+
guard,
|
|
109
|
+
close: async () => {
|
|
110
|
+
// cdp:只断开连接,绝不关用户浏览器;persistent:关自己拉起的实例
|
|
111
|
+
await (isCdp ? browser.disconnect() : browser.close()).catch(() => { /* ignore */ })
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话检索编排层(RFC §3.2):transport → adapter entry → networkHint 嗅探 → 解析 → ReadGuard → 证据链。
|
|
3
|
+
*
|
|
4
|
+
* 证据链(L4 增补):[会话:ctrip-flight@ts] = 用户本人会话内实时检索,非官方 API;
|
|
5
|
+
* 风控命中(verdict='challenged')= degraded,绝不重试、绝不绕过(合规支柱②)。
|
|
6
|
+
* 节律(§3.4):同站点 ≥30s 间隔 + 单调冷却;超间隔返回 verdict='cooldown'。
|
|
7
|
+
* 永不抛错;transport 必带 ReadGuard(fail-closed);测试/巡检用隔离 profile 与 stateRoot。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { openSession } from './session/transport.ts'
|
|
11
|
+
import { buildEntryUrl, NETWORK_HINTS, parseBatchSearch, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
|
|
12
|
+
|
|
13
|
+
export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach'
|
|
14
|
+
|
|
15
|
+
export interface SessionSearchResult {
|
|
16
|
+
ok: boolean
|
|
17
|
+
via: 'session-ctrip-flight' | 'session-ctrip-flight-error'
|
|
18
|
+
evidence: string
|
|
19
|
+
latencyMs: number
|
|
20
|
+
verdict: SessionVerdict
|
|
21
|
+
options?: SessionFlightOption[]
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionFlightQuery {
|
|
26
|
+
from: string
|
|
27
|
+
to: string
|
|
28
|
+
/** YYYY-MM-DD */
|
|
29
|
+
date: string
|
|
30
|
+
/** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
|
|
31
|
+
profileDir?: string
|
|
32
|
+
headless?: boolean
|
|
33
|
+
/** ReadGuard 审计路径(测试传隔离 stateRoot 下) */
|
|
34
|
+
auditPath?: string
|
|
35
|
+
/** 等 networkHint 回包的上限,默认 25_000 */
|
|
36
|
+
timeoutMs?: number
|
|
37
|
+
/** 允许匿名实例(默认 false——用户自己的账号是本面的存在前提);true 仅用于适配器链路自检,证据链会标 anonymous */
|
|
38
|
+
allowAnonymous?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 节律闸(§3.4):同站点最小间隔;导出仅测试用 */
|
|
42
|
+
const MIN_INTERVAL_MS = 30_000
|
|
43
|
+
const lastCallAt = new Map<string, number>()
|
|
44
|
+
export function __resetRateLimiterForTest(): void {
|
|
45
|
+
lastCallAt.clear()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CHALLENGE_RE = /验证|滑块|captcha|verify/i
|
|
49
|
+
|
|
50
|
+
export async function sessionFlightSearch(q: SessionFlightQuery): Promise<SessionSearchResult> {
|
|
51
|
+
const started = Date.now()
|
|
52
|
+
const ts = new Date().toISOString()
|
|
53
|
+
const site = 'ctrip-flight'
|
|
54
|
+
const err = (verdict: SessionVerdict, error: string): SessionSearchResult => ({
|
|
55
|
+
ok: false, via: 'session-ctrip-flight-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// 节律闸:超间隔即拒,不发起导航
|
|
59
|
+
const last = lastCallAt.get(site) ?? 0
|
|
60
|
+
if (Date.now() - last < MIN_INTERVAL_MS) {
|
|
61
|
+
return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
|
|
62
|
+
}
|
|
63
|
+
lastCallAt.set(site, Date.now())
|
|
64
|
+
|
|
65
|
+
const entry = buildEntryUrl(q.from, q.to, q.date)
|
|
66
|
+
if (!entry.ok || !entry.url) {
|
|
67
|
+
return err('error', `unresolved entry: ${(entry.unresolved ?? []).join('/')} 不在城市码表`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: q.profileDir ? 'persistent' : 'cdp' })
|
|
71
|
+
if (!t.ok) {
|
|
72
|
+
// cdp 未开端口 → needs-attach(一次性用户动作);persistent 启动失败仍走 error
|
|
73
|
+
if (q.profileDir === undefined && /cdp attach 失败/.test(t.summary)) return err('needs-attach', t.summary)
|
|
74
|
+
return err('error', t.summary)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 登录态闸:用户自己的账号,不是匿名实例——匿名态按 onAnonymous 处置(默认 fail)
|
|
79
|
+
const loggedIn = async (): Promise<boolean> => {
|
|
80
|
+
const cookies = await t.browser.cookies().catch(() => [])
|
|
81
|
+
return cookies.some((c) => c.domain.includes(SITE_DOMAIN.replace(/^\./, '')) && LOGIN_COOKIE_NAMES.includes(c.name))
|
|
82
|
+
}
|
|
83
|
+
if (!(await loggedIn()) && !q.allowAnonymous) {
|
|
84
|
+
return err('needs-login', '匿名实例——先跑 scripts/session-login.ts 用用户自己的账号建立登录态(allowAnonymous 仅限链路自检)')
|
|
85
|
+
}
|
|
86
|
+
// 先挂监听再导航(Playwright network 模式):命中 networkHint 的第一个响应即搜索回包
|
|
87
|
+
let settled = false
|
|
88
|
+
let body = ''
|
|
89
|
+
const heard = new Promise<void>((resolve) => {
|
|
90
|
+
t.page.on('response', async (res) => {
|
|
91
|
+
if (settled) return
|
|
92
|
+
const u = res.url()
|
|
93
|
+
if (!NETWORK_HINTS.some((re) => re.test(u))) return
|
|
94
|
+
try {
|
|
95
|
+
body = await res.text()
|
|
96
|
+
} catch { /* 流式/竞态不可读则继续等下一个 */ }
|
|
97
|
+
if (body) {
|
|
98
|
+
settled = true
|
|
99
|
+
resolve()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
// 超时也结算:miss/error 由后续判定
|
|
103
|
+
setTimeout(() => resolve(), q.timeoutMs ?? 25_000)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await t.page.goto(entry.url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
|
|
107
|
+
await heard
|
|
108
|
+
|
|
109
|
+
const title = await t.page.title().catch(() => '')
|
|
110
|
+
const headHtml = (await t.page.content().catch(() => '')).slice(0, 5000)
|
|
111
|
+
if (CHALLENGE_RE.test(title + headHtml)) {
|
|
112
|
+
return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
|
|
113
|
+
}
|
|
114
|
+
const options = parseBatchSearch(body)
|
|
115
|
+
const verdict: SessionVerdict = options.length > 0 ? 'hit' : 'miss'
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
via: 'session-ctrip-flight',
|
|
119
|
+
evidence: `[会话:${site}@${ts}] ${options.length} options;guard blocked=${t.guard.blockedCount()}/${t.guard.requestCount()}${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
|
|
120
|
+
latencyMs: Date.now() - started,
|
|
121
|
+
verdict,
|
|
122
|
+
options,
|
|
123
|
+
}
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return err('error', e instanceof Error ? e.message.slice(0, 200) : String(e))
|
|
126
|
+
} finally {
|
|
127
|
+
await t.close()
|
|
128
|
+
}
|
|
129
|
+
}
|