/** * 首页长按卡片→多选→批量删除(快),保留指定设备。安全开关:RESET_EXCEPT=1。 * Android: RESET_EXCEPT=1 PLATFORM=android npx ts-node scripts/reset-except.ts * iOS: RESET_EXCEPT=1 PLATFORM=ios npx ts-node scripts/reset-except.ts * * 保留:KEEP_DEVICES(精确/子串) + KEEP_KEYWORDS(子串,默认 Cam,Doorbell)。锁/摄像头/门铃自动化无法重加,务必保留。 * 「删除所有」可传 KEEP_DEVICES="" KEEP_KEYWORDS=""(谨慎:会把锁/摄像头也删掉)。 * * 平台差异: * - Android:卡片靠 resource-id nameText/nameTextMeter 定位;多选底部 "Delete"(text),确认弹框 Delete/OK。 * - iOS:卡片是 XCUIElementTypeCell(name=设备名+状态拼接);长按进多选("N selected"/Finish), * 底部 "Delete" → 确认弹框 "Delete the selected items?" 点 "Confirm"。长按偶发不进多选→重试。 */ import * as dotenv from 'dotenv'; import * as path from 'path'; dotenv.config({ path: path.resolve(__dirname, '../.env') }); import { createDriver } from '../drivers/factory'; const KEEP = (process.env.KEEP_DEVICES || 'Lock 6D,Lock Pro DE,Blind Tilt 42').split(',').map(s => s.trim()).filter(Boolean); // 关键字保留(子串匹配):摄像头/门铃/Lock Lite 名带随机后缀且品类多,用关键字一网打尽(自动化无法重加,绝不可删)。 // ⚠️ Lock Lite 必须默认保留(无法重加);此前默认漏了 Lock Lite,iOS reset(用默认)会误删 → 加入默认。 const KEEP_KW = (process.env.KEEP_KEYWORDS || 'Cam,Doorbell,Lock Lite').split(',').map(s => s.trim()).filter(Boolean); // 例外:即使命中 KEEP_KW 也**强制可删**(如 Outdoor Pan/Tilt Cam 含 "Cam" 但可经继电器重加 → 不保留)。 const NEVER_KEEP = (process.env.DELETE_ANYWAY || 'Outdoor Pan/Tilt').split(',').map(s => s.trim()).filter(Boolean); const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); // Android 首页设备卡名 + 中心坐标(nameText / nameTextMeter) function androidDevices(src: string): { name: string; cx: number; cy: number }[] { const out: { name: string; cx: number; cy: number }[] = []; for (const n of src.split('<')) { if (!/resource-id="[^"]*\/(nameText|nameTextMeter)"/.test(n)) continue; const t = (n.match(/text="([^"]*)"/) || [])[1]; const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/); if (t && t.trim() && b) out.push({ name: t.trim(), cx: Math.round((+b[1] + +b[3]) / 2), cy: Math.round((+b[2] + +b[4]) / 2) }); } return out; } // Android 精确名匹配(nameText 是纯设备名);iOS cell.name 含拼接状态 → 用子串匹配。 const isKeptAndroid = (name: string) => !NEVER_KEEP.some(k => name.includes(k)) && (KEEP.some(k => name === k) || KEEP_KW.some(k => name.includes(k))); const isKeptIOS = (name: string) => !NEVER_KEEP.some(k => name.includes(k)) && (KEEP.some(k => name.includes(k)) || KEEP_KW.some(k => name.includes(k))); // iOS cell.name 形如 "Hub Mini EATemperature 25.0..." → 截到状态前,仅用于日志可读 const shortName = (n: string) => n.split(/Temperature|Locked|Unlocked|Motion|No response|Not |Fully|>24|Open|Close|\d+%|\d+:\d+/)[0].trim() || n.slice(0, 20); async function resetAndroid(driver: any): Promise { const hasDelete = () => driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Delete")'); const exitSelect = async () => { const f = await driver.findElementRaw('-android uiautomator', 'new UiSelector().text("Finish")'); if (f) { await driver.tapElement(f); await sleep(700); } }; let total = 0; for (let round = 0; round < 30; round++) { await driver.goBackToHomepage(); await sleep(1800); await driver.dismissPopupIfPresent(); const all = androidDevices(await driver.getSource()); const nonKept = all.filter((x: any) => !isKeptAndroid(x.name)); if (!nonKept.length) { console.log('只剩保留项,结束'); break; } const first = nonKept[0]; await driver.longPress(first.cx, first.cy, 1.0); await sleep(1000); if (!(await hasDelete())) { await exitSelect(); console.log(`长按 ${first.name} 未进多选,跳过本轮`); continue; } const batch = [first.name]; for (const x of androidDevices(await driver.getSource()).filter((o: any) => !isKeptAndroid(o.name) && o.name !== first.name)) { await driver.tap(x.cx, x.cy); await sleep(400); if (await hasDelete()) { batch.push(x.name); } else { await driver.tap(x.cx, x.cy); await sleep(300); } } const del = await hasDelete(); if (!del) { await exitSelect(); continue; } await driver.tapElement(del); await sleep(900); for (let r = 0; r < 4; r++) { const s = await driver.getSource(); if (/Cancel|取消|delete|删除/i.test(s)) { let c: string | null = null; for (const t of ['Delete', 'OK', '删除', 'Confirm']) { c = await driver.findElementRaw('-android uiautomator', `new UiSelector().text("${t}")`); if (c) break; } if (c) { await driver.tapElement(c); break; } } await sleep(500); } await sleep(2000); total += batch.length; console.log(`本批删 ${batch.length}: ${batch.join(', ')}`); } await driver.goBackToHomepage(); await sleep(1500); console.log(`\n共删 ${total}。剩余:`, androidDevices(await driver.getSource()).map((x: any) => x.name).join(', ') || '(无)'); } async function resetIOS(driver: any): Promise { // 枚举首页设备卡片(Cell:name=设备名+状态;卡宽 ~175,过滤掉非设备 cell) const cards = async (): Promise<{ name: string; cx: number; cy: number }[]> => { const cells = await driver.findElementsRaw('predicate string', 'type == "XCUIElementTypeCell"').catch(() => []); const out: { name: string; cx: number; cy: number }[] = []; for (const c of cells as string[]) { const nm = ((await driver.getElementAttribute(c, 'name').catch(() => '')) || '') as string; const r = await driver.getElementRect(c).catch(() => null); if (nm && r && r.width > 100 && r.width < 260) out.push({ name: nm, cx: Math.round(r.x + r.width / 2), cy: Math.round(r.y + r.height / 2) }); } return out; }; const { height: winH } = await driver.getWindowSize().catch(() => ({ height: 844 })); const undeletable = new Set(); // 固定插件/删不掉的项 → 记下跳过,不再死磕 const deletedOnce = new Set(); // 删过的名字;若再次出现 = 删除异常(如 Keypad Vision 绑锁删不掉)→ 标记跳过 let total = 0; for (let round = 0; round < 60; round++) { await driver.goBackToHomepage(); await sleep(1500); await driver.dismissPopupIfPresent(); // 往下滚动,找第一张**可视区内、非白名单、未标记删不掉**的设备卡 let target: { name: string; cx: number; cy: number } | null = null; let lastNames = ''; for (let s = 0; s < 18 && !target; s++) { const vis = (await cards()).filter((x) => !isKeptIOS(x.name) && !undeletable.has(x.name) && x.cy > winH * 0.08 && x.cy < winH * 0.9); if (vis.length) { target = vis[0]; break; } const names = ((await driver.getSource()).match(/XCUIElementTypeCell[^>]*?name="[^"]*"/g) || []).join('|'); if (names && names === lastNames) break; // 到底,本屏无可删项 lastNames = names; await driver.scrollDown(500); await sleep(700); } if (!target) { console.log('无可删的非白名单设备,结束'); break; } // 删过却又出现 = 删除异常(如 Keypad Vision 绑锁,弹"需先解绑"删不掉)→ 标记跳过,不再死循环 if (deletedOnce.has(target.name)) { console.log(`${shortName(target.name)} 删除异常(删后仍在),跳过`); undeletable.add(target.name); continue; } // 长按该卡进多选(自动选中它)→ **只删它一张**(不批量勾其它,避免破坏多选)→ Confirm let inSel = false; for (let i = 0; i < 3 && !inSel; i++) { await driver.longPress(target.cx, target.cy, 1.6); await sleep(2000); inSel = /selected|Finish/.test(await driver.getSource()); } if (!inSel) { console.log(`长按 ${shortName(target.name)} 未进多选,标记跳过`); undeletable.add(target.name); continue; } // 底部 Delete 按钮(限定 Button,轮询等渲染) let del: string | null = null; for (let k = 0; k < 6 && !del; k++) { del = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Delete"').catch(() => null); if (!del) await sleep(500); } if (!del) { // 长按了但没 Delete = **固定插件/不可删项** → 退出多选 + 标记跳过(下轮选别的,不再卡它) console.log(`${shortName(target.name)} 无 Delete(固定插件?),标记跳过`); undeletable.add(target.name); for (const t of ['Finish', 'Cancel', '完成', '取消']) { const f = await driver.findElementRaw('predicate string', `name == "${t}"`).catch(() => null); if (f) { await driver.tapElement(f); await sleep(800); break; } } continue; } // 效率优化:当前屏内其它可删卡也勾上,一次 Delete 批量删(已跳过固定插件 → 不会破坏多选)。 const batchFull = [target.name]; for (const x of (await cards()).filter((o) => !isKeptIOS(o.name) && !undeletable.has(o.name) && o.cy > winH * 0.08 && o.cy < winH * 0.9 && (Math.abs(o.cx - target.cx) + Math.abs(o.cy - target.cy) > 10))) { await driver.tap(x.cx, x.cy); await sleep(400); // 每勾一张校验 Delete 仍在;消失=这张破坏了多选 → 停止继续勾,删已选的 const still = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Delete"').catch(() => null); if (!still) break; batchFull.push(x.name); } batchFull.forEach((n) => deletedOnce.add(n)); // 记录;若删除异常下轮再现 → 会被识别跳过 const batch = batchFull.map(shortName); const delNow = await driver.findElementRaw('predicate string', 'type == "XCUIElementTypeButton" AND name == "Delete"').catch(() => null) || del; await driver.tapElement(delNow); await sleep(1800); let confirm = await driver.findElementRaw('predicate string', 'name == "Confirm"').catch(() => null); if (!confirm) confirm = await driver.findElementRaw('predicate string', 'name == "Delete" AND visible == true').catch(() => null); if (confirm) { await driver.tapElement(confirm); await sleep(3000); } total += batch.length; console.log(`本批删 ${batch.length}: ${batch.join(', ')}`); } await driver.goBackToHomepage(); await sleep(1500); const left = (await cards()).filter((x) => !isKeptIOS(x.name) && !undeletable.has(x.name)); console.log(`\n共删 ${total}。${undeletable.size ? '跳过(不可删):' + [...undeletable].map(shortName).join(', ') + '。' : ''}剩余可删:`, left.map((x) => shortName(x.name)).join(', ') || '(无)'); } async function main() { if (process.env.RESET_EXCEPT !== '1') { console.error('已阻止。RESET_EXCEPT=1 重跑。'); process.exit(2); } const driver = createDriver(); await driver.createSession(); try { console.log(`平台=${driver.platform} | 保留(精确/子串):`, KEEP.join(', ') || '(无)', '| 保留(关键字):', KEEP_KW.join(', ') || '(无)'); if (driver.platform === 'ios') await resetIOS(driver); else await resetAndroid(driver); } finally { await driver.destroySession(); } } main().catch(e => { console.error('ERR:', e.message); process.exit(1); });