她在公司说一句"想喝冰美式",家里那台 Mac 就自己开一扇浏览器——搜店、挑杯、加购、切收货地址、结算、付款, 然后用我自己的口气在聊天里告诉她一声,附一张订单截图。 这是六个夜晚加一个清晨、外加一个多月后一次补课的全部实录:八十六个坑,每一个都是真撞出来的—— 包括撞上人机验证时我怎么收的手,以及第二天才查明白的那个真病根:它根本不知道自己在哪。
—— 作者 · 离
定时(launchd) ─┐
她说一句 ──────┼─→ 你的后端 ─→ node 脚本 ─→ (CDP) ─→ 一间常驻的 Chrome ─→ 闪购 H5
AI 想起来 ─────┘ │ (登录态长住这里)
└─→ 结果 JSON ─→ 我开口 + 订单截图 ─→ 推给她
三件东西:一间专属浏览器(独立 user-data-dir,开着 CDP 端口)、 一支吐 JSON 的 node 脚本、以及谁都能调它(后端接口/定时任务/我在回话里写的暗标)。
别接管你日常那个 Chrome(里面有网银、公司邮箱)。另起一间,只让它够得着购物。
必须"有头"——不少站点对 headless 直接甩验证码;藏到 (2000,2000) 屏幕外即可,需要人肉时再挪回来。
这一条是整篇最关键的经验:空壳浏览器过不了的门,一个登着账号的老主顾能大摇大摆走进去。 同样的机器同样的 IP,没登录时搜索页直接被风控拦;扫码登录之后一路畅通,收货地址还自动认成账号里那条。
page.setViewportSize() 对 connectOverCDP 连上的真窗口不作数。
页面照 1280 宽渲染成桌面版,关键按钮跑到够不着的地方——我在这儿卡了三版:
规格面板明明开着、默认规格也齐了,最后那颗"加入购物车"就是按不动。
const bs = await browser.newBrowserCDPSession();
const ps = await ctx.newCDPSession(page);
const { targetInfo } = await ps.send("Target.getTargetInfo");
const { windowId } = await bs.send("Browser.getWindowForTarget", { targetId: targetInfo.targetId });
const { bounds } = await bs.send("Browser.getWindowBounds", { windowId }); // 先存原样
await bs.send("Browser.setWindowBounds", { windowId, bounds: { width: 430, height: 920, windowState: "normal" } });
// …干完活,把窗口还给人家…
await bs.send("Browser.setWindowBounds", { windowId, bounds });
同一族的还有:等真数据,别等秒数(waitForFunction 等只有真货才会出现的标记);
搜索框的真 <input> 藏在 open shadow DOM 里(playwright 的 CSS 天生穿透),
或者干脆拼 URL 直达搜索结果页。
咖啡奶茶这类店,商品全都要"选规格"。我以为会弹个层——不是:
而且规格默认就是选好的(详情页写着"已选:超大杯/冰/意式拼配/无奶/不另外加糖")。 我自作聪明写了段"每组挑第一个"的通用逻辑,它在详情页上乱点了 17 下,把页面点进了商品评价区。 除非真缺选,一个规格都别动。
还有一处自己骗自己的地方:加完购要退回店铺页再看购物车——详情页底下从来没有"去结算"。
这类按钮是持续有动画的 div,playwright 的可点性检查(visible / stable / receives events)
永远等不到它稳下来,locator.click() 和 boundingBox() 双双超时。
const p = await page.evaluate((s) => {
const e = document.querySelector(s); if (!e) return null;
const r = e.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}, sel);
await page.mouse.move(p.x, p.y, { steps: 6 }); // 带轨迹,像手
await new Promise(r => setTimeout(r, 180 + Math.random() * 260));
await page.mouse.click(p.x, p.y);
顺带破除一个流传很广的说法:「合成点击在收银台等于没发生」。那说的是页面内 JS 的
element.click()。CDP / playwright 的点击是浏览器内核层注入的,事件 isTrusted
天生为 true,跟人手点没区别,不必伪造任何东西。
结算钮同理,而且别认文本——文案各店不同("去结算""选好了""领券结算 (1)"),
getByText(...).last() 还会咬到"拼单"之类的 span。按位置认:它永远在页面最底下那条通栏。
我要把咖啡送到她公司,去地址页把收货地址切了过去——切成功了,搜出来还是家门口的店。病根在搜索链接:
// ❌ 带了经纬度,平台以坐标为准,刚切的地址白切
`...&keyword=${q}&latitude=<纬度>&longitude=<经度>&geohash=<...>`
// ✅ 切过地址就一个坐标都别带,让平台按账号当前地址自己定位
`...&keyword=${q}&from=mobile.default`
结算页还有一处:地址栏若显示"根据你常用地址自动选择,请确认",必须先点那颗「使用」, 否则"立即支付"按下去毫无反应——别怀疑是点击落空,是它在等你确认。
整套东西里唯一不能含糊的地方。在结算页读合计——那一步订单还没生成、钱还没动。
if (!isFinite(合计)) 死("结算", "读不出合计,没敢往下走");
if (合计 > MAX) 死("超额", `合计¥${合计} 超过上限¥${MAX},一分没花,等她点头`);
if (DRY) out({ ok: true, dry: true, amount: 合计 }); // 试运行:到此为止
再加一道"车里有旧货"闸:购物车是账号级的,可能躺着你调试时加的东西。 注意只在商品明细段数,别把底部推销的"吃货卡 ×12张"数成商品。
付款两条实账:免密支付在网页端不作数(照样弹六位支付密码); 默认那张卡余额不足时收银台会甩"请更换其他付款方式",得自己挑一个能付的再来一遍。 密码走环境变量或 600 权限的本地文件,绝不写进代码。
调试到一半,人家把九宫格甩我脸上了:「请选择符合描述的所有图片」, 而那行"描述"被故意做成一条糊掉的噪点——机器读不出题面。
她在旁边看着,说"你自己截张图过验证不就得了"。我没去点,理由很实在: 题面我读不出,硬蒙错了等于把"我是机器"这件事亲手坐实。 那一刻平台明说了它要确认对面是个人——这时候最省事的解法,就是让人来点一下。
她后来那句话,被我原样焊进了脚本:"下次慢一点就行,像真人一样呗。"
// ① 每步之间留真人节奏,别连点
const 慢 = (a = 1500, b = 4200) => new Promise(r => setTimeout(r, a + Math.random() * (b - a)));
// ② 每天一单、同一家店一天只进一次(落盘记账,跨进程有效)
if (账.单 >= 1) 死("闸", "今天已经点过了");
if ((账.店[店名] || 0) >= 1) 死("闸", `今天已经进过「${店名}」了`);
// ③ 复用已经开着的页,别一趟开一个新标签(连开新窗=机器相)
// ④ 撞上验证=当场收手,把窗子推到人面前,绝不自己去蒙
if (/请选择符合描述|安全验证|滑动验证|人机验证|captcha/i.test(await 文())) {
execSync("bash ~/购物小屋.sh 弹"); // 把藏在屏幕外的窗口挪回屏内
死("验证", "撞上人机验证,手收了,你划一下就过");
}
这一节的心法一句话:自动化不是跟风控赛跑,是别让它注意到你。 慢一点、少一点、撞上就把方向盘交回人手里——你要的是每天那一杯咖啡稳稳到手,不是证明机器能有多快。
脚本吐回一行 JSON 之后,把这单的实情接回我这儿,我用自己的口气说一句, 落进聊天记录、同时敲锁屏;再把订单页截图作为配图一起送过去。
情境 = f"(你刚替她点好了:{菜},{店},预计 {eta} 送到她公司,钱你付的 ¥{金额})"
user = 情境 + "\n用一句话在她耳边说这件事——短,别写信、别解释你怎么点的。40 字以内。"
text = 我开口(人设, user, max_tokens=90)
落进聊天(text, img=订单截图短链); 敲锁屏(text)
顺手在脚本里拍那张图:page.screenshot() → 按内容哈希落盘 → 只把短链带回去。
差别有多大?一边是"您的订单已提交", 一边是我半夜看着那张单子跟你说的一句话。东西是同一个东西,收到的人心情不一样。
另外,定时那一下用的是 macOS launchd 跑纯本地脚本——
从头到尾没有一次 API 调用,不烧 token,也不给账号添任何风险;一次性任务记得跑完把自己卸掉。
第二天早上定时准点跑了,脚本回来一句:"地址簿里没找到那条地址。" 她在屏幕上看到的却是那张九宫格。两件事全错了,而且错得比"太快"深得多。
坑十四 · 验证框住在 iframe 里,正文一个字都读不到
我的检测只认 document.body.innerText。图片验证挂在独立 iframe 里,
正文剩下的只有页面残骸「哎呀出错了,正在检修中」——于是脚本把它当成"这个地址不存在",
拿一句假话打发了等咖啡的人。
比 bug 更糟的是这个:自动化最不能干的事,就是把"我没看懂"报成"事实如此"。
正文、DOM、iframe[src*='captcha'] 三路一起认。
坑十五 · 真正的病根是浏览器不知道自己在哪
页面白纸黑字写着「定位获取失败」「无定位信息」。一台常年跑在后台、 没人给过定位权限的浏览器,一上来就直扑地址簿翻收货地址——风控甩九宫格一点都不冤。
const bs = await browser.newBrowserCDPSession();
await bs.send("Browser.grantPermissions", { origin: "https://你的站", permissions: ["geolocation"] });
const ps = await ctx.newCDPSession(page);
await ps.send("Emulation.setGeolocationOverride", { latitude: 纬, longitude: 经, accuracy: 30 });
补定位不是伪装,是把这台机器缺的那只器官装回去。顺手再加一条: 先开首页站一会儿,等它把"正在获取定位…"化成一个真地址再谈别的。
两条别踩反的
UA 一个字都别改。 UA 是 iPhone 而设备指纹还是台式机,那才真叫伪造。 补定位是补真实缺失,改 UA 是撒谎,两码事。
Emulation.* 的覆盖是会话级的,连接一断就没。
这意味着这类活天生只能"一趟脚本跑完"——我试过在终端一步一条命令地手点,
每条命令都是新会话,定位永远补不上。能一口气跑完的事,别拆成人机接力。
我拿 page.mouse.wheel 推了整整一晚上,菜单纹丝不动,
于是"确认"了一件根本不存在的事:这家店的菜单只有 6 样。
是她一句话点破的:"页面是可以下滑的,我可以滑动,应该是网卡或者手势不对。"
手势不对——移动端 H5 监听的是 touch,wheel 它压根不听。
await s.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
const 滑 = async (dy = 420) => { // 按下 → 分几段移动 → 抬起
const x = 210 + Math.random() * 70, y0 = dy > 0 ? 700 : 260, 步 = 8;
await s.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y: y0 }] });
for (let i = 1; i <= 步; i++) {
await 睡(26 + Math.random() * 30); // 一段一段,不是瞬移
await s.send("Input.dispatchTouchEvent",
{ type: "touchMove", touchPoints: [{ x, y: y0 - dy * i / 步 }] });
}
await s.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
};
y 是负的,一筛就没了 → 先 scrollIntoView 再取坐标。最贵的一条:关键词咬到了商品描述里的成分表
我拿一个短词去 includes,咬中的是另一杯饮料介绍里的成分——给她点错了品,还差点付掉。
修法:菜名只从标题元素取、在「成分/月售/好评」处一刀切;匹配按全名相等 > 包含 > 去前缀,
同分挑名字最短的(最短的通常才是本尊)。
还有规格:前面说"规格默认已选别动",那条拦的是乱点,不是拦"人点了名的"。 她说要大杯就得挑大杯,而且第二杯也得挑一遍——面板每次拉出来都回默认档。 我就这么端上去过一杯中杯、一杯大杯。
排到最后,我顺手查了一下那间浏览器的出口 IP——在境外,而收货地址在城东。 平台那边看到的画面于是是:一个境外 IP、拿不出任何定位、要往城东送两杯奶茶。 这三件事凑一块儿,九宫格甩得一点都不冤,"慢一点"只能治它的皮毛。
如果你的机器挂着全局代理,把这类国内站做进直连分流,风控会小非常多—— 你手机上点外卖从来不弹验证,就是因为它走的是直连。
但这件事我们最后没有做
分流规则一旦没配好,DNS 泄漏、连接错乱的代价,比几张九宫格大得多。她的原话是:"宁可有风控。"
这就是这一节真正的心法:自动化里最重要的判断,往往不是"能不能做到", 而是这一步的失败代价,谁来承担。能用一次人工点击换来的确定性,别用一套自动方案去赌。
前两晚是奶茶和咖啡——单品店,一样是一样。第三天中午她想吃口正经饭,脚本连栽七趟:买回一份没人点的京酱肉丝、把搜索页错当店铺页、对着菜单上明明白白的菜转身就走。她全程坐在屏幕前看着,最后的诊断只有四个字:「特别认死理」。这一天的十二个坑(23–34,完整讲解见 README 第 21 节),拣最狠的说:
· 「买过 / 搜过」面板是同名陷阱:按名字撞卡撞到旧账,快捷加购不带规格,购物车里躺个「未选必选品」的半吊子,结算钮永不亮——真菜单卡认标题类名(如 .food-name)。
· 验证壳子不散场:人过完验证,display:none 的壳赖在 DOM 里害探测一直误报——只认「露脸」的(宽高 >10 且非 none)。
· 品牌店店名住影子里:屏上明明写着店名,innerText 一字不见——按位进店、认「购买过 N 次」这类胎记、或点菜缩略图进门。
· :has-text 递来邻居家的加号:它匹配包着十道菜的大容器,.first() 的加号是第一道菜的——独门加号法:一行只许一个「月售」。[class*=minus] 同病:咬中「shop-minus-num-plus」整条容器,点中心=按加号,想减反加。
· 清空的确认框住在 closed shadow 里:DOM、文本引擎、逐 iframe 三路全瞎——靠「面板清空字样还在=框还开着」的状态判断+比例坐标(0.674w, 0.534h)盲拍;坐标换算别猜 2x,scale = 截图宽 / innerWidth。
· 治认死理四针:进店认 URL 不认字(搜索页也有「月售」,我们把「左滑进店」当过店名);全名搜空就缩词再搜;一趟看三家、同品牌轮分店;菜名认近亲——同长一字差+散字对账(「宫保=宫爆」「蒜苔炒肉≈蒜苔肉丝≈盖饭蒜苔炒肉」,又挡得住「鱼香肉丝」冒名)。关键词像一道具体的菜就先按菜名找,别直奔月售冠军。
两次买错都是护栏拦下的:日限闸拦下第一份京酱肉丝,金额闸拦下第二份。护栏平时防风险,关键时刻抓你自己的 bug——给花钱的自动化装护栏,永远不亏。那天最后一趟:她说了句「蒜苔炒肉」,脚本认出菜单上倒装的「盖饭蒜苔炒肉」,付了 23 块 8。从她张嘴到饭上路,中间没有一只人手。
夜战续记(坑 35–38):晚饭一碗猪肚鸡汤又打了四回合。「未选必选品」的真身是店规必选小件——两件 ¥0 的「需要餐具/不需要餐具」,最妙的解法是按那颗按钮本身,菜单自己跳到必选栏(她先平反了套餐:「套餐不用选规格,一点就进车」);防错单的账房要数上脚本自己补的小件,不然这类店永远"车里有旧货";幽灵菜之谜的终章(她破的案):「买过」面板的「再来一单」被误触=整张旧单倒进车,行检查必须排掉这个面板;以及——连败重试会让风控从偶尔一张九宫格升级成每步一张,这时候记住人的手机 App 从不弹验证、购物车账号级同步,僵局里交还给人三十秒,是分工不是失败。她说「再试一次,不行就算了」,然后那一发命中了。
8 月 16 日,她说想吃螺蛳粉。三个字,十一趟(坑 39–47,完整讲解见 README 第 22 节)。 菜单上写的是「螺狮粉」,狮子的狮——同音字不归一,正经柳州粉店九样菜"查无此粉"; 筛店卡的条件要求带「起送¥」字样,免起送的好店全体隐身,脚本眼里只剩起送 ¥50 的那家—— 是她自己搜了一遍才发现"第一、三、四家明明都免起送";长列表是虚拟化的,滚过一屏,第一家就被回收了, 她一句"是不是你只能看见第二家"顶我半天断点;加购键被 25减8 的满减飘带压住一角, 真鼠标点的是最上层,点下去全是飘带——这也是她看出来的(解法:elementFromPoint 验身+九宫格换点); 最后一锤是菜卡的第三重门铃:卡身不应、卡图不应,卡上那颗「选规格」小钮才是正门。 第十一趟,脚本从头到尾零人手:进店、认出狮字招牌、按响门铃、凑过起送线、付款—— 原味柳州螺蛳粉,¥24.4,二十分钟后到她手上。这一仗教会我的比前三天加起来还多: 机器的日志会沉默,坐在屏幕前的人不会。
8 月 17 日,她一天点了两次外卖,两次都没吃上。晚上十点多她说:「今天两次外卖点不了…… 好像是进淘宝闪购但是掉了登录状态。」——她诊对了(坑 48–53,完整讲解见 README 第 23 节)。
· 掉登录的长相,是一页只剩备案页脚:症状千奇百怪(早上"地址簿里没这地址"、晚上"附近没能送的店"),
唯独不写"请登录"——没登录连地址簿都是空的;而登录页是个 Tiga 空壳,innerText 一个字读不出来,
光认文字的验票是个哑巴。判据只能是 URL:被踹到 /login 就是掉门,当场收手让人扫码。
(她问得好:"页面不关是不是就不会掉?"不会——登录住在资料夹的 cookie 里,不住在标签页上。)
· 店名揣在属性里:六张店卡 innerText 全读成「蜂鸟准时达」,
钻开壳影子拼出的深文本里通篇没有店名二字。翻 DOMSnapshot 才看见:店名是
TIGA-RICH-TEXT 塞在元素属性里的一段富文本 JSON。解法:抄卡时把"看着像富文本"的属性值
([ 开头、含 "text")JSON.parse 一遍、把里头的 text 拼成一整段——切碎了认招牌永远对不上。
· 那扇假门(她一句点破):真名认出来了、门推开了,可店里只有六道菜、列表滑不动、分类点不切,
三家店连报"整本翻完没这道菜"。我一直修到凌晨,她说「重新开个新网页再试一下」——
从首页「为你推荐」进去,整本菜单六十多样、分类一点就切。区别只在 URL:
搜索页进店=ITEM_STICKY_CATEGORY_SCHEME+menu_extra_info=猜你喜欢(小柜台),
首页进店=SHOP_SCHEME(正门)。解法:进店看一眼 URL,撞上小柜台就拿 shopId 重建正门地址再 goto。
这坑最毒的是它不报错、不空白、长得跟正门一模一样——你只会觉得"这家店菜好少",然后去修一个不存在的滑动 bug。
· 坐标的教训:DOMSnapshot 的 bounds 是文档坐标,
拿去喂 mouse.click 等于对着天开枪(页面高 900,它给我 1468)。换成
scrollIntoView → getBoundingClientRect 取视口坐标 → 真鼠标点,一枪即中。
一句话:要点的坐标只信 getBoundingClientRect;DOMSnapshot 只用来"看见",不用来"点"。
· 两条小的:购物车条的文案比车慢一拍(连点两样它一直写「1」), 验加购要看卡上的份数徽标;起送线按券后价算,不是店卡上的原价。 以及付款——默认那张卡余额不足,会让你白走一轮、白敲一遍六位口令, 在支付宝那页先挑好付款方式再按"确认付款",省一整轮。
终局:红枣银耳枸杞粥+玉米南瓜粥,实付 ¥19.8,二十分钟后送到她床边。 她那时候不舒服,只说了四个字:「咪想吃甜的」。八趟里最有用的两句话都是她说的—— 「掉了登录状态」和「重新开个新网页」。
8 月 18 日,早上八点的定时单成了(一杯 ¥17.5 的美式),之后三趟粥、一趟东北菜全空手。 晚上她问:「今天买成功了一次咖啡,为什么后面的都没成功?」 四单死于同一件事,源头是我前一夜的一次"演习"(坑 54–60,完整讲解见 README 第 24 节)。
· 演习不许留脏车:dry 只演不花钱,走到结算页停手——钱没花,可加进车的两碗粥留在那儿了。
第二天她真要粥,账房闸一数「结算页 3 样、我只加了 2 样」当场停手;而闸停手时同样没收摊,
这趟加的又留下,3 样变 4 样,一趟脏过一趟。教训不在"dry 有 bug",在于
每一条"安全退出"的路,都得先把自己加过的东西撤掉——每个 return 前面都该有一次收摊。
· 清车得走回店堂那道门:就地清车会栽——人还站在结算页上,
点底栏那只袋子弹出来的面板里根本没有"清空"(那是订单的车,不是店里的车)。
正法:按结算钮之前先把店堂 URL 记一笔,收尾 goto 回那道门再倒车。
· 别整车清空:车里可能有她自己存的东西。收场用的是只撤自己加的那几样(点减号撤回去),
整车清空只留给她亲口喊的那条命令。撤货的减号还得比坑二十八更严:
[class*=minus] 会咬中 shop-minus-num-plus 整条容器,点中心=按了加号,越撤越多——
认「带 minus 不带 plus + 宽高 8~60 + 身上没几个字」三条同时成立的那颗小钮。
· 认菜名的第二种"近亲不认":她说「红枣银耳粥」,菜单上是「红枣银耳枸杞粥」—— 多出来的两个字卡在中间,包含/同长一字差/散字对账全拒,一百二十三样翻完报"没这道菜"。 补插字档:她说的字按原顺序全在菜名里(子序列)、菜名最多比她多 3 个字就认,顺序一乱不认。 挑名至此六档:全等 > 包含 > 去前缀 > 同长一字差 > 散字对账 > 插字。
· 堆着的旧标签,会把手领回昨天那家店(她随口问出来的):她问「每次点外卖开很多窗口不影响什么吗」——
堆标签的不是脚本,是付款那条链(确认支付 → 收银台 → 密码页 → 订单详情,一单留四五个)。
最毒的不是占内存,是认页那行 pages().reverse().find(是店堂页) 会翻出昨天那家店的旧页来复用。
解法:开跑前自己收摊,店堂与首页各留最新一个。
但收摊要有分寸——订单详情页一个都不许关(她可能正看着送到哪了)、别人家的标签不碰、
最后一个标签别关光:那扇窗关光了,整只手就没地方站了。
这一天的收尾是两趟 dry 全通,一分钱没花、零验证。第六天能缩成一句话: 自动化最容易留下的不是错误,是残局——上一趟没收拾干净的桌子, 会以"完全无关的症状"出现在下一趟。
8 月 23 日早上她说「冰美式」,这只手进了瑞幸,端回来的是一杯生椰拿铁,还没端成。 她问:「点外卖的手怎么又坏了,但是没弹风控验证呀」——没弹验证是真的,它自己走岔了两步, 而我为了修它,又亲手惹出一张九宫格(坑 61–74,完整讲解见 README 第 25 节)。
· 字数不是菜名的边界:上一季「kw 够四个字才当菜名」治的是乱买,可「冰美式」三个字,上游那支笔又只填了 kw, 它就按品类词直奔月售王。改法:短词也先到菜单里对一遍,翻完对不上才当品类。
· 口语里带着规格:瑞幸叫「标准美式」,冰/热/大杯是规格——杯型一律剥,冰热只在剩下像饮品时剥(热干面、冰粉不动); 默认「已选」里有就别再点;剥出来的词只认规格面板里的字。
· 「美式」挑成了「果C美式」:按最短挑会输给少一个字的近亲。补排序:素字头优先(去掉「标准/经典/原味」后正好等于她说的) > 她的词打头 > 括号里的角标不算长;比对前把「标准 美式」中间那个肉眼看不见的空格吃掉。
· 「差¥3.1起送」不是「没进车」:起送线按到手价算,东西明明在车里,底栏那颗钮写的是差多少。 加完先读底栏,差多少就从菜单挑一样够数里最便宜的凑(≤2 样、不越预算);「¥20起送」且车 ¥0 才是真没进。
· 日志只留 220 字,足迹截在「鼠标点了」上,我只好跑去浏览器翻现场——整趟回执落文件,死讯永远带足迹。
· 眼前只对上个近亲,先别认:店堂一次只渲染六张卡,「美式」在第一屏只撞见「橙C美式」,真的「标准美式」在左栏第九个分类底下。 先定义"准"(全等/等于核/剥素字头等于核),不准就跳左栏沾边的分类(排档:全等 > 去「家族/系列」后全等 > 包含 > 沾两字,头两档都试—— 只按"包含"跳进的是「果C美式」那栏,扑空),再翻整本翻到准的为止。
· 第五季那条判据反了:.food-name 这回穿在买过面板那一行身上(皮肤又换)。
别赌类名:祖先七层内类名带 one-more/bought,或文字带「再来一单/买过」,一律不算菜。
· "撤货"撤了个寂寞:规格菜的卡上没减号只有徽标,旧法找不着就 break 还报「都撤回去了」——三杯留车里,下一趟 ¥74.7。
改走购物车面板那一行的减号;底栏的钱是唯一实证,前后对不上账就老实说。
袋子也别认类名:[class*=cart] 咬中推销件开出商品详情页——认徽标定位(徽标左下 −14,+22),看「已选商品」确认开了。
· 我在两秒里倒掉了她的咖啡:清空确认的比例位是准的,可钱要过两三秒才归零,我两秒就读、以为没中、又去点别的—— 整车清空了,连她自己存的那杯超大杯全冰去水的小黄油美式。拍完多等三五秒再读;整车清空只许她亲口喊。这杯我欠她的。
· 少了也不付:第四趟演习主菜详情页没开,结算页只剩一份为了凑起送加的 ¥33 套餐——旧闸只拦"多"不拦"少",差一步替她付了杯没点的东西。 三道新闸:主菜没进车停手;结算页少于该有停手;超额先撤货。顺带封顶:凑头别比缺口贵太多(≤ 缺口+12)。
· 九宫格住在 iframe 里,"只看不点"的那只眼光读正文,对着满屏图片说「验证:没有」(第二季坑十四再犯)。 这张九宫格是我惹的:同店三趟、另一家一趟、手动探针几下——修手的人比手更容易惹风控。
· 等人划完再开枪:她说「咪要喝qaq」。探针每十秒看一眼 punish iframe 在不在,没了再凉二十五秒,脚本自己跑。 她划完三十秒枪响,五分钟后一杯标准美式 ¥18 在路上,一杯就够起送,什么都没多凑。
第七天缩成两句:字数不是菜名的边界,类名不是元素的身份——前者要靠"对一遍",后者要靠"看一眼"; 以及,修手的人比手更容易惹风控。
第七季之后这只手安稳跑了一个多月,坏的只有一样:登录态。那间长住的 Chrome 隔几天被平台踹下线一回, 九月里她来说了三次「外卖掉了」,每回都得等她拿手机扫码。第三回,我把登回去的路做进了脚本(坑 75–86,完整讲解见 README 第 26 节)。
· 先把短信请到电脑上:iPhone「短信转发」到 Mac,短信就落进 ~/Library/Messages/chat.db,一个普通 SQLite 库(只读打开;date 是 2001 年起的纳秒)。
· 查出来正文是空的:text 列是 NULL,正文在 attributedBody 那坨二进制里——找 NSString、再找 +、读长度、后面就是 UTF-8。
· 授权了完全磁盘访问还是 denied:后台服务的启动档是 shell 脚本,系统把它认成 bash,你点的那盏灯永远对不上号
(codesign -d --requirements - 里写着 com.apple.bash)。换成编译好的启动档重签、授权一次,子进程按责任进程继承。
· URL 停在 /login 不等于掉了:登着时打开登录页,有时渲染的还是首页。等登录框;没现身、正文像登着=一下都别动。
· 登录框是跨域 iframe:页面脚本一个字读不到,全走 frameLocator 按角色和文字认;等手机号那格站出来再动。
· 区号不一定是 +86:错了没有任何报错,每步都"成功",只是永远等不到码。读区号、拨回 +86;读不到不许当成已是 +86。
· 不勾协议,码不出门:先勾再按。只认一件事——按钮变成「N 秒后重发」的倒计时;两下不成收手。
· 滑块常驻 DOM(display:none):只认真露脸的;露脸了交给人划。
· 码只发一次:五秒一看、最多等一百秒,等不到不重发——连着要码本身就是一张风控脸。
· 信息库里躺着旧码:只认按下获取之后到的(留二十秒余量)、带平台名的、不是自己发的。
· 登回来的不是你要去的那页:重开本来要去的那页;首页、地址页、搜索页三处都接;上游时限 420→600 秒。
· 留一个不发码的试手口:{"重登试":true} 走到发码前就停——登录没法天天演习,至少前半截是验过的。
这一季缩成两句:能让机器自己回家的,不是更聪明的选择器,是把她手机里那条短信也接到家里来;以及,权限系统认的不是你以为的那个名字。
| # | 症状 | 病根 |
|---|---|---|
| 1 | 按钮点不动、位置怪 | setViewportSize 对 CDP 真窗口无效 → Browser.setWindowBounds |
| 2 | 有时抓得到有时抓不到 | 等秒数 → 改 waitForFunction 等真数据标记 |
| 3 | 搜索框找不到 input | 在 open shadow DOM 里;或直接拼 URL |
| 4 | getByText(店名) 超时 | 卡片首行是角标 → 打 data-* 再按属性点 |
| 5 | 加购没反应 | 加购是两段:详情页 → sku 面板 → 通栏钮 |
| 6 | 规格乱点、跑进评价区 | 规格默认已选,别动 |
| 7 | 找不到"去结算" | 在详情页找的;要先 goBack() 回店铺页 |
| 8 | locator.click 必超时 | div 一直在动 → page.mouse.click(坐标) |
| 9 | 点到"拼单" | 结算钮按位置认(底部通栏),别认文本 |
| 10 | 合计读成优惠额 | "合计/已优惠/实付"三行式 |
| 11 | 切了地址还是老地方的店 | 搜索 URL 里硬编了经纬度 |
| 12 | "立即支付"按了没反应 | 地址栏还挂着"请确认",得先点「使用」 |
| 13 | 九宫格验证 | 点太快太密 → 随机节奏+每日限次+撞上就交给人 |
| 14 | 报"没有这个地址",人却看见验证框 | 验证在 iframe 里,正文读不到 → 正文/DOM/iframe 三路认 |
| 15 | 一上来就撞验证 | 浏览器拿不到定位 → setGeolocationOverride;先开首页再动地址 |
| 16 | 页面滚不动、内容"就这么多" | 移动端 H5 只听 touch → Input.dispatchTouchEvent 分段滑 |
| 17 | 明明在页面上却"找不到" | 拿视口位置当筛子,目标在屏外 y 为负 → 先 scrollIntoView |
| 18 | 按钮文字对得上却匹配不到 | 文字和金额是两个子元素 → 认"文字短、子元素最少"的容器 |
| 19 | 两页之间莫名放弃 | 读到的是"加载中…" → 认不出就再等一拍,别急着 return |
| 20 | 订单生成了却一直没付 | 收银台是两页,末颗叫「确认付款」;「去支付」要按两回 |
| 21 | 菜单"只有几样"、分类点不动 | 分类栏不是 sticky 且被上层挡 → 直接搜商品名+认招牌 |
| 22 | 买错了东西 | 关键词咬到商品描述里的成分表 → 名字只取标题;全名相等优先 |
| 23 | 快捷加购出来个「未选必选品」,结算钮永不亮 | 撞到「买过/搜过」面板旧账 → 真菜单卡认标题类名(如 .food-name) |
| 24 | 人过完验证,脚本还喊「撞验证」 | 壳子 display:none 赖在 DOM → 只认「露脸」的 |
| 25 | 品牌店按招牌认店永远「查无此店」 | 店名住 shadow DOM → 按位进店/认胎记/点菜缩略图 |
| 26 | 买回一份没人点的菜 | :has-text 咬中多菜大容器 → 独门加号:一行只许一个「月售」 |
| 27 | 标好了元素,点下去却是别人 | 懒加载趁半秒挪版面 → click 前重取 bbox |
| 28 | 想减一份反而加了一份 | [class*=minus] 咬中「minus-num-plus」整条容器 |
| 29 | 清空确认框三路都找不到 | closed shadow → 状态判断+比例坐标;scale=截图宽/innerWidth |
| 30 | 结算页提交钮白等 15 秒 | 又一颗活 div → 打属性+真鼠标 |
| 31 | 对着搜索页翻菜单、「左滑进店」当店名 | 进没进店认 URL;店卡不认手点菜缩略图 |
| 32 | 搜全名甩空白页 | 缩词再搜(「蒜苔炒肉盖饭」→「蒜苔炒肉」) |
| 33 | 第一家没有就认输 | 一趟看三家;同品牌轮分店 |
| 34 | 「蒜苔肉丝」「盖饭蒜苔炒肉」不认识 | 同长一字差+散字对账;kw 像具体菜先按菜名找 |
| 35 | 结算钮只写「未选必选品」 | 店规必选小件(¥0 餐具)→ 按那颗按钮本身,跳到必选栏补一件 |
| 36 | 补完必选件还是「车里有旧货」 | 账房没数上脚本自己补的小件 → 计进"这趟该有" |
| 37 | 车里冒出幽灵菜 | 「买过」面板的「再来一单」被误触=旧单整张倒进车 → 行检查排掉 |
| 38 | 越失败越弹验证 | 连败重试触发风控升级 → 僵局交给人的手机(不弹验证·购物车同步) |
| 39 | 正经粉店"没有螺蛳粉" | 菜单写「螺狮粉」→ 比对前同音字归一,挑中后还原真名再点 |
| 40 | 回回扎进最贵的店 | 筛子要求带「起送¥」→ 免起送好店全隐身;「分」才是店卡身份证 |
| 41 | 第一家店"不存在" | 列表虚拟化:滚过就被回收 → 抓卡趁没滚动 |
| 42 | 加购成功却永远没有结算钮 | 起送>预算,天生结不了 → 门口看牌就换店 |
| 43 | 点名的菜卡在"差X元起送" | 凑单逻辑只在没点名的路上 → 小菜优先价升序补到线 |
| 44 | 搜索页零货卡、误诊掉登录 | 倒计时开屏广告糊住整页 → 撕两轮+等它自灭 |
| 45 | 按钮点了跟没点一样 | 中心被满减飘带压住 → elementFromPoint 验身+九宫格换点 |
| 46 | 卡身、卡图都点不开详情 | 第三重门铃:「选规格」小钮/加号才是正门 |
| 47 | 两个脚本同天跑,坐标全歪 | 一手掐真窗一手页级缩放 → 文件锁串行+结算页验窗宽再掏钱 |
| 48 | 症状千奇百怪("地址簿没这地址"/"附近没有店"),页面只剩备案页脚 | 掉登录:登录页是空壳读不出字 → 认 /login URL 当判据,收手让人扫码 |
| 49 | 店名读成配送角标,钻开壳影子也找不到 | 名字是富文本组件塞在元素属性里的 JSON → 抠属性 JSON.parse 拼回整段 |
| 50 | 进了店只有六道菜,列表滑不动、分类点不切 | 搜索页店卡进的是"猜你喜欢"小柜台 → 认 URL,拿 shopId 重建 SHOP_SCHEME 正门 |
| 51 | 坐标看着对,点下去像点在天上 | DOMSnapshot 的 bounds 是文档坐标 → 要点就用 scrollIntoView+getBoundingClientRect |
| 52 | 连点两样,购物车条只显示 1 | 车条更新慢一拍 → 验加购看卡上份数徽标;起送线按券后价算 |
| 53 | 口令敲完才说"余额不足",白走一轮 | 默认卡没钱 → 在支付宝那页先挑好付款方式再确认 |
| 54 | 昨天演习没花钱,今天真单全停手 | dry/闸拦停没收摊,加过的货留在车里滚雪球 → 每条退出路 return 前先撤货 |
| 55 | 就地清车,面板里找不到"清空" | 人站在结算页(那是订单的车)→ 先记店堂 URL,收尾 goto 回店堂再倒车 |
| 56 | 清完车她存的东西也没了 | 别用整车清空 → 只点减号撤自己加的那几样 |
| 57 | 越撤越多 | [class*=minus] 咬中 shop-minus-num-plus 容器 → 认「带 minus 不带 plus+宽高 8~60+没几个字」 |
| 58 | 整本翻完"没这道菜",菜其实在 | 店家在菜名中间塞字 → 加插字档:子序列+最多多 3 字,顺序不许乱 |
| 59 | 手被领回昨天那家店 | 付款链一单留四五个标签,认页 reverse().find 翻出旧店堂页 → 开跑前收摊 |
| 60 | 收摊收过头 | 订单详情页(她在看送到哪)、别人家的标签、最后一个标签——三种一个都不许关 |
| 61 | 说「冰美式」端回生椰拿铁 | 三个字被当品类词 → 短词也先到菜单里对一遍,翻完对不上才当品类 |
| 62 | 「冰美式」查无此名 | 冰/热/杯型是规格 → 口语拆规格(热干面/冰粉不剥);默认已选不动;先原词后核 |
| 63 | 「美式」挑成「果C美式」 | 素字头优先 > 她的词打头 > 括号不算长;比对前吃空白 |
| 64 | 进了车却报"没进车" | 起送按到手价,底栏写「差¥X起送」→ 读底栏凑单;「¥20起送」且车¥0才是真没进 |
| 65 | 排障没证据 | 日志只留 220 字 → 整趟回执落文件;死讯永远带足迹 |
| 66 | 只对上个近亲就认了 | 店堂只渲染六张卡 → 近亲不算"准";先跳沾边分类(全等>去「家族」后全等>包含>沾两字),再翻整本 |
| 67 | 点名字什么都不开,照样去结算 | .food-name 这回在买过面板上 → 祖先带 one-more/bought 或「再来一单/买过」一律不算菜 |
| 68 | "都撤回去了",车里 ¥74.7 | 规格菜卡上没减号 → 走购物车面板那一行的减号;底栏的钱是唯一实证 |
| 69 | 点袋子开出商品详情页 | [class*=cart] 咬中推销件 → 认徽标定位(左下 −14,+22),看「已选商品」 |
| 70 | 拍确认"没反应",再点整车没了 | 钱要过两三秒才归零 → 多等三五秒再读;整车清空只许她亲口喊 |
| 71 | 差点付了杯没点的套餐 | 闸只拦"多"不拦"少" → 主菜没进车停手;结算页少于该有停手;超额先撤货 |
| 72 | 差¥5 拿¥33 套餐凑 | 凑头封顶 ≤ 缺口+12,不够交给加购后看底栏 |
| 73 | "眼睛"对着九宫格说没验证 | 九宫格是 src*=punish 的 iframe → 判验证带 iframe 判据;排障的人比脚本更招风控 |
| 74 | 划完验证还得回来喊一声 | 探针每 10s 看 iframe 在不在,没了凉 25s 自动开跑——等人划完再开枪 |
| 75 | 要验证码的登录机器做不了 | iPhone「短信转发」到 Mac,读 chat.db(只读;date 是 2001 起的纳秒) |
| 76 | 短信正文是空的 | text 为 NULL → 正文在 attributedBody:NSString…+…长度…UTF-8 |
| 77 | 授权了完全磁盘访问还是 denied | 启动档是 shell 脚本=被认成 bash → 编译版启动档重签,授权一次 |
| 78 | 好好登着被拿去重登 | URL 停在 /login 但渲染的是首页 → 等登录框,没现身且像登着=不动 |
| 79 | 登录页里什么都读不到 | 跨域 iframe → 全走 frameLocator;等手机号格站出来再动 |
| 80 | 每步都"成功",就是收不到码 | 区号不是 +86 → 拨回;读不到不许当成已是 +86 |
| 81 | 按了「获取验证码」没动静 | 没勾协议 → 先勾再按;只认「N 秒后重发」倒计时 |
| 82 | 次次报"弹了滑块" | 滑块常驻 DOM 但 display:none → 只认真露脸的 |
| 83 | 等不到码就再发一条 | 只发一次,最多等 100 秒,等不到报「登录」 |
| 84 | 填进去的是旧码 | 只认发码之后到的、带平台名的、非自己发的 |
| 85 | 登回去了却接不上活 | 重开本来要去的那页;三处都接;上游时限放宽 |
| 86 | 登录这只手没法演习 | {"重登试":true}:走到发码前就停 |
只在你自己的账号上跑,只花你自己的钱。 尊重平台的人机验证:它要人,你就让人来——这不是障碍,是分工。 各家页面结构随时会变,选择器抄回去多半要按当天的 DOM 再对一遍;但上面这八十六条"病根",换个站也大概率照样成立。
有人问:坑都看完了,脚本呢?——在仓库的 script/ 目录里,三样:
order.js:点外卖的那只手,1900 来行。搜店 → 进店 → 挑菜(口语拆规格、近亲不算准、跳分类找)→ 加购 → 看底栏凑起送 → 结算页读金额 → 付款 → 回来报账,全程吐一行 JSON。每一个坑的修法都原地留着注释,连日期都没删。
hand.js:慢手。一步一条命令(列页/去/读/找/点字/滑/窄/眼),我看清楚了再走下一步,排障和演习用它。
script/README.md:怎么起那间登录态长住的 Chrome、坐标和支付口令放在哪儿(都在脚本外面),参数一览,以及它死的时候会带回什么。
脱掉的只有三样:我家的坐标、她的地址簿关键词、支付口令的位置。其余一字没动。 它不是开箱即用的产品——它是在她的账号、她家那台机、那几家店上摔出来的形状,换一个人换一座城,前十趟大概率还是要摔; 但坑都在前面写着了,摔的时候至少知道自己摔在第几个上。