import type { Locator, Page } from "playwright"; import type { QuoteRequest } from "@/modules/providers/quote-provider"; import { pageLocator } from "@/lib/rpa/locator"; import { clickFirstVisibleFromSpecs } from "@/lib/rpa/selector-specs"; import { RpaError } from "@/modules/rpa/errors"; import type { RPAContext } from "@/workers/rpa/kernel/context"; import type { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge"; import { getAddressConfirmationSurfaceText, isAddressInputCommitted, isAddressSuggestionDropdownOpen, waitForAddressSuggestionLocked, } from "@/workers/rpa/address-suggestions"; import { visibleStreetInput } from "@/workers/rpa/address-side"; type AddressFields = QuoteRequest["pickup"]; const POLL_MS = 200; const ADDRESS_SIDE_COMMIT_TIMEOUT_MS = 12_000; const ADDRESS_INPUT_STABLE_MS = 2_000; export type AddressCommitSnapshot = { side: "pickup" | "delivery"; inputValues: string[]; surfaceText: string; widgetTextPreview: string; dropdownOpen: boolean; axelLocationHit: boolean; }; function toQueryHint(address: AddressFields) { return { street: address.street, city: address.city ?? "", state: address.state ?? "", zip: address.zip ?? "", }; } function normalizeWidgetText(text: string): string { return text .replace(/\bavenue\b/gi, "ave") .replace(/\bboulevard\b/gi, "blvd") .replace(/\bstreet\b/gi, "st") .replace(/\s+/g, " ") .trim() .toLowerCase(); } function hasWordToken(text: string, token: string): boolean { const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`\\b${escaped}\\b`, "i").test(text); } /** widget 内是否体现用户确认的 MotherShip 地址(防 widget 回退) */ export function widgetContainsConfirmedAddress( widgetText: string, address: AddressFields, ): boolean { const label = address.mothershipDisplayLabel?.trim(); if (!label) { return false; } const w = normalizeWidgetText(widgetText); const streetNum = address.street.match(/^(\d+)/)?.[1]; const labelNorm = normalizeWidgetText(label); if ( streetNum && labelNorm.includes(streetNum.toLowerCase()) && !w.includes(streetNum.toLowerCase()) ) { return false; } const state = address.state?.trim(); if (state && !w.includes(state.toLowerCase())) { return false; } const city = address.city?.trim().toLowerCase(); if (city && city.length >= 3 && !w.includes(city)) { const cityParts = city.split(/\s+/).filter((part) => part.length >= 2); if (cityParts.length > 1) { const allPartsPresent = cityParts.every((part) => hasWordToken(widgetText, part), ); if (!allPartsPresent) { return false; } } else { const streetCore = normalizeWidgetText(address.street); if (!streetCore || !w.includes(streetCore)) { return false; } } } const labelTokens = label .replace(/([a-z])([A-Z])/g, "$1 $2") .split(/[,\s]+/) .map((t) => t.toLowerCase()) .filter((t) => t.length > 3); const streetCore = normalizeWidgetText(address.street); if ( streetCore && !/\bbridge\b/.test(streetCore) && /\bbridge\b/.test(w) && w.includes(streetCore.split(/\s+/)[0] ?? "") ) { return false; } return labelTokens.some((t) => w.includes(t)); } async function readCommittedInputSurface( page: Page, widgetSelector: string, ): Promise { return page.evaluate( `(function(sel) { var widget = document.querySelector(sel); if (!widget) return ""; var chunks = []; var inputs = widget.querySelectorAll("input"); for (var i = 0; i < inputs.length; i++) { var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim(); if (val.length >= 8) chunks.push(val); } return chunks.join(" "); })(${JSON.stringify(widgetSelector)})`, ) as Promise; } async function readStreetInputValues( page: Page, widgetSelector: string, ): Promise { return page.evaluate( `(function(sel) { var widget = document.querySelector(sel); if (!widget) return []; var out = []; var inputs = widget.querySelectorAll("input"); for (var i = 0; i < inputs.length; i++) { var ph = (inputs[i].placeholder || "").toLowerCase(); if (!/search|搜索/.test(ph)) continue; var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim(); if (val) out.push(val); } return out; })(${JSON.stringify(widgetSelector)})`, ) as Promise; } export async function readWidgetCombinedText( page: Page, widgetSelector: string, ): Promise { const surfaceText = await getAddressConfirmationSurfaceText( page, widgetSelector, ); const fullText = await pageLocator(page, widgetSelector) .innerText() .catch(() => ""); return [surfaceText, fullText] .map((part) => part.replace(/\s+/g, " ").trim()) .filter(Boolean) .join(" "); } export async function captureAddressCommitSnapshot( ctx: RPAContext, side: "pickup" | "delivery", widgetSelector: string, axelLocationHit = false, ): Promise { const widgetText = await readWidgetCombinedText(ctx.page, widgetSelector); return { side, inputValues: await readStreetInputValues(ctx.page, widgetSelector), surfaceText: await getAddressConfirmationSurfaceText( ctx.page, widgetSelector, ), widgetTextPreview: widgetText.slice(0, 220), dropdownOpen: await isAddressSuggestionDropdownOpen(ctx.page), axelLocationHit, }; } export function logAddressCommitSnapshot( phase: string, snapshot: AddressCommitSnapshot, ): void { console.log( `[rpa] address-commit:${phase} side=${snapshot.side} inputs=${JSON.stringify(snapshot.inputValues)} dropdown=${snapshot.dropdownOpen} axelLocation=${snapshot.axelLocationHit} widget=${snapshot.widgetTextPreview}`, ); } export async function dismissAddressSuggestionDropdown( page: Page, streetInput?: Locator | null, ): Promise { const deadline = Date.now() + 4_000; while (Date.now() < deadline) { if (!(await isAddressSuggestionDropdownOpen(page))) { return true; } if (streetInput) { await streetInput.press("Enter").catch(() => undefined); } await page.keyboard.press("Escape").catch(() => undefined); await page.keyboard.press("Tab").catch(() => undefined); await page.waitForTimeout(POLL_MS); } return !(await isAddressSuggestionDropdownOpen(page)); } /** 强制收起联想(仅键盘,避免误点 Use my location / 重置 widget) */ export async function forceDismissAddressSuggestionDropdown( page: Page, _widgetSelector: string, ): Promise { for (let i = 0; i < 8; i += 1) { if (!(await isAddressSuggestionDropdownOpen(page))) { return; } await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(POLL_MS); } for (let i = 0; i < 3; i += 1) { await page.keyboard.press("Tab").catch(() => undefined); await page.waitForTimeout(POLL_MS); if (!(await isAddressSuggestionDropdownOpen(page))) { return; } } } async function readAddressSurfaceForCommit( page: Page, widgetSelector: string, dropdownOpen: boolean, ): Promise { const surfaceText = await getAddressConfirmationSurfaceText( page, widgetSelector, ); if (dropdownOpen) { return surfaceText; } return readWidgetCombinedText(page, widgetSelector); } async function waitForInputSurfaceStable( page: Page, widgetSelector: string, address: AddressFields, stableMs: number, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; let lastSurface = ""; let stableSince = 0; while (Date.now() < deadline) { const surface = await getAddressConfirmationSurfaceText(page, widgetSelector); const now = Date.now(); if (widgetContainsConfirmedAddress(surface, address)) { if (surface === lastSurface) { if (stableSince === 0) { stableSince = now; } if (now - stableSince >= stableMs) { return true; } } else { lastSurface = surface; stableSince = now; } } else { lastSurface = ""; stableSince = 0; } await page.waitForTimeout(POLL_MS); } return false; } async function waitForInputValueStable( input: Locator, address: AddressFields, stableMs: number, timeoutMs: number, ): Promise { const queryHint = toQueryHint(address); const deadline = Date.now() + timeoutMs; let lastValue = ""; let stableSince = 0; while (Date.now() < deadline) { const value = ((await input.inputValue().catch(() => "")) || "") .replace(/\s+/g, " ") .trim(); const committed = await isAddressInputCommitted(input, queryHint); const now = Date.now(); if (value.length >= 8 && committed && value === lastValue) { if (stableSince === 0) { stableSince = now; } if (now - stableSince >= stableMs) { return true; } } else { lastValue = value; stableSince = committed && value.length >= 8 ? now : 0; } await input.page().waitForTimeout(POLL_MS); } return false; } /** 收起当前地址编辑焦点,避免双地址 focus 竞争 */ export async function blurAddressEditing( ctx: RPAContext, widgetSelector: string, ): Promise { const page = ctx.page; await page.keyboard.press("Escape").catch(() => undefined); await page.keyboard.press("Tab").catch(() => undefined); const deadline = Date.now() + 3_000; while (Date.now() < deadline) { if (!(await isAddressSuggestionDropdownOpen(page))) { return; } await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(POLL_MS); } } /** pickup 提交后、打开 delivery 前:锁定提货侧,防止 React 未 commit 即切换 */ export async function settlePickupBeforeDelivery( ctx: RPAContext, widgetSelector: string, pickup: AddressFields, ): Promise { await blurAddressEditing(ctx, widgetSelector); await dismissAddressSuggestionDropdown(ctx.page); await forceDismissAddressSuggestionDropdown(ctx.page, widgetSelector); await ctx.page.keyboard.press("Tab").catch(() => undefined); await ctx.page.waitForTimeout(POLL_MS); const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS; while (Date.now() < deadline) { if (await isAddressPresentInWidget(ctx.page, widgetSelector, pickup)) { break; } await ctx.page.waitForTimeout(POLL_MS); } if (!(await isAddressPresentInWidget(ctx.page, widgetSelector, pickup))) { throw new RpaError( "ADDRESS_NOT_CONFIRMED", `提货地址未写入 widget:${pickup.mothershipDisplayLabel}`, { retryable: true }, ); } while (Date.now() < deadline) { if (await isPickupReadyForDeliveryInput(ctx.page, widgetSelector, pickup)) { break; } await releasePickupFieldForDelivery(ctx, widgetSelector, pickup); await ctx.page.waitForTimeout(POLL_MS); } const snapshot = await captureAddressCommitSnapshot( ctx, "pickup", widgetSelector, ); logAddressCommitSnapshot("pickup-settled", snapshot); if (!(await isPickupReadyForDeliveryInput(ctx.page, widgetSelector, pickup))) { const diag = await captureAddressCommitSnapshot(ctx, "pickup", widgetSelector); console.log("[rpa] pickup-commit-diagnostic", { phase: "settlePickupBeforeDelivery", placeId: pickup.placeId, mothershipDisplayLabel: pickup.mothershipDisplayLabel, commitState: "widget_not_locked", placeRequestObserved: diag.axelLocationHit, inputValues: diag.inputValues, dropdownOpen: diag.dropdownOpen, }); throw new RpaError( "ADDRESS_NOT_CONFIRMED", `提货地址未锁定,无法打开派送侧(${pickup.mothershipDisplayLabel})`, { retryable: true }, ); } } /** 等待单侧地址被 MotherShip widget 接受(须 place/{id} 200,禁止仅以 input.value 判定) */ export async function waitForAddressSideCommit( ctx: RPAContext, side: "pickup" | "delivery", address: AddressFields, widgetSelector: string, streetInput?: Locator | null, placeCommit?: { bridge: AxelSelectionBridge; placeId: string }, ): Promise { const page = ctx.page; const placeId = placeCommit?.placeId?.trim(); const bridge = placeCommit?.bridge; if (placeId && bridge) { if (bridge.hasPlaceCommit(placeId)) { const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, true, ); logAddressCommitSnapshot(`${side}-committed`, snapshot); return; } const committed = await bridge.waitForPlaceCommit( placeId, ADDRESS_SIDE_COMMIT_TIMEOUT_MS, ); if (committed) { const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, true, ); logAddressCommitSnapshot(`${side}-committed`, snapshot); return; } const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, false, ); logAddressCommitSnapshot(`${side}-commit-failed`, snapshot); throw new RpaError( "ADDRESS_NOT_CONFIRMED", `${side === "pickup" ? "提货" : "派送"}地址 place/${placeId} 未 commit(网络证据缺失)`, { retryable: true }, ); } const queryHint = toQueryHint(address); const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS; let consecutiveSurfaceMatches = 0; while (Date.now() < deadline) { const dropdownOpen = await isAddressSuggestionDropdownOpen(page); if (dropdownOpen) { await dismissAddressSuggestionDropdown(page, streetInput ?? undefined); } const surface = await readCommittedInputSurface(page, widgetSelector); const surfaceOk = widgetContainsConfirmedAddress(surface, address); if (surfaceOk) { consecutiveSurfaceMatches += 1; if (consecutiveSurfaceMatches * POLL_MS >= ADDRESS_INPUT_STABLE_MS) { const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, false, ); logAddressCommitSnapshot(`${side}-committed`, snapshot); return; } } else { consecutiveSurfaceMatches = 0; } if (!surfaceOk && !dropdownOpen) { const widgetText = await readWidgetCombinedText(page, widgetSelector); if (widgetContainsConfirmedAddress(widgetText, address)) { const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, false, ); logAddressCommitSnapshot(`${side}-committed`, snapshot); return; } } if (streetInput) { const inputVisible = await streetInput.isVisible().catch(() => false); const inputEditable = await streetInput.isEditable().catch(() => false); if (inputVisible && inputEditable && (await isAddressInputCommitted(streetInput, queryHint))) { const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, false, ); logAddressCommitSnapshot(`${side}-committed`, snapshot); return; } } await page.waitForTimeout(POLL_MS); } const snapshot = await captureAddressCommitSnapshot( ctx, side, widgetSelector, false, ); logAddressCommitSnapshot(`${side}-commit-failed`, snapshot); throw new RpaError( "ADDRESS_NOT_CONFIRMED", `${side === "pickup" ? "提货" : "派送"}地址未提交到 widget(${snapshot.widgetTextPreview.slice(0, 100)})`, { retryable: true }, ); } /** 双地址快照校验;delivery 为 null 时仅校验 pickup 已锁定(非仅 input 文本回显) */ export async function assertDualAddressStable( ctx: RPAContext, widgetSelector: string, pickup: AddressFields, delivery: AddressFields | null, ): Promise { const page = ctx.page; const deadline = Date.now() + ADDRESS_SIDE_COMMIT_TIMEOUT_MS; let lastPickupOk = false; let lastDeliveryOk = delivery === null; while (Date.now() < deadline) { if (delivery === null) { lastPickupOk = await isPickupReadyForDeliveryInput( page, widgetSelector, pickup, ); } else { const surface = await readCommittedInputSurface(page, widgetSelector); const full = await readWidgetCombinedText(page, widgetSelector); const combined = `${surface} ${full}`; lastPickupOk = widgetContainsConfirmedAddress(surface, pickup) || widgetContainsConfirmedAddress(full, pickup) || widgetContainsConfirmedAddress(combined, pickup); lastDeliveryOk = widgetContainsConfirmedAddress(surface, delivery) || widgetContainsConfirmedAddress(full, delivery) || widgetContainsConfirmedAddress(combined, delivery); } if (lastPickupOk && lastDeliveryOk) { const snapshot = await captureAddressCommitSnapshot( ctx, "pickup", widgetSelector, ); logAddressCommitSnapshot("dual-stable", { ...snapshot, side: "pickup", widgetTextPreview: snapshot.widgetTextPreview.slice(0, 220), }); return; } if (delivery && lastPickupOk === false) { await blurAddressEditing(ctx, widgetSelector); const pickupSections = ctx.selectors.specs("RPA_SELECTOR_PICKUP_SECTION"); if (pickupSections.length > 0) { await clickFirstVisibleFromSpecs(page, pickupSections).catch( () => undefined, ); } } await page.waitForTimeout(POLL_MS); } const widgetText = await readWidgetCombinedText(page, widgetSelector); if (!lastPickupOk) { throw new RpaError( "ADDRESS_NOT_CONFIRMED", `双地址校验失败:提货地址已从 widget 消失(${pickup.mothershipDisplayLabel})preview=${widgetText.slice(0, 120)}`, { retryable: true }, ); } if (delivery && !lastDeliveryOk) { throw new RpaError( "ADDRESS_NOT_CONFIRMED", `双地址校验失败:派送地址未锁定(${delivery.mothershipDisplayLabel})preview=${widgetText.slice(0, 120)}`, { retryable: true }, ); } } export async function isAddressPresentInWidget( page: Page, widgetSelector: string, address: AddressFields, ): Promise { const surface = await readCommittedInputSurface(page, widgetSelector); const full = await readWidgetCombinedText(page, widgetSelector); return widgetContainsConfirmedAddress(`${surface} ${full}`, address); } /** 纯函数:widget 文案是否已体现双地址(截图态:两州码 / 两门牌 / Freight details 旁双址) */ export function evaluateAddressesVisuallyComplete(widgetText: string): boolean { const normalized = widgetText.replace(/\s+/g, " ").trim(); if (!normalized) { return false; } const stateCount = (normalized.match(/,\s*[A-Z]{2}\b/g) ?? []).length; const streetMarkers = normalized.match(/\d{3,5}\s+\S+/g) ?? []; const hasFreightChip = /freight details|货运详情/i.test(normalized); if (stateCount >= 2) { return true; } if (streetMarkers.length >= 2) { return true; } if (hasFreightChip && (stateCount >= 1 || streetMarkers.length >= 1)) { const hasDeliverTo = /deliver to|送货至/i.test(normalized); if (hasDeliverTo || stateCount >= 1) { return true; } } return false; } /** * 视觉优先:双地址已在 widget/地图体现即可进入货物步(不等 place 网络 / 下拉收起)。 */ export async function isAddressesVisuallyCompleteForCargo( page: Page, widgetSelector: string, pickup?: AddressFields, delivery?: AddressFields, ): Promise { const widgetText = await readWidgetCombinedText(page, widgetSelector).catch( () => "", ); const surface = await readCommittedInputSurface(page, widgetSelector).catch( () => "", ); const combined = `${surface} ${widgetText}`.replace(/\s+/g, " ").trim(); if (pickup && delivery) { const pickupOk = widgetContainsConfirmedAddress(combined, pickup); const deliveryOk = widgetContainsConfirmedAddress(combined, delivery); if (pickupOk && deliveryOk) { return true; } } if (evaluateAddressesVisuallyComplete(combined)) { return true; } return page.evaluate(() => { const body = (document.body.innerText || "").replace(/\s+/g, " "); const stateHits = body.match(/,\s*[A-Z]{2}\b/g) ?? []; if (stateHits.length < 2) { return false; } const hasMap = document.querySelector("canvas") !== null || document.querySelector("svg") !== null || document.querySelector('[class*="map" i]') !== null; return hasMap; }); } async function hasEmptyEditableStreetInput( page: Page, widgetSelector: string, ): Promise { return page.evaluate( `(function(sel) { var widget = document.querySelector(sel); if (!widget) return false; var inputs = widget.querySelectorAll("input"); for (var i = 0; i < inputs.length; i++) { var ph = (inputs[i].placeholder || "").toLowerCase(); if (!/search|搜索/.test(ph)) continue; if (inputs[i].offsetParent === null) continue; var val = (inputs[i].value || "").replace(/\\s+/g, " ").trim(); var editable = !inputs[i].disabled && inputs[i].readOnly === false && inputs[i].getAttribute("aria-readonly") !== "true"; if (editable && val.length < 8) return true; } return false; })(${JSON.stringify(widgetSelector)})`, ) as Promise; } /** 提货已写入 widget 且派送侧可独立输入(非同一搜索框覆盖) */ export async function isPickupReadyForDeliveryInput( page: Page, widgetSelector: string, pickup: AddressFields, ): Promise { await forceDismissAddressSuggestionDropdown(page, widgetSelector); const values = await readStreetInputValues(page, widgetSelector); const surface = await readCommittedInputSurface(page, widgetSelector); const full = await readWidgetCombinedText(page, widgetSelector); const combined = `${surface} ${full}`; if (!widgetContainsConfirmedAddress(combined, pickup)) { return false; } if (values.length >= 2) { return true; } if (await hasEmptyEditableStreetInput(page, widgetSelector)) { return true; } if (values.length === 1) { const only = values[0]!.replace(/\s+/g, " ").trim().toLowerCase(); const streetNum = pickup.street.match(/^\d+/)?.[0]?.toLowerCase(); if (streetNum && only.includes(streetNum) && only.length >= 8) { return false; } if (only.length >= 8) { const deliverVisible = await pageLocator(page, widgetSelector) .getByText(/^Deliver to$/i) .first() .isVisible() .catch(() => false); if (deliverVisible && widgetContainsConfirmedAddress(combined, pickup)) { return true; } return false; } return only.length < 8; } // chip 态:inputs 为空但 widget 已含提货且派送框可编辑 return values.length === 0 && (await hasEmptyEditableStreetInput(page, widgetSelector)); } /** 提货点选后:Enter/Tab 收起联想,再点「Deliver to」释放派送框 */ export async function lockPickupIntoChip( ctx: RPAContext, widgetSelector: string, streetInput?: Locator | null, ): Promise { const page = ctx.page; const widget = pageLocator(page, widgetSelector); await dismissAddressSuggestionDropdown(page); if (streetInput) { await streetInput.press("Enter").catch(() => undefined); await page.waitForTimeout(250); } await page.keyboard.press("Tab").catch(() => undefined); await page.waitForTimeout(200); await forceDismissAddressSuggestionDropdown(page, widgetSelector); const deliverTo = widget.getByText(/^Deliver to$/i).first(); if (await deliverTo.isVisible().catch(() => false)) { await deliverTo.click({ force: true, timeout: 3_000 }).catch(() => undefined); } else { await clickFirstVisibleFromSpecs( page, ctx.selectors.specs("RPA_SELECTOR_DELIVERY_SECTION"), ).catch(() => undefined); } await page.waitForTimeout(400); await dismissAddressSuggestionDropdown(page); } /** 点选提货后:收起下拉并打开派送侧,释放独立搜索框 */ export async function releasePickupFieldForDelivery( ctx: RPAContext, widgetSelector: string, pickup: AddressFields, streetInput?: Locator | null, ): Promise { const page = ctx.page; if (await isPickupReadyForDeliveryInput(page, widgetSelector, pickup)) { return true; } const pickupInput = streetInput ?? (await visibleStreetInput(ctx, "pickup")); await lockPickupIntoChip(ctx, widgetSelector, pickupInput ?? undefined); return isPickupReadyForDeliveryInput(page, widgetSelector, pickup); } /** delivery 提交后若 pickup 被覆盖:交替 refill 直到双地址均出现在 widget */ export async function ensureDualAddressCommitted( ctx: RPAContext, widgetSelector: string, pickup: AddressFields, delivery: AddressFields, refillSide: ( side: "pickup" | "delivery", address: AddressFields, ) => Promise, ): Promise { const page = ctx.page; const MAX_REFILL_ROUNDS = 6; for (let round = 0; round < MAX_REFILL_ROUNDS; round += 1) { await dismissAddressSuggestionDropdown(page); await blurAddressEditing(ctx, widgetSelector); const pickupOk = await isAddressPresentInWidget( page, widgetSelector, pickup, ); const deliveryOk = await isAddressPresentInWidget( page, widgetSelector, delivery, ); if (pickupOk && deliveryOk) { break; } if (!pickupOk) { console.log( `[rpa] address-commit:pickup-missing refill round=${round + 1}`, ); await refillSide("pickup", pickup); continue; } if (!deliveryOk) { console.log( `[rpa] address-commit:delivery-missing refill round=${round + 1}`, ); await refillSide("delivery", delivery); continue; } } await assertDualAddressStable(ctx, widgetSelector, pickup, delivery); } export async function waitForPickupCommit( ctx: RPAContext, pickup: AddressFields, widgetSelector: string, streetInput?: Locator | null, ): Promise { await waitForAddressSideCommit( ctx, "pickup", pickup, widgetSelector, streetInput, ); }