@danceiny/gotry 0.0.1-rc.11 → 0.0.1-rc.13
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 +111 -146
- package/cordis.gotry-patch.yml +14 -7
- package/dist/capabilities/flyai.js +135 -30
- package/dist/capabilities/hbcli.js +14 -2
- package/dist/capabilities/session/benchmark.js +252 -0
- package/dist/capabilities/session/transport.js +25 -10
- package/dist/capabilities/session-consent.js +82 -0
- package/dist/capabilities/session-login.js +119 -0
- package/dist/capabilities/session-search.js +7 -4
- package/dist/capabilities/weather.js +72 -17
- package/dist/scripts/agent-reach-wrapper-tests.js +4 -0
- package/dist/scripts/async-collect.js +33 -5
- package/dist/scripts/hbcli-tests.js +25 -4
- package/dist/scripts/ledger-tests.js +134 -4
- package/dist/scripts/memory-value-report.js +379 -0
- package/dist/scripts/product-metrics.js +569 -0
- package/dist/scripts/session-benchmark.js +261 -0
- package/dist/scripts/session-login.js +28 -0
- package/dist/scripts/session-tests.js +262 -23
- package/dist/scripts/smoke.js +112 -14
- package/dist/scripts/weather-tests.js +8 -1
- package/dist/src/index.js +124 -5
- package/dist/src/loop.js +65 -14
- package/dist/src/state-ledger.js +30 -4
- package/package.json +4 -2
- package/ts/capabilities/flyai.ts +177 -22
- package/ts/capabilities/hbcli.ts +16 -4
- package/ts/capabilities/session/benchmark.ts +273 -0
- package/ts/capabilities/session/transport.ts +41 -9
- package/ts/capabilities/session-consent.ts +127 -0
- package/ts/capabilities/session-login.ts +146 -0
- package/ts/capabilities/session-search.ts +13 -5
- package/ts/capabilities/weather.ts +84 -19
- package/ts/scripts/async-collect.ts +48 -7
- package/ts/src/index.ts +107 -11
- package/ts/src/loop.ts +85 -12
- package/ts/src/state-ledger.ts +48 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionConsent——账号会话授权闸(RFC 合规支柱④「用户明示授权 + 站点白名单 + 随时可关」进代码)。
|
|
3
|
+
*
|
|
4
|
+
* founder 口径(2026-08-29):「OTA 这些都是工具,不要区分什么主路径/降级路径;
|
|
5
|
+
* 这要用到用户的账号,所以必须跟用户确认(请求授权)。」
|
|
6
|
+
* 运行时原生审批卡语义是 allowed-once(逐调用批准)——首日落地后 founder 实测纠偏:
|
|
7
|
+
* 「每次都要弹,经常无法点击」→ 逐次弹卡把授权变成了骚扰。授权模型 v2:
|
|
8
|
+
*
|
|
9
|
+
* - 每会话每站点**首次调用**弹审批卡;allowed-once 记入该 agent 的 granted 集,
|
|
10
|
+
* 会话内同站点后续调用直接放行(不再弹);
|
|
11
|
+
* - rejected/cancelled 视为**会话内吊销**(denied 集):本会话不再弹卡、不再执行,
|
|
12
|
+
* 返回值明确告诉模型「改走其他工具,不要反复尝试」——防止反复开页骚扰用户;
|
|
13
|
+
* - 审批通道缺席(unavailable,如 headless 一问一答)→ deny 且**不**记 denied
|
|
14
|
+
* (通道回来后还能问一次);
|
|
15
|
+
* - sessionAccess = 'ask'(默认)| 'allow'(用户已明示预授权,放行)| 'off'(随时可关);
|
|
16
|
+
* - 授权状态存 Weak<agent>——会话结束即遗忘,绝不跨会话持久化(明示授权不默认延续)。
|
|
17
|
+
* 审计:批准/拒绝事件由 dsh ApprovalService 落 session log(approval/asked + decided),
|
|
18
|
+
* 本模块不重复建账。站点白名单 = ACCOUNT_TOOLS 注册表(新会话适配器接入时登记)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
22
|
+
|
|
23
|
+
export type SessionAccess = 'ask' | 'allow' | 'off'
|
|
24
|
+
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
|
25
|
+
|
|
26
|
+
/** dsh-tools PreToolDecision 同构(自持类型,纯函数可离线测试) */
|
|
27
|
+
export type ConsentDecision =
|
|
28
|
+
| { kind: 'allow' }
|
|
29
|
+
| { kind: 'deny'; reason: string }
|
|
30
|
+
| { kind: 'ask'; reason?: string }
|
|
31
|
+
|
|
32
|
+
export interface ApprovalSeam {
|
|
33
|
+
request(r: { agent?: unknown; toolName: string; callId?: string; reason?: string; signal?: AbortSignal }): Promise<ApprovalOutcome>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 账号面工具 → 站点(白名单登记处;新会话适配器接入时在此登记) */
|
|
37
|
+
export const ACCOUNT_TOOLS: Record<string, string> = {
|
|
38
|
+
gotry_session_search: 'ctrip-flight',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SITE_LABEL: Record<string, string> = {
|
|
42
|
+
'ctrip-flight': '携程机票',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AuthState {
|
|
46
|
+
granted: Set<string>
|
|
47
|
+
denied: Set<string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConsentGateOptions {
|
|
51
|
+
/** 主闸语义:ask=每会话每站点首调弹卡(默认);allow=配置级预授权放行;off=总闸关 */
|
|
52
|
+
access: () => string
|
|
53
|
+
/** 审批缝懒解析(审批服务晚于插件注册,与 dsh-tools serviceAsk 同协议,缺席=运行时原生 ask→fail-closed) */
|
|
54
|
+
approval?: () => ApprovalSeam | undefined
|
|
55
|
+
/** 授权状态仓(Weak:随 agent/会话回收;测试注入) */
|
|
56
|
+
store?: WeakMap<object, AuthState>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ConsentExec = { name?: string; agent?: object; callId?: string }
|
|
60
|
+
export type ConsentGate = (exec: ConsentExec, next: () => Promise<ConsentDecision>) => Promise<ConsentDecision>
|
|
61
|
+
|
|
62
|
+
function reasonFor(toolName: string, site: string): string {
|
|
63
|
+
return `${toolName} 将使用你本人已登录的浏览器会话做「${SITE_LABEL[site] ?? site}」只读检索`
|
|
64
|
+
+ '(ReadGuard 物理只读:写请求网络层中止,agent 永不碰凭证与验证码);本次批准在你本会话内有效'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 账号会话授权闸:挂 dsh `tools/pre-execute` waterfall。
|
|
69
|
+
* 契约:
|
|
70
|
+
* - 非账号面工具 → next() 放行(零开销);
|
|
71
|
+
* - off → 拒绝(随时可关);
|
|
72
|
+
* - 会话内已拒绝 → 拒绝且不弹卡(拒绝=本会话吊销);
|
|
73
|
+
* - 会话内已批准 / allow 预授权 → 放行;
|
|
74
|
+
* - 其余 → ApprovalService.request();allowed-once 记入会话 granted;
|
|
75
|
+
* rejected/cancelled 记入会话 denied(本会话不在请求);
|
|
76
|
+
* 无审批通道 → deny(fail-closed,headless 无用户 = 无授权)。
|
|
77
|
+
*/
|
|
78
|
+
export function createConsentGate(opts: ConsentGateOptions): ConsentGate {
|
|
79
|
+
const store = opts.store ?? new WeakMap<object, AuthState>()
|
|
80
|
+
const approvalOf = opts.approval ?? (() => undefined)
|
|
81
|
+
return async (exec, next) => {
|
|
82
|
+
const site = exec.name && ACCOUNT_TOOLS[exec.name]
|
|
83
|
+
if (!site) return next()
|
|
84
|
+
if ((opts.access() ?? 'ask') === 'off') {
|
|
85
|
+
return { kind: 'deny', reason: `${exec.name} 已被配置关闭(sessionAccess=off);需要账号会话检索时由用户开启` }
|
|
86
|
+
}
|
|
87
|
+
const state = (exec.agent && store.get(exec.agent)) || undefined
|
|
88
|
+
if (state?.denied.has(site)) {
|
|
89
|
+
return { kind: 'deny', reason: `你在本会话已拒绝过「${SITE_LABEL[site] ?? site}」的账号会话检索——本会话不再请求授权,请改走其他工具推进` }
|
|
90
|
+
}
|
|
91
|
+
if (state?.granted.has(site) || (opts.access() === 'allow' && exec.agent)) return next()
|
|
92
|
+
const approval = opts.approval?.()
|
|
93
|
+
if (!approval || !exec.agent) return { kind: 'ask', reason: reasonFor(exec.name ?? '', site) }
|
|
94
|
+
let outcome: string
|
|
95
|
+
try {
|
|
96
|
+
outcome = await approval.request({ agent: exec.agent, toolName: exec.name ?? '', callId: exec.callId, reason: reasonFor(exec.name ?? '', site) })
|
|
97
|
+
} catch {
|
|
98
|
+
outcome = 'unavailable'
|
|
99
|
+
}
|
|
100
|
+
if (outcome === 'allowed-once') {
|
|
101
|
+
remember(store, exec.agent, site, 'grant')
|
|
102
|
+
return next()
|
|
103
|
+
}
|
|
104
|
+
if (outcome === 'rejected' || outcome === 'cancelled') {
|
|
105
|
+
remember(store, exec.agent, site, 'deny')
|
|
106
|
+
return { kind: 'deny', reason: `你拒绝了 ${exec.name} 的账号会话授权(本会话内生效);不再重复请求,请改走其他工具推进` }
|
|
107
|
+
}
|
|
108
|
+
return { kind: 'deny', reason: `${exec.name} 需要你授权,但当前没有可用的审批通道(headless 一问一答无审批界面;请在 web 会话中使用账号会话检索)` }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function remember(store: WeakMap<object, AuthState>, agent: object, site: string, mode: 'grant' | 'deny'): void {
|
|
113
|
+
const prev = store.get(agent) ?? { granted: new Set<string>(), denied: new Set<string>() }
|
|
114
|
+
if (mode === 'grant') prev.granted.add(site)
|
|
115
|
+
else prev.denied.add(site)
|
|
116
|
+
store.set(agent, prev)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 从运行时上下文懒解析审批缝(与 dsh-tools serviceAsk 的 ctx.get('approval') 同一协议) */
|
|
120
|
+
export function approvalFromContext(ctx: Context): () => ApprovalSeam | undefined {
|
|
121
|
+
return () => {
|
|
122
|
+
const get = (ctx as unknown as { get?: (n: string) => unknown }).get
|
|
123
|
+
if (typeof get !== 'function') return undefined
|
|
124
|
+
const approval = get.call(ctx, 'approval') as ApprovalSeam | undefined
|
|
125
|
+
return approval && typeof approval.request === 'function' ? approval : undefined
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号会话登录引导(产品化工具面:gotry_session_login 的能力层,founder 2026-08-29「请产品化」)。
|
|
3
|
+
*
|
|
4
|
+
* **语义边界(防误解,founder 红线「我们是不会要求用户上传密钥给 gotry 的」)**:
|
|
5
|
+
* - 登录发生在外部网站(携程官网),用用户自己的浏览器会话完成;
|
|
6
|
+
* - gotry 不提供、不代填、不接触任何密码/短信验证码/cookie 值;
|
|
7
|
+
* - 本模块唯一事实:**只读 cookie 的名字**,判断「是否已登录」这个布尔——
|
|
8
|
+
* 名称级存在性检查 = 数据最小化的极端形态(0 个值过手);
|
|
9
|
+
* - 登录入口页不挂 ReadGuard(见 transport.ts guard:false 注释):那是用户自己的
|
|
10
|
+
* 凭证流,我们的拦截绝不能站在用户与网站中间(检索面守卫不变量不受影响)。
|
|
11
|
+
*
|
|
12
|
+
* flow:attach 用户日常 Chrome → 打开登录入口标签 → 用户在携程官网自行登录 →
|
|
13
|
+
* 本模块只读轮询票据 cookie **名字**(cticket/uid/uname/passport)→ 到齐即报「已登录」。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { openSession } from './session/transport.ts'
|
|
17
|
+
|
|
18
|
+
export interface LoginTarget {
|
|
19
|
+
domain: string
|
|
20
|
+
/** 登录票据 cookie 名(经 founder 登录实测后校准;D-13 同源) */
|
|
21
|
+
names: string[]
|
|
22
|
+
label: string
|
|
23
|
+
entryUrl: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 站点白名单 = 适配器注册表;新站点适配器落地时在此登记 */
|
|
27
|
+
export const LOGIN_TARGETS: Record<string, LoginTarget> = {
|
|
28
|
+
'ctrip-flight': { domain: 'ctrip.com', names: ['cticket', 'uid', 'uname', 'passport'], label: '携程机票', entryUrl: 'https://flights.ctrip.com/' },
|
|
29
|
+
'meituan-hotel': { domain: 'meituan.com', names: ['lt', 'u', 'token', 'n'], label: '美团酒店', entryUrl: 'https://hotel.meituan.com/' },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CookieLike {
|
|
33
|
+
domain?: string
|
|
34
|
+
name?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SessionLoginQuery {
|
|
38
|
+
/** 站点(默认 ctrip-flight) */
|
|
39
|
+
site?: string
|
|
40
|
+
/** 等待用户完成登录的上限(ms,默认 90_000;到点返 pending,不阻塞会话) */
|
|
41
|
+
waitMs?: number
|
|
42
|
+
/** 轮询间隔(ms,默认 3000;票据 cookie 名只读) */
|
|
43
|
+
pollMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SessionLoginResult {
|
|
47
|
+
ok: boolean
|
|
48
|
+
via: 'session-login' | 'session-login-error'
|
|
49
|
+
evidence: string
|
|
50
|
+
latencyMs: number
|
|
51
|
+
verdict: 'logged-in' | 'pending' | 'needs-attach' | 'error'
|
|
52
|
+
site: string
|
|
53
|
+
/** 已检出的登录票据 cookie 名(名字,非值——本模块永不读取/存储/回传任何 cookie 值) */
|
|
54
|
+
tickets?: string[]
|
|
55
|
+
error?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function err(site: string, verdict: SessionLoginResult['verdict'], error: string, started: number, ts: string): SessionLoginResult {
|
|
59
|
+
return {
|
|
60
|
+
ok: false, via: 'session-login-error', evidence: `[会话:login-error@${ts}] ${error.slice(0, 200)}`,
|
|
61
|
+
latencyMs: Date.now() - started, verdict, site, error: error.slice(0, 200),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 只读轮询:目标域上已存在的登录票据 cookie 名单(只要名字,永不读值) */
|
|
66
|
+
export async function pollTicketNames(browser: { cookies(): Promise<CookieLike[]> }, target: LoginTarget): Promise<string[]> {
|
|
67
|
+
const cookies = await browser.cookies().catch(() => [] as CookieLike[])
|
|
68
|
+
if (!Array.isArray(cookies)) return []
|
|
69
|
+
return cookies
|
|
70
|
+
.filter((c) => (c.domain ?? '').includes(target.domain) && c.name != null && target.names.includes(c.name))
|
|
71
|
+
.map((c) => c.name as string)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 产品化登录引导(永不抛错;登录由用户在携程官网完成,gotry 只做名称级存在性检查)。
|
|
76
|
+
* verdict:logged-in=已检出票据;pending=登录入口已打开、用户尚未完成(不阻塞,可稍后再查);
|
|
77
|
+
* needs-attach=用户 Chrome 未开调试端口(一次性开关指引);error=其余降级。
|
|
78
|
+
*/
|
|
79
|
+
export async function sessionLogin(q: SessionLoginQuery = {}): Promise<SessionLoginResult> {
|
|
80
|
+
const started = Date.now()
|
|
81
|
+
const ts = new Date().toISOString()
|
|
82
|
+
const site = q.site ?? 'ctrip-flight'
|
|
83
|
+
const target = LOGIN_TARGETS[site]
|
|
84
|
+
if (!target) {
|
|
85
|
+
return err(site, 'error', `未知站点 ${site}(可选 ${Object.keys(LOGIN_TARGETS).join('/')})`, started, ts)
|
|
86
|
+
}
|
|
87
|
+
// 人机共治纪律:开自己的新标签页并置前台——用户必须看见登录页(2026-08-29 founder:
|
|
88
|
+
// 「我根本就看不到登录页面」,此前误劫持用户既有标签页);closeOwnPage=false 把登录页留给用户
|
|
89
|
+
const t = await openSession({ mode: 'cdp', guard: false })
|
|
90
|
+
if (!t.ok) {
|
|
91
|
+
const needsAttach = /chrome:\/\/inspect|DevToolsActivePort|cdp attach 失败/.test(t.summary)
|
|
92
|
+
return err(
|
|
93
|
+
site,
|
|
94
|
+
needsAttach ? 'needs-attach' : 'error',
|
|
95
|
+
needsAttach
|
|
96
|
+
? `${t.summary};开启方法:你的 Chrome 打开 chrome://inspect/#remote-debugging → 打开开关(Chrome 144+,一次性),开启后直接再说一声即可`
|
|
97
|
+
: t.summary,
|
|
98
|
+
started, ts,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
const evidenceTag = `[会话:${site}-login@${ts}]`
|
|
102
|
+
try {
|
|
103
|
+
// 自动检测优先(2026-08-29 founder「UI 里有自动检测吗」):先只读票据名——
|
|
104
|
+
// 已登录则**零弹窗**直接确认,不打开任何页面;登录状态由用户在携程官网自然留存
|
|
105
|
+
const pre = await pollTicketNames(t.browser as unknown as { cookies(): Promise<CookieLike[]> }, target)
|
|
106
|
+
if (pre.length > 0) {
|
|
107
|
+
await t.close()
|
|
108
|
+
return {
|
|
109
|
+
ok: true, via: 'session-login', latencyMs: Date.now() - started, verdict: 'logged-in', site, tickets: pre,
|
|
110
|
+
evidence: `${evidenceTag} 自动检测:票据 cookie 已在(先前登录已生效)——[${pre.join(', ')}](只读名字,0 网页交互)`,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 未检出 → 开自己的新标签页并置前台(绝不劫持用户既有页面),等待用户在官网完成登录
|
|
114
|
+
const page = await t.browser.newPage()
|
|
115
|
+
await (page as unknown as { bringToFront(): Promise<void> }).bringToFront().catch(() => { /* 某些环境不可置前 */ })
|
|
116
|
+
try {
|
|
117
|
+
await page.goto(target.entryUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 })
|
|
118
|
+
} catch {
|
|
119
|
+
// 站点慢不致命:入口标签已打开,用户手动登录即可
|
|
120
|
+
}
|
|
121
|
+
console.log(`[session-login] ${target.label} 登录入口已在新标签页置前打开`)
|
|
122
|
+
const waitMs = Math.min(Math.max(q.waitMs ?? 90_000, 0), 300_000)
|
|
123
|
+
const pollMs = Math.min(Math.max(q.pollMs ?? 3_000, 500), 10_000)
|
|
124
|
+
const deadline = Date.now() + waitMs
|
|
125
|
+
let tickets: string[] = []
|
|
126
|
+
while (Date.now() < deadline) {
|
|
127
|
+
await new Promise<void>((resolve) => setTimeout(resolve, pollMs))
|
|
128
|
+
tickets = await pollTicketNames(t.browser as unknown as { cookies(): Promise<CookieLike[]> }, target)
|
|
129
|
+
if (tickets.length > 0) break
|
|
130
|
+
}
|
|
131
|
+
await t.close()
|
|
132
|
+
if (tickets.length > 0) {
|
|
133
|
+
return {
|
|
134
|
+
ok: true, via: 'session-login', latencyMs: Date.now() - started, verdict: 'logged-in', site, tickets,
|
|
135
|
+
evidence: `${evidenceTag} 票据 cookie 已检出 [${tickets.join(', ')}](只读名字;登录在你自己的浏览器里完成,gotry 全程未接触任何密码/验证码/cookie 值)`,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
ok: true, via: 'session-login', latencyMs: Date.now() - started, verdict: 'pending', site, tickets: [],
|
|
140
|
+
evidence: `${evidenceTag} 登录入口已在你的 Chrome 打开(${target.label});在标签页里正常登录完成后再说一声「继续查」即可——gotry 只检查"是否已登录",永不收集你的账号信息`,
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
await t.close().catch(() => { /* ignore */ })
|
|
144
|
+
return err(site, 'error', e instanceof Error ? e.message : String(e), started, ts)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -47,6 +47,13 @@ export function __resetRateLimiterForTest(): void {
|
|
|
47
47
|
|
|
48
48
|
const CHALLENGE_RE = /验证|滑块|captcha|verify/i
|
|
49
49
|
|
|
50
|
+
/** 把 transport 文案漂移收敛为稳定的产品 verdict;纯函数供回归覆盖。 */
|
|
51
|
+
export function classifyTransportFailure(summary: string, cdpMode: boolean): Extract<SessionVerdict, 'needs-attach' | 'error'> {
|
|
52
|
+
return cdpMode && /日常 Chrome 未开调试端口|cdp attach 失败/.test(summary)
|
|
53
|
+
? 'needs-attach'
|
|
54
|
+
: 'error'
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
export async function sessionFlightSearch(q: SessionFlightQuery): Promise<SessionSearchResult> {
|
|
51
58
|
const started = Date.now()
|
|
52
59
|
const ts = new Date().toISOString()
|
|
@@ -67,11 +74,12 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
|
|
|
67
74
|
return err('error', `unresolved entry: ${(entry.unresolved ?? []).join('/')} 不在城市码表`)
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
|
|
77
|
+
// 人机共治纪律:检索一律开自己的新标签页(绝不劫持用户已有页面),用完关自己的页
|
|
78
|
+
const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: q.profileDir ? 'persistent' : 'cdp', newPage: true })
|
|
71
79
|
if (!t.ok) {
|
|
72
|
-
// cdp
|
|
73
|
-
|
|
74
|
-
return err(
|
|
80
|
+
// cdp 未开端口或握手失败 → needs-attach(一次性用户动作);persistent 启动失败仍走 error。
|
|
81
|
+
// transport 的“端口未开”在连接前返回,文案不含 `cdp attach 失败`,两种形态都要归入同一用户门禁。
|
|
82
|
+
return err(classifyTransportFailure(t.summary, q.profileDir === undefined), t.summary)
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
try {
|
|
@@ -81,7 +89,7 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
|
|
|
81
89
|
return cookies.some((c) => c.domain.includes(SITE_DOMAIN.replace(/^\./, '')) && LOGIN_COOKIE_NAMES.includes(c.name))
|
|
82
90
|
}
|
|
83
91
|
if (!(await loggedIn()) && !q.allowAnonymous) {
|
|
84
|
-
return err('needs-login', '
|
|
92
|
+
return err('needs-login', '未检出你本人登录态——调用 gotry_session_login 为用户打开携程登录入口(登录在携程官网完成;gotry 永不经手密码/验证码/cookie 值)')
|
|
85
93
|
}
|
|
86
94
|
// 先挂监听再导航(Playwright network 模式):命中 networkHint 的第一个响应即搜索回包
|
|
87
95
|
let settled = false
|
|
@@ -59,11 +59,11 @@ export function wmoLabel(code: number): string {
|
|
|
59
59
|
return WMO_ZH[code] ?? `天气码${code}`
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
async function fetchJson(url: string, timeoutMs: number): Promise<{ ok: boolean; data?: unknown; error?: string }> {
|
|
62
|
+
async function fetchJson(url: string, timeoutMs: number, headers: Record<string, string> = {}): Promise<{ ok: boolean; data?: unknown; error?: string }> {
|
|
63
63
|
const ctrl = new AbortController()
|
|
64
64
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
65
65
|
try {
|
|
66
|
-
const res = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } })
|
|
66
|
+
const res = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json', ...headers } })
|
|
67
67
|
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
|
68
68
|
return { ok: true, data: await res.json() }
|
|
69
69
|
} catch (e) {
|
|
@@ -73,29 +73,94 @@ async function fetchJson(url: string, timeoutMs: number): Promise<{ ok: boolean;
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org/search'
|
|
77
|
+
|
|
78
|
+
/** 行政级/人口重镇 feature_code(open-meteo 命名惯例):PPLC=首都 PPLA*=首府 PPLB=城镇 PPLX=区片 */
|
|
79
|
+
const MAJOR_FEATURE = /^(PPLC|PPLA|PPLB|PPLX)/
|
|
80
|
+
|
|
81
|
+
interface GeoHitInternal {
|
|
82
|
+
name: string
|
|
83
|
+
latitude: number
|
|
84
|
+
longitude: number
|
|
85
|
+
country?: string
|
|
86
|
+
admin1?: string
|
|
87
|
+
/** 排序用:open-meteo 有 population;同名小地(村/镇)缺失时按 -1 处理 */
|
|
88
|
+
population: number
|
|
89
|
+
featureCode: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripHits(hits: GeoHitInternal[]): Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }> {
|
|
93
|
+
return hits.map(({ name, latitude, longitude, country, admin1 }) =>
|
|
94
|
+
({ ...(country ? { country } : {}), ...(admin1 ? { admin1 } : {}), name, latitude, longitude }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 地名 → 坐标(双源,供 dsh 工具把「大理」转成经纬度):
|
|
98
|
+
* 1. Open-Meteo(主源):命中按人口/行政级降序——「大理」会同时命中云南州府与四川同名乡,不能盲信次序;
|
|
99
|
+
* 2. Nominatim(兜底):open-meteo 的中文名覆盖有洞(实测(issue #24)「普吉岛」0 结果、
|
|
100
|
+
* 裸词「普吉」错配西藏林芝同名村),弱命中/零结果时走 Nominatim 拿正确目标,
|
|
101
|
+
* 兜底仍空则回退 open-meteo 弱结果(不比此前更差)。 */
|
|
77
102
|
export async function geocodePlace(
|
|
78
103
|
name: string,
|
|
79
104
|
opts: { timeoutMs?: number; count?: number } = {},
|
|
80
|
-
): Promise<{ ok: boolean; evidence: string; results: Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }>; error?: string }> {
|
|
105
|
+
): Promise<{ ok: boolean; evidence: string; via?: 'open-meteo' | 'nominatim'; results: Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }>; error?: string }> {
|
|
81
106
|
const ts = new Date().toISOString()
|
|
82
107
|
const count = opts.count ?? 5
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
108
|
+
const query = name.trim()
|
|
109
|
+
const omUrl = `${GEOCODE_BASE}?name=${encodeURIComponent(query)}&count=${count}&language=zh&format=json`
|
|
110
|
+
const r = await fetchJson(omUrl, opts.timeoutMs ?? 15_000)
|
|
111
|
+
const omResults: GeoHitInternal[] = r.ok
|
|
112
|
+
? ((r.data as { results?: unknown[] })?.results ?? []).map((item) => {
|
|
113
|
+
const it = item as Record<string, unknown>
|
|
114
|
+
return {
|
|
115
|
+
name: String(it['name'] ?? ''),
|
|
116
|
+
latitude: Number(it['latitude']),
|
|
117
|
+
longitude: Number(it['longitude']),
|
|
118
|
+
country: it['country'] ? String(it['country']) : undefined,
|
|
119
|
+
admin1: it['admin1'] ? String(it['admin1']) : undefined,
|
|
120
|
+
population: Number(it['population'] ?? 0) || 0,
|
|
121
|
+
featureCode: String(it['feature_code'] ?? ''),
|
|
122
|
+
}
|
|
123
|
+
}).filter(h => h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude))
|
|
124
|
+
: []
|
|
125
|
+
omResults.sort((a, b) => (b.population - a.population) || (Number(MAJOR_FEATURE.test(b.featureCode)) - Number(MAJOR_FEATURE.test(a.featureCode))))
|
|
126
|
+
const top = omResults[0]
|
|
127
|
+
// 强命中:有人口规模或主要行政级,直接采信(「深圳市」「曼谷」路径不变)
|
|
128
|
+
if (top && (top.population > 0 || MAJOR_FEATURE.test(top.featureCode))) {
|
|
129
|
+
return { ok: true, evidence: `[实时API:open-meteo-geo@${ts}]`, via: 'open-meteo', results: stripHits(omResults) }
|
|
130
|
+
}
|
|
131
|
+
// 弱命中/零结果/主源请求失败:Nominatim 兜底(免费无 key;中文 accept-language)
|
|
132
|
+
const nomTs = new Date().toISOString()
|
|
133
|
+
const nomUrl = `${NOMINATIM_BASE}?q=${encodeURIComponent(query)}&format=jsonv2&limit=${count}&accept-language=zh&addressdetails=1`
|
|
134
|
+
const nom = await fetchJson(nomUrl, opts.timeoutMs ?? 15_000, { 'User-Agent': 'gotry-travel-agent/0.1 (+https://github.com/Danceiny/gotry)' })
|
|
135
|
+
const nomResults: Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }> = nom.ok
|
|
136
|
+
? ((nom.data as unknown[]) ?? []).map((item) => {
|
|
137
|
+
const it = item as Record<string, unknown>
|
|
138
|
+
const addr = (it['address'] ?? {}) as Record<string, string>
|
|
139
|
+
return {
|
|
140
|
+
name: String(it['name'] ?? ''),
|
|
141
|
+
latitude: Number(it['lat']),
|
|
142
|
+
longitude: Number(it['lon']),
|
|
143
|
+
country: addr['country'],
|
|
144
|
+
admin1: addr['province'] ?? addr['state'] ?? addr['county'],
|
|
145
|
+
}
|
|
146
|
+
}).filter(h => h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude))
|
|
147
|
+
: []
|
|
148
|
+
if (nomResults.length) {
|
|
149
|
+
const omNote = r.ok
|
|
150
|
+
? `open-meteo ${omResults.length} 条弱命中(无人口/行政级,不足采信)`
|
|
151
|
+
: `open-meteo 失败:${r.error ?? 'HTTP error'}`
|
|
152
|
+
return { ok: true, evidence: `[实时API:nominatim@${nomTs}](兜底层: ${omNote})`, via: 'nominatim', results: nomResults }
|
|
153
|
+
}
|
|
154
|
+
// 双源皆空/兜底失败:open-meteo 有弱结果仍返回(同构「降级不阻塞」契约),否则维持失败终态
|
|
155
|
+
if (omResults.length) {
|
|
156
|
+
return { ok: true, evidence: `[实时API:open-meteo-geo@${ts}]`, via: 'open-meteo', results: stripHits(omResults) }
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
evidence: `[实时API:open-meteo-geo@error@${ts}];[实时API:nominatim@error@${nomTs}]`,
|
|
161
|
+
results: [],
|
|
162
|
+
error: r.ok ? `双源无结果:${nom.error ?? 'nominatim empty'}` : (r.error),
|
|
87
163
|
}
|
|
88
|
-
const results = ((r.data as { results?: unknown[] })?.results ?? []).map((item) => {
|
|
89
|
-
const it = item as Record<string, unknown>
|
|
90
|
-
return {
|
|
91
|
-
name: String(it['name'] ?? ''),
|
|
92
|
-
latitude: Number(it['latitude']),
|
|
93
|
-
longitude: Number(it['longitude']),
|
|
94
|
-
country: it['country'] ? String(it['country']) : undefined,
|
|
95
|
-
admin1: it['admin1'] ? String(it['admin1']) : undefined,
|
|
96
|
-
}
|
|
97
|
-
})
|
|
98
|
-
return { ok: true, evidence: `[实时API:open-meteo-geo@${ts}]`, results }
|
|
99
164
|
}
|
|
100
165
|
|
|
101
166
|
/** 未来天气预报(≤16 天) */
|
|
@@ -3,16 +3,50 @@
|
|
|
3
3
|
* 经可日志化 solve 端口执行深度规划(done 步骤复用账本结果,不重执行),
|
|
4
4
|
* 交付物落账本 + .deliverable.md 视图。可由任何后续进程执行(loopx 驱动的
|
|
5
5
|
* tick、state-cli tick、人工、未来的通知系统)——「一小时后回来」跨进程成立,
|
|
6
|
-
* 且崩溃恢复 exactly-once
|
|
6
|
+
* 且崩溃恢复 exactly-once:重放不重复花钱。最后一行固定输出
|
|
7
|
+
* gotry_async_terminal.v1 JSON;4/4 exit 0,非 4/4 exit 2。
|
|
7
8
|
* 运行(在 ts/ 下):npx tsx scripts/async-collect.ts <ticketId> [stateRoot]
|
|
8
9
|
* 环境变量 GOTRY_SOLVE_COUNT_FILE:每次真实求解追加一行(回归用计数探针)。
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { appendFileSync } from 'node:fs'
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ASYNC_TERMINAL_SCHEMA,
|
|
15
|
+
collectDeepPlanning,
|
|
16
|
+
loadAsyncTicket,
|
|
17
|
+
makeJournaledSolvePort,
|
|
18
|
+
settleAsyncTicket,
|
|
19
|
+
type AsyncTerminalOutcome,
|
|
20
|
+
} from '../src/loop.ts'
|
|
13
21
|
import { solveUnified } from '../src/unified.ts'
|
|
14
22
|
import { openLedgerIfExists } from '../src/state-ledger.ts'
|
|
15
23
|
|
|
24
|
+
function isTerminalOutcome(value: Record<string, unknown> | null): value is Record<string, unknown> & AsyncTerminalOutcome {
|
|
25
|
+
return value?.['schema'] === ASYNC_TERMINAL_SCHEMA
|
|
26
|
+
&& (value['status'] === 'succeeded' || value['status'] === 'failed')
|
|
27
|
+
&& typeof value['passed'] === 'number'
|
|
28
|
+
&& value['total'] === 4
|
|
29
|
+
&& Array.isArray(value['failed_checks'])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function legacyTerminalOutcome(ticket: string, status: 'settled' | 'failed', deliverable: string): AsyncTerminalOutcome {
|
|
33
|
+
const succeeded = status === 'settled' && deliverable.includes('不失望四条:4/4 ✅')
|
|
34
|
+
return {
|
|
35
|
+
schema: ASYNC_TERMINAL_SCHEMA,
|
|
36
|
+
ticket_id: ticket,
|
|
37
|
+
status: succeeded ? 'succeeded' : 'failed',
|
|
38
|
+
passed: succeeded ? 4 : 0,
|
|
39
|
+
total: 4,
|
|
40
|
+
checks: { legacy_human_marker_only: succeeded },
|
|
41
|
+
failed_checks: succeeded ? [] : ['legacy_unstructured_terminal'],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emitTerminal(outcome: AsyncTerminalOutcome): never {
|
|
46
|
+
console.log(JSON.stringify(outcome))
|
|
47
|
+
process.exit(outcome.status === 'succeeded' ? 0 : 2)
|
|
48
|
+
}
|
|
49
|
+
|
|
16
50
|
const ticketId = process.argv[2]
|
|
17
51
|
const stateRoot = process.argv[3] ?? '.'
|
|
18
52
|
if (!ticketId) {
|
|
@@ -26,12 +60,17 @@ if (!loaded) {
|
|
|
26
60
|
process.exit(1)
|
|
27
61
|
}
|
|
28
62
|
|
|
29
|
-
// 终态幂等:已 settled
|
|
63
|
+
// 终态幂等:已 settled/failed 的工单复诵账本交付物与机器终态,零重算
|
|
30
64
|
const ledger = openLedgerIfExists(stateRoot)
|
|
31
65
|
const run = ledger?.getWorkflowRun(ticketId)
|
|
32
|
-
if (run?.status === 'settled' && run.deliverable) {
|
|
66
|
+
if ((run?.status === 'settled' || run?.status === 'failed') && run.deliverable) {
|
|
67
|
+
const storedOutcome = ledger?.getWorkflowTerminalOutcome(ticketId) ?? null
|
|
68
|
+
const outcome = isTerminalOutcome(storedOutcome)
|
|
69
|
+
? storedOutcome
|
|
70
|
+
: legacyTerminalOutcome(ticketId, run.status, run.deliverable)
|
|
71
|
+
await settleAsyncTicket(ticketId, run.deliverable, stateRoot, outcome)
|
|
33
72
|
console.log(`工单 ${ticketId} 已交付(账本终态,复诵不重算):\n\n${run.deliverable}`)
|
|
34
|
-
|
|
73
|
+
emitTerminal(outcome)
|
|
35
74
|
}
|
|
36
75
|
if (!ledger) {
|
|
37
76
|
console.error(`工单 ${ticketId}:stateRoot(${stateRoot})无账本——持久化先于回收,不应发生`)
|
|
@@ -45,6 +84,8 @@ const solve = makeJournaledSolvePort(ledger, ticketId, solveUnified as never, {
|
|
|
45
84
|
},
|
|
46
85
|
})
|
|
47
86
|
|
|
48
|
-
const { reply } = await collectDeepPlanning(loaded.state, loaded.ticket, solve)
|
|
49
|
-
const out = await settleAsyncTicket(ticketId, reply, stateRoot)
|
|
87
|
+
const { reply, outcome } = await collectDeepPlanning(loaded.state, loaded.ticket, solve)
|
|
88
|
+
const out = await settleAsyncTicket(ticketId, reply, stateRoot, outcome)
|
|
50
89
|
console.log(`交付物已落盘:${out}\n\n${reply}`)
|
|
90
|
+
console.log(JSON.stringify(outcome))
|
|
91
|
+
process.exitCode = outcome.status === 'succeeded' ? 0 : 2
|