资讯动态

退出码是 0,日志没有 ERROR,稿子却没发出去:CDP 脚本里的三种静默失败

发布时间:2026/10/26 10:27:26 来源:尧图企业网站定制
退出码是 0日志没有 ERROR稿子却没发出去CDP 脚本里的三种静默失败那天晚上我要给一个新平台写自动化发布脚本。跑完之后脚本打印的每一步都像成功页面打开了、按钮找到了、图片下载了、坐标算出来了、拖动完成了。退出码 0没有一行 ERROR没有一次抛异常。然后我去平台列表里看——那篇文章根本不在。更糟的是这不是第一次。同一个晚上同类型的错误出现了三次每一次都安静得像成功。我把它们摊开放在一起看才发现它们其实是同一件事的三个变体脚本的每一步都在正常执行只是执行的结果和你以为的不是一回事。第一种返回值取错了一层于是一切都是空字符串我写了一个通用的页面探测脚本用来摸清目标站点上有哪些按钮、哪些输入框。核心就两个函数send()负责往 Chrome 发 CDP 指令ev()负责执行一段 JS 并把结果带回来。constsend(method,params)newPromise((resolve,reject){constidseq;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params:params||{}}));});ws.onmessage(event){constmsgJSON.parse(event.data);if(msg.idpending.has(msg.id)){constentrypending.get(msg.id);pending.delete(msg.id);msg.error?entry.reject(newError(JSON.stringify(msg.error))):entry.resolve(msg.result);// 注意这里resolve 的是 msg.result}};constevasync(expression){constrawaitsend(Runtime.evaluate,{expression,returnByValue:true});constinnerr.result;constboxedinner.result;return(boxedboxed.value!undefined)// 注意这里多取了一层?String(boxed.value):;};看出问题了吗send()往外抛的是msg.result也就是 CDP 响应里的result字段。而Runtime.evaluate的完整响应长这样{id:7,result:{result:{type:string,value:该站首页地址}}}msg.result已经是{ result: { type: string, value: ... } }。所以在ev()里应该「取 result 再取 value」两层我写成了「取 result、再取 result、再取 value」三层——多取了一层。多取一层的后果是什么中间那层result是undefined于是整个三元表达式走到: 返回一个空字符串。一个空字符串。不是异常不是null引发的 TypeError不是 reject。就是一个空字符串安静地流进了后面的每一行代码。于是我看到的输出是这样的URL TITLE READY BODYTEXT INPUTS每一行标签都在每一个值都是空的。我当时第一反应是「页面没加载出来」于是去加等待时间、去检查 CSP、去怀疑是不是附到了错误的标签页。折腾了两轮才想到去打印原始响应constrawawaitsend(Runtime.evaluate,{expression:location.href,returnByValue:true});console.error(RAW,JSON.stringify(raw).slice(0,400));一行就出水了RAW {result:{type:string,value:该站首页地址}}result.result确实存在——它在raw里面不在raw.result里面。这个 bug 的可怕之处不在于它难修改一个字符而在于它不产生任何信号。对比一下错误类型表现你多久能发现抛异常FATAL Cannot read property value of undefined立刻rejectError: timeout Runtime.evaluate立刻返回空一切正常值为空我用了两轮才反应过来同样是「取值取错了」如果你取的是undefined.xxxJavaScript 会当场给你一个 TypeError但你取的是「对象.字段.子字段」而「对象.字段」恰好存在、「对象.字段.子字段」恰好不存在你只会得到undefined然后被我那句防御性的: 兜底成了空字符串。防御性代码把错误信号吃掉了。后来我给所有新写的 CDP 脚本定了条规矩写完send()和ev()第一件事是打印一次原始响应确认层级。不是相信自己的记忆是看一眼。constevasync(expression){constrawaitsend(Runtime.evaluate,{expression,returnByValue:true});if(!r)return;if(r.exceptionDetails){constdetailr.exceptionDetails;returnERR:String(detail.text||);}constinnerr.result;return(innerinner.value!undefined)?String(inner.value):;};顺带说一句那个exceptionDetails分支也是这次加的。之前如果 JS 在页面里抛错中间那层result不存在同样会走进: ——页面里的异常和取值错误长得一模一样。现在它们至少能分开。第二种按钮找到了点的是它的兄弟第二个坑出现在「按文字找元素」这件事上。自动化脚本里最常见的操作是「找到写着『写点什么』的那个东西点它」。实现通常是遍历所有元素比对文字constclickTextasync(txt){constboxawaitev((function(){ var want ${JSON.stringify(txt)}; var els Array.from(document.querySelectorAll(div,span,a,button,li)); for (var i 0; i els.length; i) { var e els[i]; var s (e.textContent || ).trim(); if (s ! want) continue; // 精确相等 var r e.getBoundingClientRect(); if (r.width 6 || r.height 6) continue; return JSON.stringify({ x: Math.round(r.x r.width / 2), y: Math.round(r.y r.height / 2) }); } return {}; })());if(!box||box{}){console.log(NOT_FOUND_txt);returnnull;}constpJSON.parse(box);awaitclick(p.x,p.y);returnp;};看起来没问题。实际运行时clickText(写点什么)打印了NOT_FOUND_写点什么。但那个按钮明明就在页面上——我截图能看到前面的 DOM dump 也能看到{t:写点什么,tag:DIV,cls:write-btn Button_button_3onsJ,x:1148,y:35}问题出在s ! want。页面上那个元素的textContent不是写点什么而是 写点什么——前面带了一个字体图标字符。这类站点大量使用 iconfont一个i或者span里放一个私用区字符比如靠 web font 渲染成图标。视觉上它是个图标DOM 里它是个实打实的字符而且它属于父元素的textContent。所以你以为的实际的写点什么 写点什么我已阅读并同意协议 我已阅读并同意协议搜索而且这还只是第一层。更难的是同一个按钮在页面的不同状态下图标字符还可能不一样——勾选协议前后那个字符从变成。你按「未勾选时的文本」去匹配勾完就找不到了。我的第一版修复是放宽匹配if(s.indexOf(want)0)continue;// 包含匹配这能解决大部分情况但引入一个新问题包含匹配会命中祖先元素。一个div包着整个按钮区它的textContent也包含「写点什么」而且它在 DOM 里排得更靠前——你会点到一个巨大的容器上点了个寂寞。所以完整的写法要同时满足三件事包含匹配、剥掉不可见字符、优先选最内层元素。现在用的版本是这样constclean(s)(s||).replace(/[\uE000-\uF8FF\uF0000-\uFFFFD]/g,).replace(/\s/g, ).trim();// 在页面里for(vari0;iels.length;i){vareels[i];varraw(e.textContent||).trim();varsclean(raw);if(s!want)continue;if(e.childElementCount0)continue;// 只取最内层没有子元素才可能是文本节点本身varre.getBoundingClientRect();if(r.width6||r.height6)continue;if(r.y0||r.yinnerHeight||r.x0||r.xinnerWidth)continue;returnJSON.stringify({x:Math.round(r.xr.width/2),y:Math.round(r.yr.height/2)});}先剥私用区字符Unicode 的UE000–UF8FF和补充私用区再做精确匹配。childElementCount 0这一条保证拿到的是叶子节点——也就是真正承载文字的那个元素而不是包着它的容器。这条经验推广出去就是一句很土的话你看到的和 DOM 里的不是一回事尤其是那些你看不见的字符。空格、换行、零宽字符、私用区图标字符都会让失效而它们的共同点是——在渲染结果里完全看不出来。第三种算法跑完了答案从一开始就错了第三个坑是滑块验证码。这次我遇到的站点滑块长这样背景图img.puzzle300×200src 是 base64 data URL拼图块img52×200也是 base64拖动把手div.slider-move40×40两图都嵌在 DOM 里省了下载这一步。我复用上一套逻辑拿拼图块当模板在背景图上横向滑动找差异最小的位置——这是标准的模板匹配之前在另一个站点上成功过。bgImage.open(bg.png).convert(RGB)jgImage.open(jg.png).convert(RGBA)alphajg.split()[3].point(lambdap:255ifp100else0)tpljg.convert(RGB)results[]forxinrange(0,bw-W1):winbg.crop((x,0,xW,H))diffImageChops.difference(win,tpl)stImageStat.Stat(diff,maskalpha)results.append((sum(st.mean)sum(st.stddev),x))results.sort()输出MATCH {x:58,score:152.17,second:59} DRAGGED 59 AFTER {sliderGone:false,hasTip:true,sent:false} SLIDE_FAIL_RETRY拖了 59 像素滑块没过。注意这里的信号特征没有报错没有异常算法完整跑完还给出了一个看起来非常合理的答案——x58。如果它崩了我立刻就知道去修它给了一个数字我就去用了。问题出在对滑块类型的假设上。拼图滑块其实有两种内容型背景图上的缺口处显示的是「被抠掉的那块」模糊后的痕迹而给你的拼图块是从原图上抠下来的真实像素。这种情况下拼图块和缺口位置的内容高度相关模板匹配很准。轮廓型背景图上根本没有内容缺失——缺口是一个白色高亮的拼图形状叠在上面而给你的拼图块本身是不透明的纯色或半透明灰。我这次遇到的是后者。验证方法很简单直接统计拼图块的颜色pxjg.convert(RGB)white0;total0foryinrange(0,h,5):forxinrange(0,w,5):ifalpha.getpixel((x,y))100:total1r,g,bpx.getpixel((x,y))ifr220andg220andb220:white1print(white,/,total)输出0 / 76——76 个采样点里0 个是白色。拼图块不是白的而背景图上的缺口是白的。拿一个「非白的东西」去匹配「白色的区域」模板匹配当然算不出东西它只是尽职尽责地在一堆都不像的答案里挑了一个最不像的x58。正确做法是换个问法别找「拼图块该去哪」直接找「背景图上那个白色的形状在哪」。colcnt[]forxinrange(W):c0foryinrange(H):r,g,bpx[x,y]ifr205andg205andb205:c1colcnt.append(c)统计每一列的高亮像素数然后找宽度在 25–70 像素之间的连续高亮区间。实测下来高亮列集中在 x≈200–220 这个区间阈值 205 时每列有 40 个高亮像素缺口中心大约在 211。58 和 211。差了 153 像素。这两个数字都能让脚本「正常运行到最后一步」只有一个能过验证。这个坑给我最大的提醒是算法的可信度取决于你验证过它的假设而不是它跑通了几次。同一套模板匹配代码在 A 站点成功过三次不代表它在 B 站点有效——它只代表 A 站点的滑块是内容型。换一个站点第一件事应该是重新确认「这个滑块的缺口是怎么画出来的」而不是直接复用。验证的成本很低读一下拼图块的像素统计不验证的成本是拖一次失败刷新再拖一次失败——每次失败都要重新走一遍填表、发码、弹窗的完整流程。把三种失败放在一起看表面信号真实原因为什么没被发现层级取错值为空字符串三层取值 vs 两层取值防御性兜底|| 吃掉了信号文本不匹配NOT_FOUND_写点什么textContent 带私用区图标字符页面上根本看不见那些字符算法假设错给出 x58滑块是轮廓型不是内容型算法不会告诉你它的前提不成立三件事有一个共同结构每一步都执行了每一步都「成功」了但组成起来的结果全是错的。而且它们都不会产生异常——因为从程序自己的视角看它确实每一步都做完了。这正是我之前在另一篇文章里写过的那件事的延伸弱判据的问题不是它经常错是它错了你不知道。那篇讲的是「用页面上的提示文字当判据结果抓到了自己稿子正文里引用的提示」。这次三个坑是同一件事在操作层的版本——你的判据不是「页面上有没有出现成功提示」而是「脚本有没有报错」。而脚本报错从来不等于事情办成了。现在我的做法三条都写进了脚本模板1. 新脚本先打印一次原始响应。不管是 CDP 返回值、接口 JSON 还是算法输出第一次接入时打印一次完整结构确认层级和字段名。成本是十秒钟收益是避免两轮的瞎猜。2. 匹配文本前先归一化。剥私用区字符、折叠空白、优先叶子节点。并且对「找不到」这种结果保持警惕——找不到往往不是元素不存在是你找的方式不对。3. 每一步都要有独立于「没报错」的判据。拖完滑块判据不是「拖动指令发出去了」是「滑块面板消失了」填完表单判据不是「insertText 返回成功」是「输入框的 value 长度等于预期」发布完判据不是「脚本退出码 0」是「文章列表里多了一条标题对得上」。最后一条最关键也最容易被跳过。因为「退出码 0 无异常」是一个太容易达成的状态——它只要求你的代码没有崩溃完全不要求你的代码做对了事。写完这几条之后我重跑了一遍滑块那部分还是没过后来是人工登录绕过去的。但这三个小时没有白费脚本模板里多了三行防御我知道下一个滑块该怎么先问一句「你是什么类型」也知道下次看到「所有值都是空的」时第一件事是去打印原始响应而不是去加等待时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑