@danceiny/gotry 0.2.0-rc.31 → 0.2.0-rc.32
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +63 -53
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/client/client.js +66 -21
- package/dist/capabilities/doctor.js +7 -8
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/bootstrap-tests.js +23 -6
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +19 -4
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +5 -0
- package/dist/scripts/gotry-web-client-tests.js +20 -1
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +1 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/gotry-web-api.js +64 -2
- package/dist/src/index.js +40 -18
- package/extension/manifest.json +2 -2
- package/package.json +2 -1
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/gotry-web-api.ts +51 -2
- package/ts/src/index.ts +41 -19
|
@@ -20,7 +20,9 @@ import { buildEntryUrl, parseBatchSearch, parseBatchSearchResult } from '../capa
|
|
|
20
20
|
import { buildHotelEntryUrl, parseCtripHotelList, looksLikeHotelListBody } from '../capabilities/session/adapters/ctrip-hotel.js';
|
|
21
21
|
import { buildTrainEntryUrl, hasRecognizedAvailableSeat, parseLeftTicketQuery, parseLeftTicketQueryResult, resolveTrainQueryTelecodes, STATION_TELECODES, validateTrainQueryResponseUrl } from '../capabilities/session/adapters/rail-12306.js';
|
|
22
22
|
import { buildDidaEntryUrl, parseDidaRates, parseDidaSearchCache, looksLikeDidaRatesBody } from '../capabilities/session/adapters/dida-portal.js';
|
|
23
|
-
import { sessionFlightSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
|
|
23
|
+
import { sessionFlightSearch, sessionFlightGuiSearch, sessionHotelSearch, sessionTrainSearch, sessionDidaSearch, trainStationUnresolvedHint, hotelCityUnresolvedHint, flightCityUnresolvedHint, didaLoginHint, __resetRateLimiterForTest, __setTrainSessionTransportForTest, classifyTransportFailure } from '../capabilities/session-search.js';
|
|
24
|
+
import { planFlightGuiActions, assertGuiPlanSafe, runFlightGuiSearch, parseLowestPriceCalendar, __setGuiDriverForTest } from '../capabilities/session/gui-flight.js';
|
|
25
|
+
import { planHotelGuiActions, assertHotelPlanSafe, runHotelGuiSearch, __setHotelDriverForTest } from '../capabilities/session/gui-hotel.js';
|
|
24
26
|
import { flyaiSearch } from '../capabilities/flyai.js';
|
|
25
27
|
import { createConsentGate } from '../capabilities/session-consent.js';
|
|
26
28
|
import { sessionLogin, pollTicketNames, LOGIN_TARGETS } from '../capabilities/session-login.js';
|
|
@@ -145,6 +147,16 @@ assert(e1.ok === true && e1.url === 'https://flights.ctrip.com/online/list/onewa
|
|
|
145
147
|
const e2 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01');
|
|
146
148
|
assert(e2.ok === false && e2.unresolved?.[0] === '乌兰巴托', '词表外城市 unresolved 逐字保留(不猜)');
|
|
147
149
|
assert(buildEntryUrl('上海', '丽江', '十月一号').ok === false, '非法日期拒绝');
|
|
150
|
+
const internationalEntry = buildEntryUrl('迪拜', '香港', '2026-12-01', { fromCode: 'DXB', toCode: 'HKG' });
|
|
151
|
+
assert(internationalEntry.ok && internationalEntry.url?.includes('oneway-dxb-hkg') === true, '显式供应商城市码不受内置表限制');
|
|
152
|
+
assert(!buildEntryUrl('迪拜', '香港', '2026-12-01', { fromCode: '../dxb', toCode: 'HKG' }).ok, '显式城市码拒绝 URL 注入');
|
|
153
|
+
// 实测校准抽查(2026-10-14 list 页实证:合法组合 200+title 航线名,非法组合 302)
|
|
154
|
+
const e3 = buildEntryUrl('香港', '迪拜', '2026-12-06');
|
|
155
|
+
assert(e3.ok === true && e3.url === 'https://flights.ctrip.com/online/list/oneway-hkg-dxb?depdate=2026-12-06', '香港/迪拜=实测校准入缓存表(hkg/dxb)', e3);
|
|
156
|
+
const e4 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01', { fromCode: 'ubn' });
|
|
157
|
+
assert(e4.ok === true && e4.url === 'https://flights.ctrip.com/online/list/oneway-ubn-ljg?depdate=2026-10-01', '显式三字码逃生口(形状合格即成行)', e4);
|
|
158
|
+
assert(buildEntryUrl('乌兰巴托', '丽江', '2026-10-01', { fromCode: 'UBN!' }).ok === false, '显式码形状不合格不得进 URL');
|
|
159
|
+
assert(flightCityUnresolvedHint(['乌兰巴托']).includes('fromCityCode'), '机票 unresolved 指引带显式码逃生口');
|
|
148
160
|
// D. DOM 提交件过滤
|
|
149
161
|
console.log('D. isSubmitText(DOM 提交件黑名单)');
|
|
150
162
|
assert(isSubmitText('立即下单') && isSubmitText('去支付') && isSubmitText('提交订单'), '下单/支付/提交 命中');
|
|
@@ -153,9 +165,9 @@ assert(!isSubmitText('搜索') && !isSubmitText('筛选'), '搜索/筛选 不误
|
|
|
153
165
|
console.log('E. 节律闸(≥30s 间隔)');
|
|
154
166
|
__resetRateLimiterForTest();
|
|
155
167
|
const r1 = await sessionFlightSearch({ from: '乌兰巴托', to: '丽江', date: '2026-10-01' });
|
|
156
|
-
const r2 = await sessionFlightSearch({ from: '
|
|
168
|
+
const r2 = await sessionFlightSearch({ from: '另一个未知城市', to: '丽江', date: '2026-10-01' });
|
|
157
169
|
assert(r1.verdict === 'error' && /unresolved/.test(r1.error ?? ''), '首次调用:词表外 → error(unresolved)');
|
|
158
|
-
assert(r2.verdict === '
|
|
170
|
+
assert(r2.verdict === 'error', '城市解析失败不占用库存检索节律额度,不误报 cooldown');
|
|
159
171
|
__resetRateLimiterForTest();
|
|
160
172
|
// F. transport 失败分类 + live FlyAI 官方通道
|
|
161
173
|
console.log('F. transport verdict + flyaiSearch(live,飞猪官方,无 key;GOTRY_SESSION_LIVE=0 跳过)');
|
|
@@ -167,7 +179,10 @@ if (process.env.GOTRY_SESSION_LIVE === '0') {
|
|
|
167
179
|
console.log(' SKIP - GOTRY_SESSION_LIVE=0(离线门禁不调用 FlyAI 外部实时端点)');
|
|
168
180
|
}
|
|
169
181
|
else {
|
|
170
|
-
|
|
182
|
+
// 滚动未来日期(硬编码日期跨日假红的教训:过去日期会被上游「出发日期非法」业务失败拒绝)
|
|
183
|
+
const roll = new Date(Date.now() + 30 * 24 * 3600 * 1000);
|
|
184
|
+
const rollingDepDate = `${roll.getFullYear()}-${String(roll.getMonth() + 1).padStart(2, '0')}-${String(roll.getDate()).padStart(2, '0')}`;
|
|
185
|
+
fr = await flyaiSearch({ kind: 'flight', origin: '上海', destination: '丽江', depDate: rollingDepDate });
|
|
171
186
|
const sentinelBlocked = fr.verdict === 'error' && /sentinel|block|trial limit/i.test(fr.error ?? '');
|
|
172
187
|
if (sentinelBlocked) {
|
|
173
188
|
console.log(' WARN - 飞猪上游限流(Sentinel 2026-08-28 / trial-limit 429 2026-08-31 实测,配额未文档化)——降级合同验证通过,跳过 hit 断言');
|
|
@@ -540,8 +555,8 @@ console.log('K. 酒店适配器(buildHotelEntryUrl/走形解析/形状嗅探/城
|
|
|
540
555
|
__resetRateLimiterForTest();
|
|
541
556
|
const e1 = await sessionHotelSearch({ to: '不在码表的城市' });
|
|
542
557
|
assert(e1.ok === false && e1.verdict === 'error' && /city=/.test(e1.error ?? ''), '未收录城市 → error + cityId 指引', e1);
|
|
543
|
-
const e2 = await sessionHotelSearch({ to: '
|
|
544
|
-
assert(e2.ok === false && e2.verdict === '
|
|
558
|
+
const e2 = await sessionHotelSearch({ to: '另一个未知酒店城市' });
|
|
559
|
+
assert(e2.ok === false && e2.verdict === 'error', '酒店城市解析失败不占用库存节律额度', e2);
|
|
545
560
|
}
|
|
546
561
|
// L. 火车会话面(2026-09-03 实装,12306 公开查询面)纯函数:entry URL/管道行解析/电报码指引/闸面
|
|
547
562
|
console.log('L. 火车适配器(buildTrainEntryUrl/parseLeftTicketQuery/电报码指引/节律闸)');
|
|
@@ -854,6 +869,162 @@ console.log('M. Dida 门户(entry 守域 + 信封走形 + 闸面)');
|
|
|
854
869
|
assert(q2.ok === false && q2.verdict === 'cooldown', '节律闸:30s 内二调 → cooldown', q2);
|
|
855
870
|
assert(typeof didaLoginHint() === 'string' && /自动登录/.test(didaLoginHint()), '登录指引指向 hotel-be portal 跳板(账密永不经 gotry)');
|
|
856
871
|
}
|
|
872
|
+
// L. 携程机票 GUI 车道(2026-10-14):规划器/安全白名单/假 driver 全流程/readback 硬闸
|
|
873
|
+
{
|
|
874
|
+
console.log('L. GUI 车道(规划器 + 安全闸 + 假 driver)');
|
|
875
|
+
const planOw = planFlightGuiActions({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
876
|
+
assert(planOw.some(a => a.t === 'clickTripTab' && a.trip === 'oneway') && !planOw.some(a => a.t === 'setDate' && a.which === 'back'), '单程规划:显式点单程 tab(页面默认往返态)/无回程日期');
|
|
877
|
+
const planRt = planFlightGuiActions({ from: '香港', to: '迪拜', date: '2026-12-06', backDate: '2026-12-13' });
|
|
878
|
+
assert(planRt.some(a => a.t === 'clickTripTab' && a.trip === 'roundtrip') && planRt.some(a => a.t === 'setDate' && a.which === 'back'), '往返规划:往返 tab + 回程日期(不知 URL 形状也能搜)');
|
|
879
|
+
const rb = planRt.find(a => a.t === 'readback');
|
|
880
|
+
assert(!!rb && rb.t === 'readback' && rb.expect.back === '2026-12-13', 'readback 期望含回程日期');
|
|
881
|
+
// 安全白名单:闭集外/提交件文本/非固定入口/坏日期/坏按键全拒
|
|
882
|
+
assert(!assertGuiPlanSafe([{ t: 'goto', url: 'https://evil.example.com/' }]).ok, '安全闸:非固定入口 URL 拒绝');
|
|
883
|
+
assert(!assertGuiPlanSafe([{ t: 'typeCity', which: 'from', text: '立即下单' }]).ok, '安全闸:城市位提交件文本拒绝(写面红线)');
|
|
884
|
+
assert(!assertGuiPlanSafe([{ t: 'setDate', which: 'dep', ymd: '十二月六号' }]).ok, '安全闸:非 YMD 日期拒绝');
|
|
885
|
+
assert(!assertGuiPlanSafe([{ t: 'press', key: 'Meta' }]).ok, '安全闸:按键白名单外拒绝');
|
|
886
|
+
assert(assertGuiPlanSafe(planRt).ok, '合法规划全过');
|
|
887
|
+
// 假 driver:表单内存模型 + 可编程回包
|
|
888
|
+
const FIXTURE = JSON.stringify({ data: { flightItineraryList: [{
|
|
889
|
+
flightSegments: [{ airlineName: 'Spring Airlines', duration: 215, flightList: [{ flightNo: '9C6617', departureAirportName: '浦东', arrivalAirportName: '三义', departureDateTime: '2027-05-02 17:05:00', arrivalDateTime: '2027-05-02 20:40:00' }] }],
|
|
890
|
+
priceList: [{ adultPrice: 1790 }],
|
|
891
|
+
}] } });
|
|
892
|
+
// live 实测形状(2026-10-14 GUI 捕获,382 条之一):顶层 priceList,/Date(ms+0800)/ 日期,price>0 才可售
|
|
893
|
+
const CAL_FIXTURE = JSON.stringify({ responseStatus: { Ack: 'Success' }, priceList: [
|
|
894
|
+
{ departDate: '/Date(1791993600000+0800)/', price: 0 }, // 无价日(过滤)
|
|
895
|
+
{ departDate: '/Date(1792080000000+0800)/', price: 880 }, // 2027-05-02? 由断言锚定实际换算
|
|
896
|
+
{ departDate: '/Date(1792166400000+0800)/', price: 1020 },
|
|
897
|
+
{ departDate: '/Date(1792252800000+0800)/', price: 760 },
|
|
898
|
+
] });
|
|
899
|
+
const makeFakeDriver = (opts = {}) => {
|
|
900
|
+
const state = { clickedSearch: false, form: { from: '', to: '', dep: '', back: '' } };
|
|
901
|
+
const page = {
|
|
902
|
+
async goto() { },
|
|
903
|
+
async clickTripTab() { },
|
|
904
|
+
async typeCity(which, text) { state.form[which] = text; },
|
|
905
|
+
async pickSuggest(which, text) { state.form[which] = `${text}(SHA)`; /* 站点补全归一化形态 */ },
|
|
906
|
+
async setDate(which, ymd) { state.form[which === 'dep' ? 'dep' : 'back'] = ymd; },
|
|
907
|
+
async press() { },
|
|
908
|
+
async clickSearch() { state.clickedSearch = true; },
|
|
909
|
+
async visibleFormText() { return Object.values({ ...state.form, ...opts.formOverride }).concat(['上海', '丽江']).join(' | '); },
|
|
910
|
+
async cityFieldValues() { const f = { ...state.form, ...opts.formOverride }; return { from: f.from ?? '', to: f.to ?? '' }; },
|
|
911
|
+
async pageText() { return opts.challenge ? '请输入验证码 拖动滑块' : '机票预订'; },
|
|
912
|
+
async currentUrl() { return opts.listUrl ?? 'https://flights.ctrip.com/online/channel/domestic'; },
|
|
913
|
+
async waitForApiCapture() {
|
|
914
|
+
const itinerary = opts.apiBody === undefined
|
|
915
|
+
? { url: 'https://flights.ctrip.com/international/search/api/search/batchSearch', body: FIXTURE }
|
|
916
|
+
: opts.apiBody ? { url: 'x', body: opts.apiBody } : null;
|
|
917
|
+
const calendar = opts.calendarBody ? { url: 'https://m.ctrip.com/restapi/soa2/15380/bjjson/FlightIntlAndInlandLowestPriceSearch', body: opts.calendarBody } : null;
|
|
918
|
+
return (itinerary || calendar) ? { ...(itinerary ? { itinerary } : {}), ...(calendar ? { calendar } : {}) } : null;
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
return { driver: { open: async () => page, close: async () => { } }, state };
|
|
922
|
+
};
|
|
923
|
+
__setGuiDriverForTest(() => makeFakeDriver().driver);
|
|
924
|
+
const g1 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
925
|
+
assert(g1.ok === true && g1.verdict === 'hit' && g1.anonymous === true && (g1.options?.length ?? 0) === 1, '假 driver 全流程 → hit(anonymous 标注)', g1);
|
|
926
|
+
assert(/\[会话:ctrip-flight-gui@/.test(g1.evidence) && /transport=gui/.test(g1.evidence), 'GUI 证据链 [会话:ctrip-flight-gui@ts]');
|
|
927
|
+
assert(g1.options?.[0]?.flightNo === '9C6617' && g1.options?.[0]?.price === 1790, '既有 batchSearch 解析器复用(结构化选项)');
|
|
928
|
+
// #661/#662:匿名绑定面——无行程回包、仅有低价日历 → hit + 日历数据 + listUrl 透传
|
|
929
|
+
const calDriver = makeFakeDriver({ apiBody: null, calendarBody: CAL_FIXTURE, listUrl: 'https://flights.ctrip.com/online/list/roundtrip-sha-ljg?depdate=2027-05-02&retdate=2027-05-09' });
|
|
930
|
+
__setGuiDriverForTest(() => calDriver.driver);
|
|
931
|
+
const g5 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-09' });
|
|
932
|
+
assert(g5.ok === true && g5.verdict === 'hit' && (g5.options?.length ?? 0) === 0, '日历绑定:无行程回包时日历即 hit(#661)', g5);
|
|
933
|
+
assert((g5.calendar?.length ?? 0) === 3 && g5.calendar?.[0]?.date === '2026-10-16' && g5.calendar?.[0]?.price === 880 && g5.calendar?.[2]?.price === 760, '日历数据 /Date(ms)/→date 与 price>0 解析(#662,无价日过滤)', g5.calendar);
|
|
934
|
+
const calDup = parseLowestPriceCalendar(JSON.stringify({ priceList: [
|
|
935
|
+
{ departDate: '/Date(1792080000000+0800)/', price: 900 }, { departDate: '/Date(1792080000000+0800)/', price: 700 },
|
|
936
|
+
] }));
|
|
937
|
+
assert(calDup.length === 1 && calDup[0]?.price === 700, '往返双程同日双价 → 按日聚合取最低', calDup);
|
|
938
|
+
assert(g5.listUrl === 'https://flights.ctrip.com/online/list/roundtrip-sha-ljg?depdate=2027-05-02&retdate=2027-05-09', 'listUrl 回传(#663 URL 教师)', g5.listUrl);
|
|
939
|
+
assert(/低价日历/.test(g5.evidence), '日历证据链标注');
|
|
940
|
+
assert(parseLowestPriceCalendar('not json').length === 0 && parseLowestPriceCalendar('{"data":{"unknownShape":1}}').length === 0, '日历 parser 形状闸:未知形状空数组(不发明价格)');
|
|
941
|
+
const mixed = makeFakeDriver({ calendarBody: CAL_FIXTURE });
|
|
942
|
+
__setGuiDriverForTest(() => mixed.driver);
|
|
943
|
+
const g6 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
944
|
+
assert((g6.options?.length ?? 0) === 1 && (g6.calendar?.length ?? 0) === 3 && /calendar=3天/.test(g6.evidence), '行程+日历双槽并存(行程优先结算,日历随行)', g6);
|
|
945
|
+
const rbDriver = makeFakeDriver({ formOverride: { dep: '2027-05-03' } }); // 日期被「改回」→ readback 必须拦
|
|
946
|
+
__setGuiDriverForTest(() => rbDriver.driver);
|
|
947
|
+
const g2 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
948
|
+
assert(g2.ok === false && g2.verdict === 'error' && /readback 硬闸/.test(g2.error ?? ''), 'readback 硬闸:日期不符 → 终止,不发起搜索', g2);
|
|
949
|
+
assert(rbDriver.state.clickedSearch === false, 'readback 拦截后搜索按钮零点击(绝不搜错日期)');
|
|
950
|
+
const cityDriver = makeFakeDriver({ formOverride: { from: '北京(BJS)' } }); // 城市打进别的框(live 实测形态)
|
|
951
|
+
__setGuiDriverForTest(() => cityDriver.driver);
|
|
952
|
+
const g2b = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
953
|
+
assert(g2b.ok === false && /出发城市框未确认/.test(g2b.error ?? '') && cityDriver.state.clickedSearch === false, 'readback 逐字段:城市框错位(全局框假阳)→ 终止', g2b);
|
|
954
|
+
__setGuiDriverForTest(() => makeFakeDriver({ challenge: true }).driver);
|
|
955
|
+
const g3 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
956
|
+
assert(g3.ok === false && g3.verdict === 'challenged', '风控页 → challenged(不重试不绕过)', g3);
|
|
957
|
+
__setGuiDriverForTest(() => makeFakeDriver({ apiBody: null }).driver);
|
|
958
|
+
const g4 = await runFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
959
|
+
assert(g4.ok === false && g4.verdict === 'error' && /嗅探超时/.test(g4.error ?? ''), '回包超时 → 结构化 error', g4);
|
|
960
|
+
// 编排面:节律闸同站同桶 + 回程日期校验
|
|
961
|
+
__resetRateLimiterForTest();
|
|
962
|
+
__setGuiDriverForTest(() => makeFakeDriver().driver);
|
|
963
|
+
const s1 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
964
|
+
assert(s1.ok === true && s1.verdict === 'hit', 'sessionFlightGuiSearch 编排 → hit');
|
|
965
|
+
const s2 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02' });
|
|
966
|
+
assert(s2.ok === false && s2.verdict === 'cooldown', 'GUI 与 bridge 同站同桶:30s 内二调 → cooldown');
|
|
967
|
+
__resetRateLimiterForTest();
|
|
968
|
+
const s3 = await sessionFlightGuiSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-01' });
|
|
969
|
+
assert(s3.ok === false && s3.verdict === 'error' && /回程日期/.test(s3.error ?? ''), '回程早于去程 → 参数闸拒绝');
|
|
970
|
+
// bridge 往返(#663 解锁):URL 形状由 GUI 教师实测定稿;回程≤去程仍参数闸拒绝
|
|
971
|
+
__resetRateLimiterForTest();
|
|
972
|
+
const s4 = await sessionFlightSearch({ from: '上海', to: '丽江', date: '2027-05-02', backDate: '2027-05-01' });
|
|
973
|
+
assert(s4.ok === false && s4.verdict === 'error' && /回程日期/.test(s4.error ?? ''), 'bridge 回程早于去程 → 参数闸拒绝', s4);
|
|
974
|
+
const rtUrl = buildEntryUrl('香港', '迪拜', '2026-11-13', {}, '2026-11-17');
|
|
975
|
+
assert(rtUrl.ok === true && rtUrl.url === 'https://flights.ctrip.com/online/list/round-hkg0-dxb0?depdate=2026-11-13_2026-11-17&cabin=y_s&adult=1&child=0&infant=0', '往返 URL=GUI 教师实证形状(round-{code}0)', rtUrl);
|
|
976
|
+
__setGuiDriverForTest(null);
|
|
977
|
+
}
|
|
978
|
+
// M. 携程酒店 GUI 车道(2026-10-14,#664 首片):规划/安全闸/readback/needs-login 边界
|
|
979
|
+
{
|
|
980
|
+
console.log('M. 酒店 GUI 车道(规划器 + 安全闸 + 假 driver + 登录墙判语)');
|
|
981
|
+
const hp = planHotelGuiActions({ to: '迪拜', checkIn: '2026-12-06', checkOut: '2026-12-08' });
|
|
982
|
+
assert(hp.some(a => a.t === 'setDate' && a.which === 'in') && hp.some(a => a.t === 'setDate' && a.which === 'out'), '酒店规划:入住/退房成对');
|
|
983
|
+
assert(!assertHotelPlanSafe([{ t: 'typeCity', text: '立即下单' }]).ok, '安全闸:目的地位提交件文本拒绝');
|
|
984
|
+
assert(!assertHotelPlanSafe([{ t: 'goto', url: 'https://evil.example.com/' }]).ok, '安全闸:非固定入口拒绝');
|
|
985
|
+
const HOTEL_FIXTURE = JSON.stringify({ ResponseStatus: { Ack: 'Success' }, hotelList: [
|
|
986
|
+
{ hotelName: '测试酒店', minPrice: 500, star: 4 },
|
|
987
|
+
] });
|
|
988
|
+
const mkHotelDriver = (opts = {}) => {
|
|
989
|
+
const state = { clickedSearch: false, form: { to: '', in: '', out: '' }, url: 'https://hotels.ctrip.com/' };
|
|
990
|
+
const page = {
|
|
991
|
+
async goto() { },
|
|
992
|
+
async typeCity(text) { state.form.to = text; },
|
|
993
|
+
async pickSuggest(text) { state.form.to = `${text}(测试)`; },
|
|
994
|
+
async setDate(which, ymd) { state.form[which] = ymd; },
|
|
995
|
+
async press() { },
|
|
996
|
+
async clickSearch() { state.clickedSearch = true; if (opts.passportAfterSearch)
|
|
997
|
+
state.url = 'https://passport.ctrip.com/user/login?backurl=x'; },
|
|
998
|
+
async visibleFormText() {
|
|
999
|
+
const f = { ...state.form, ...opts.formOverride };
|
|
1000
|
+
const cn = (ymd) => ymd ? `${Number(ymd.slice(5, 7))}月${Number(ymd.slice(8, 10))}日` : '';
|
|
1001
|
+
return Object.values(f).concat(['上海', cn(f.in ?? ''), cn(f.out ?? '')]).join(' | ');
|
|
1002
|
+
},
|
|
1003
|
+
async destFieldValue() { return (opts.formOverride?.to ?? state.form.to) + '(测试)'; },
|
|
1004
|
+
async pageText() { return '酒店预订'; },
|
|
1005
|
+
async currentUrl() { return state.url; },
|
|
1006
|
+
async waitForApiBody() {
|
|
1007
|
+
if (opts.passportAfterSearch)
|
|
1008
|
+
return null;
|
|
1009
|
+
return opts.body === undefined ? { url: 'https://hotels.ctrip.com/hotels/api/list', body: HOTEL_FIXTURE } : opts.body ? { url: 'x', body: opts.body } : null;
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
return { driver: { open: async () => page, close: async () => { } }, state };
|
|
1013
|
+
};
|
|
1014
|
+
__setHotelDriverForTest(() => mkHotelDriver().driver);
|
|
1015
|
+
const h1 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
|
|
1016
|
+
assert(h1.ok === true && h1.verdict === 'hit' && h1.anonymous === true && (h1.hotels?.length ?? 0) >= 1, '酒店假 driver 全流程 → hit(匿名标注;本地化日期格式核对)', h1);
|
|
1017
|
+
const hLogin = mkHotelDriver({ passportAfterSearch: true });
|
|
1018
|
+
__setHotelDriverForTest(() => hLogin.driver);
|
|
1019
|
+
const h2 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
|
|
1020
|
+
assert(h2.ok === false && h2.verdict === 'needs-login' && /gotry_session_login/.test(h2.error ?? ''), '登录墙(passport 跳转)→ needs-login 判语(live 实证边界)', h2);
|
|
1021
|
+
const hBadDate = mkHotelDriver({ formOverride: { in: '2026-11-20' } });
|
|
1022
|
+
__setHotelDriverForTest(() => hBadDate.driver);
|
|
1023
|
+
const h3 = await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-13', checkOut: '2026-11-15' });
|
|
1024
|
+
assert(h3.ok === false && /readback 硬闸/.test(h3.error ?? '') && hBadDate.state.clickedSearch === false, 'readback 硬闸:入住日期错位 → 零搜索点击', h3);
|
|
1025
|
+
assert((await runHotelGuiSearch({ to: '上海', checkIn: '2026-11-15', checkOut: '2026-11-13' })).verdict === 'error', '退房早于入住 → 参数闸拒绝');
|
|
1026
|
+
__setHotelDriverForTest(null);
|
|
1027
|
+
}
|
|
857
1028
|
if (process.env.GOTRY_SESSION_TEST_FORCE_FAILURE === '1') {
|
|
858
1029
|
assert(false, 'forced session failure propagation proof');
|
|
859
1030
|
}
|
|
@@ -4,6 +4,7 @@ import { isAbsolute } from 'node:path';
|
|
|
4
4
|
import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js';
|
|
5
5
|
import { listArtifacts } from '../capabilities/artifacts.js';
|
|
6
6
|
import { atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath, isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification, removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey, sha256Hex, writeFlyaiVerification, } from '../capabilities/flyai-config.js';
|
|
7
|
+
import { hbcliCredentialPath, hbcliSetupStatus, readHbcliCredentials, removeHbcliVerification, verifyHbcliCandidate, withHbcliCredentials, withoutHbcliCredentials, writeHbcliCredentials, writeHbcliVerification, } from '../capabilities/hbcli-config.js';
|
|
7
8
|
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
8
9
|
const rejection = (error, status = 400) => json({ ok: false, error }, status);
|
|
9
10
|
const BODY_LIMIT = 8192;
|
|
@@ -40,6 +41,7 @@ async function readInput(request) {
|
|
|
40
41
|
export function createGotryWebHandlers(options) {
|
|
41
42
|
const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env;
|
|
42
43
|
const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir);
|
|
44
|
+
const hbPaths = { homeDir, env, hbcliBin: options.hbcliBin };
|
|
43
45
|
let mutations = Promise.resolve();
|
|
44
46
|
const status = () => {
|
|
45
47
|
const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir);
|
|
@@ -99,7 +101,65 @@ export function createGotryWebHandlers(options) {
|
|
|
99
101
|
}
|
|
100
102
|
return json(status());
|
|
101
103
|
}
|
|
104
|
+
async function mutateHbcli(input, signal) {
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
return rejection('操作已取消。', 409);
|
|
107
|
+
if (!hbcliSetupStatus(hbPaths).writable)
|
|
108
|
+
return rejection('当前凭证来自环境变量,或本机配置不可安全写入。', 409);
|
|
109
|
+
const before = readHbcliCredentials(hbPaths);
|
|
110
|
+
if (!before.ok)
|
|
111
|
+
return rejection('原配置损坏,已保留原文件,请先修复。', 409);
|
|
112
|
+
if (input.action === 'clear') {
|
|
113
|
+
if (!writeHbcliCredentials(withoutHbcliCredentials(before.data), hbPaths))
|
|
114
|
+
return rejection('无法清除配置,请检查本机文件及权限。', 409);
|
|
115
|
+
removeHbcliVerification(homeDir);
|
|
116
|
+
return json(hbcliSetupStatus(hbPaths));
|
|
117
|
+
}
|
|
118
|
+
const appKey = typeof input.appKey === 'string' ? input.appKey.trim() : '';
|
|
119
|
+
const appSecret = typeof input.appSecret === 'string' ? input.appSecret.trim() : '';
|
|
120
|
+
if ([appKey, appSecret].some(value => !value || value.length > 2048 || /[\r\n\0]/.test(value)))
|
|
121
|
+
return rejection('请填写 HotelByte 提供的 App Key 和 App Secret。');
|
|
122
|
+
const verified = await verifyHbcliCandidate(appKey, appSecret, hbPaths, signal);
|
|
123
|
+
if (signal.aborted || verified.verdict === 'error' || verified.verdict === 'cancelled') {
|
|
124
|
+
return json({ ok: false, error: verified.error ?? '验证已取消,旧配置保留。' }, 422);
|
|
125
|
+
}
|
|
126
|
+
const current = readHbcliCredentials(hbPaths);
|
|
127
|
+
if (!hbcliSetupStatus(hbPaths).writable || !current.ok || current.bytes !== before.bytes)
|
|
128
|
+
return rejection('配置在验证期间发生变化,请重新读取后再试。', 409);
|
|
129
|
+
const next = withHbcliCredentials(before.data, appKey, appSecret);
|
|
130
|
+
if (!writeHbcliCredentials(next, hbPaths))
|
|
131
|
+
return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409);
|
|
132
|
+
if (!writeHbcliVerification(next, verified.verdict, hbPaths)) {
|
|
133
|
+
const restored = before.bytes === undefined
|
|
134
|
+
? (() => { try {
|
|
135
|
+
rmSync(hbcliCredentialPath(hbPaths));
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
} })()
|
|
141
|
+
: writeHbcliCredentials(before.data, hbPaths);
|
|
142
|
+
return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,请检查本机文件。', 409);
|
|
143
|
+
}
|
|
144
|
+
return json(hbcliSetupStatus(hbPaths));
|
|
145
|
+
}
|
|
102
146
|
return {
|
|
147
|
+
async hbcli(request) {
|
|
148
|
+
if (request.method === 'GET')
|
|
149
|
+
return json(hbcliSetupStatus(hbPaths));
|
|
150
|
+
if (request.method !== 'POST')
|
|
151
|
+
return rejection('不支持此操作。', 405);
|
|
152
|
+
const input = await readInput(request);
|
|
153
|
+
if (input instanceof Response)
|
|
154
|
+
return input;
|
|
155
|
+
if (!['save', 'clear'].includes(String(input.action))
|
|
156
|
+
|| Object.keys(input).some(key => !['action', 'appKey', 'appSecret'].includes(key))
|
|
157
|
+
|| (input.action === 'clear' && ('appKey' in input || 'appSecret' in input)))
|
|
158
|
+
return rejection('请求无效。');
|
|
159
|
+
const operation = mutations.then(() => mutateHbcli(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500));
|
|
160
|
+
mutations = operation.then(() => undefined);
|
|
161
|
+
return operation;
|
|
162
|
+
},
|
|
103
163
|
async flyai(request) {
|
|
104
164
|
if (request.method === 'GET')
|
|
105
165
|
return json(status());
|
|
@@ -137,12 +197,12 @@ export function createGotryWebHandlers(options) {
|
|
|
137
197
|
},
|
|
138
198
|
};
|
|
139
199
|
}
|
|
140
|
-
export function registerGotryWebApi(ctx, stateRoot) {
|
|
200
|
+
export function registerGotryWebApi(ctx, stateRoot, hbcliBin) {
|
|
141
201
|
if (typeof ctx.inject !== 'function')
|
|
142
202
|
return;
|
|
143
203
|
ctx.inject(['connection', 'sessions'], scope => {
|
|
144
204
|
const connection = scope.get('connection');
|
|
145
|
-
const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async (sessionId) => {
|
|
205
|
+
const handlers = createGotryWebHandlers({ stateRoot, hbcliBin, workspaceForSession: async (sessionId) => {
|
|
146
206
|
const id = sessionId;
|
|
147
207
|
const live = scope.sessions.get(id)?.header;
|
|
148
208
|
const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId);
|
|
@@ -152,6 +212,8 @@ export function registerGotryWebApi(ctx, stateRoot) {
|
|
|
152
212
|
// it. Exact routes have one body mode, so reads and bounded writes differ.
|
|
153
213
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }));
|
|
154
214
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }));
|
|
215
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli', methods: ['GET'], requestBody: 'buffered', fetch: handlers.hbcli }));
|
|
216
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.hbcli }));
|
|
155
217
|
scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }));
|
|
156
218
|
});
|
|
157
219
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -432,7 +432,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
432
432
|
// 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
|
|
433
433
|
// 必须产出最终交付物,不得再次 handoff(否则无限递归)。
|
|
434
434
|
if (!rawBenchmarkEnvironmentConfigPath) {
|
|
435
|
-
registerGotryWebApi(ctx, config.stateRoot ?? '.');
|
|
435
|
+
registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin);
|
|
436
436
|
registerArtifactDeliveries(ctx);
|
|
437
437
|
}
|
|
438
438
|
const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
|
|
@@ -1305,10 +1305,15 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1305
1305
|
const resp = itp.result;
|
|
1306
1306
|
const dir = await ensureStateDir(config.stateRoot);
|
|
1307
1307
|
const isLive = resp.via === 'hbcli-realtime';
|
|
1308
|
-
const
|
|
1308
|
+
const staticStays = resp.hotels?.stays;
|
|
1309
|
+
const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0;
|
|
1310
|
+
const liveList = resp.hotels?.list;
|
|
1311
|
+
const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error';
|
|
1312
|
+
const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence;
|
|
1309
1313
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => { });
|
|
1310
1314
|
const payload = {
|
|
1311
|
-
ok:
|
|
1315
|
+
ok: isLive || hasEstimate,
|
|
1316
|
+
verdict,
|
|
1312
1317
|
hotels: resp.hotels ?? null,
|
|
1313
1318
|
evidence,
|
|
1314
1319
|
destination: q.destination,
|
|
@@ -1316,6 +1321,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1316
1321
|
latency_ms: Date.now() - started,
|
|
1317
1322
|
summary: resp.summary,
|
|
1318
1323
|
error: resp.error,
|
|
1324
|
+
...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
|
|
1319
1325
|
...(dateNotes.length ? { date_notes: dateNotes } : {}),
|
|
1320
1326
|
};
|
|
1321
1327
|
return JSON.parse(JSON.stringify(payload));
|
|
@@ -1342,13 +1348,14 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1342
1348
|
};
|
|
1343
1349
|
}
|
|
1344
1350
|
const h = r.hotels;
|
|
1345
|
-
const
|
|
1351
|
+
const liveList = h?.list;
|
|
1352
|
+
const liveCount = Array.isArray(liveList) ? liveList.length : 0;
|
|
1346
1353
|
// 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
|
|
1347
1354
|
const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
|
|
1348
1355
|
const staticCount = Array.isArray(stays) ? stays.length : 0;
|
|
1349
1356
|
const tag = r.via === 'hbcli-realtime'
|
|
1350
|
-
?
|
|
1351
|
-
: staticCount ? `静态包 ${staticCount} 块` :
|
|
1357
|
+
? `实时 ${liveCount} 家`
|
|
1358
|
+
: staticCount ? `静态包 ${staticCount} 块` : '查询未完成';
|
|
1352
1359
|
return {
|
|
1353
1360
|
card: 'generic',
|
|
1354
1361
|
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
@@ -1731,8 +1738,9 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1731
1738
|
description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
|
|
1732
1739
|
+ 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
|
|
1733
1740
|
+ 'Transport: Stai Travel Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
1734
|
-
+ 'kind="flight": from/to
|
|
1735
|
-
+ '
|
|
1741
|
+
+ 'kind="flight": from/to city or airport names (Chinese/English) + date YYYY-MM-DD; unknown names resolve from supplier metadata, ambiguous names require confirmation; optional fromCityCode/toCityCode are explicit Ctrip three-letter codes — sniffs the site search API for structured options. '
|
|
1742
|
+
+ 'ui?="bridge" (default; the user\'s own logged-in session, real member prices) | "gui" (gotry opens its OWN isolated anonymous Chrome window and drives the Ctrip PC search form — roundtrip via backDate? and ANY city by construction: the site\'s own autocomplete resolves cities; anonymous public prices, NOT logged-in prices; verdicts carry anonymous=true; result-binding iteration tracked in #661). Evidence [会话:ctrip-flight@ts] / [会话:ctrip-flight-gui@ts]. '
|
|
1743
|
+
+ 'kind="hotel": to=destination name, country? for same-name disambiguation; cityId? = explicit numeric city= from a Ctrip hotel list URL; otherwise resolves from the hotel destination service, checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
|
|
1736
1744
|
+ 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
|
|
1737
1745
|
+ 'kind="train": from/to/date(YYYY-MM-DD) + fromStationTelecode?/toStationTelecode? (three-letter codes in the kyfw query URL, for cities outside the built-in table) — 12306 left-ticket query (public face): train codes, times, durations, seat availability; the list API carries NO prices (prices live on the 12306 page). Evidence [会话:train-12306@ts]. '
|
|
1738
1746
|
+ 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
|
|
@@ -1745,15 +1753,20 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1745
1753
|
// execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
|
|
1746
1754
|
parameters: {
|
|
1747
1755
|
kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
|
|
1748
|
-
from: { type: 'string', description: '
|
|
1756
|
+
from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
|
|
1749
1757
|
to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
|
|
1750
1758
|
date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
|
|
1751
|
-
|
|
1759
|
+
fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1760
|
+
toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1761
|
+
cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
|
|
1762
|
+
country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
|
|
1752
1763
|
checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1753
1764
|
checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1754
1765
|
adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
|
|
1755
1766
|
fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
|
|
1756
1767
|
toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
|
|
1768
|
+
backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
|
|
1769
|
+
ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/任意城市原生+低价日历;hotel 任意目的地原生(cityId 码表不在主链路);匿名公开价)' },
|
|
1757
1770
|
},
|
|
1758
1771
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v.summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
1759
1772
|
async execute(args, _exec) {
|
|
@@ -1830,17 +1843,17 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1830
1843
|
}
|
|
1831
1844
|
if (q.kind === 'hotel') {
|
|
1832
1845
|
if (!q.to) {
|
|
1833
|
-
return { ok: false, summary: 'kind=hotel 需要 to
|
|
1846
|
+
return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' };
|
|
1834
1847
|
}
|
|
1835
1848
|
const itp = await runSessionEffect({
|
|
1836
|
-
effect: 'SESSION_HOTEL_SEARCH',
|
|
1849
|
+
effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
|
|
1837
1850
|
params: {
|
|
1838
|
-
to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1851
|
+
to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1839
1852
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1840
1853
|
},
|
|
1841
1854
|
});
|
|
1842
1855
|
if (!itp.result)
|
|
1843
|
-
return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace);
|
|
1856
|
+
return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace);
|
|
1844
1857
|
const r = itp.result;
|
|
1845
1858
|
await noteChannel('session:ctrip-hotel', r.verdict);
|
|
1846
1859
|
// D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
|
|
@@ -1862,14 +1875,19 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1862
1875
|
}));
|
|
1863
1876
|
}
|
|
1864
1877
|
if (!q.from || !q.to || !q.date) {
|
|
1865
|
-
return { ok: false, summary: '需要 from/to
|
|
1878
|
+
return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
|
|
1866
1879
|
}
|
|
1867
1880
|
// 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
|
|
1868
1881
|
// 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
|
|
1882
|
+
// GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
|
|
1883
|
+
if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
|
|
1884
|
+
return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' };
|
|
1885
|
+
}
|
|
1869
1886
|
const itp = await runSessionEffect({
|
|
1870
|
-
effect: 'SESSION_FLIGHT_SEARCH',
|
|
1887
|
+
effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
|
|
1871
1888
|
params: {
|
|
1872
|
-
from: q.from, to: q.to, date: q.date,
|
|
1889
|
+
from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
|
|
1890
|
+
...(q.backDate ? { backDate: q.backDate } : {}),
|
|
1873
1891
|
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
1874
1892
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1875
1893
|
},
|
|
@@ -1882,8 +1900,12 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1882
1900
|
const avMapS = await loadAirlineAirportMap();
|
|
1883
1901
|
await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias));
|
|
1884
1902
|
const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
|
|
1903
|
+
const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)';
|
|
1904
|
+
const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`);
|
|
1905
|
+
const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : '';
|
|
1906
|
+
const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : '';
|
|
1885
1907
|
const summary = r.verdict === 'hit'
|
|
1886
|
-
? `${q.from}→${q.to} ${q.date}
|
|
1908
|
+
? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
|
|
1887
1909
|
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
|
|
1888
1910
|
return JSON.parse(JSON.stringify({
|
|
1889
1911
|
...r, summary,
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Stai Travel Bridge",
|
|
4
|
-
"version": "0.2.0.
|
|
4
|
+
"version": "0.2.0.32",
|
|
5
5
|
"description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
|
|
7
7
|
"permissions": [
|
|
@@ -56,5 +56,5 @@
|
|
|
56
56
|
"run_at": "document_start"
|
|
57
57
|
}
|
|
58
58
|
],
|
|
59
|
-
"version_name": "0.2.0-rc.
|
|
59
|
+
"version_name": "0.2.0-rc.32"
|
|
60
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danceiny/gotry",
|
|
3
|
-
"version": "0.2.0-rc.
|
|
3
|
+
"version": "0.2.0-rc.32",
|
|
4
4
|
"description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ts/src/index.ts",
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
"ts/capabilities/opensky.ts",
|
|
116
116
|
"ts/capabilities/flyai.ts",
|
|
117
117
|
"ts/capabilities/flyai-config.ts",
|
|
118
|
+
"ts/capabilities/hbcli-config.ts",
|
|
118
119
|
"ts/capabilities/session-search.ts",
|
|
119
120
|
"ts/capabilities/session/",
|
|
120
121
|
"extension/",
|
|
@@ -185,22 +185,22 @@ export function verifySecureMode(dir: string, file: string): { ok: boolean; erro
|
|
|
185
185
|
/**
|
|
186
186
|
* 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
|
|
187
187
|
* 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
|
|
188
|
-
* 0700
|
|
188
|
+
* 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
|
|
189
189
|
*/
|
|
190
|
-
function
|
|
190
|
+
function preparePrivateDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
|
|
191
191
|
if (process.platform === 'win32') return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' }
|
|
192
192
|
try {
|
|
193
193
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
194
194
|
const entry = lstatSync(dir)
|
|
195
|
-
if (entry.isSymbolicLink()) return { ok: false, error: '
|
|
196
|
-
if (!entry.isDirectory()) return { ok: false, error: '
|
|
195
|
+
if (entry.isSymbolicLink()) return { ok: false, error: '配置目录是符号链接,拒绝写入' }
|
|
196
|
+
if (!entry.isDirectory()) return { ok: false, error: '配置路径不是目录,拒绝写入' }
|
|
197
197
|
if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
|
|
198
|
-
return { ok: false, error: '
|
|
198
|
+
return { ok: false, error: '配置目录不属于当前用户,拒绝写入' }
|
|
199
199
|
}
|
|
200
200
|
if ((entry.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
|
|
201
201
|
const after = lstatSync(dir)
|
|
202
|
-
if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '
|
|
203
|
-
if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '
|
|
202
|
+
if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '配置目录状态发生变化,拒绝写入' }
|
|
203
|
+
if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '配置目录所有者发生变化,拒绝写入' }
|
|
204
204
|
return verifySecureMode(dir, file)
|
|
205
205
|
} catch (e) {
|
|
206
206
|
return { ok: false, error: (e as Error).message }
|
|
@@ -259,7 +259,7 @@ export function saveFlyaiKey(key: string, opts: FlyaiConfigPaths = {}): SaveFlya
|
|
|
259
259
|
const read = readFlyaiConfig(configPath)
|
|
260
260
|
if (!read.ok) return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` }
|
|
261
261
|
|
|
262
|
-
const configDir =
|
|
262
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath)
|
|
263
263
|
if (!configDir.ok) return { ok: false, error: configDir.error }
|
|
264
264
|
const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text })
|
|
265
265
|
if (!write.ok) return { ok: false, error: write.error }
|
|
@@ -297,7 +297,7 @@ export function clearFlyaiKey(opts: FlyaiConfigPaths = {}): ClearFlyaiKeyResult
|
|
|
297
297
|
return { ok: false, source: 'none', error: `移除文件失败:${(e as Error).message}` }
|
|
298
298
|
}
|
|
299
299
|
} else {
|
|
300
|
-
const configDir =
|
|
300
|
+
const configDir = preparePrivateDir(dirname(configPath), configPath)
|
|
301
301
|
if (!configDir.ok) return { ok: false, source: 'none', error: configDir.error }
|
|
302
302
|
const write = atomicWriteJson(configPath, next)
|
|
303
303
|
if (!write.ok) return { ok: false, source: 'none', error: write.error }
|
|
@@ -355,7 +355,9 @@ export function readFlyaiVerification(homeDir: string): FlyaiVerification | unde
|
|
|
355
355
|
|
|
356
356
|
/** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
|
|
357
357
|
export function writeFlyaiVerification(entry: FlyaiVerification, homeDir: string): boolean {
|
|
358
|
-
|
|
358
|
+
const path = flyaiVerificationPath(homeDir)
|
|
359
|
+
const prepared = preparePrivateDir(dirname(path), path)
|
|
360
|
+
return prepared.ok && atomicWriteJson(path, entry).ok
|
|
359
361
|
}
|
|
360
362
|
|
|
361
363
|
export function removeFlyaiVerification(homeDir: string): void {
|