
你发的照片可能带着家庭住址——我用码道 Agent 写了个 EXIF 隐私清除器19 项断言验证清除彻底一键开通华为云码道 CodeArts 代码智能体https://developer.huaweicloud.com/codeartsco.html?sourcedmzntgwatomgit1sourceaddmzntgwatomgithd一、这玩意儿是干嘛的你在朋友圈、社交平台发一张手机拍的照片以为只是发了张图。但那张图的字节里可能悄悄塞着你家的 GPS 经纬度、拍摄时间、手机型号、甚至相机序列号。别人把图存下来用个免费的 EXIF 查看器就能知道你什么时候、在哪个小区按的快门。我做了个工具拖入一张照片它解析出里面所有隐私字段GPS、机型、时间…给你看然后一键把这些字段从字节层面抹掉输出一张干净的图。关键是——清除结果可验证清除后 GPS 读不出来了、但图片本身没坏尺寸、画面完好。代码是喂华为云码道 CodeArts Agent一轮轮建的仓库atomgit.com/wangleizi/exif-scrubber零第三方依赖纯字节解析验证脚本 19/19 断言全过。二、为什么做这个前阵子看新闻有人晒火车票照片二维码和座位信息没打码被人反查出行程。我心想照片这玩意儿更隐蔽——火车票你至少知道要打码但 EXIF 里的 GPS 是默认写进去、肉眼完全看不见的。我自己试了下手机随手拍一张用系统属性一看经度纬度、几点几分、哪款手机全在里面。这太吓人了而且大家根本没意识。更麻烦的是很多人以为我裁了图、加了滤镜再发就没事了——不一定。很多修图 App 会保留原图的 EXIF你裁完发出去 GPS 还在。真正稳妥的办法是在发送前把元数据整段剥掉。可市面上大部分清除 EXIF的在线工具要你先把隐私照片上传到别人服务器——这本身就是二次泄露。所以我想做一个本地跑、不联网、字节级操作的照片不出你电脑清除过程全透明还能验证。这事儿适合做成可验证的工具因为它有确定的对错标准一个 JPEG 的 EXIF 存在 APP1 段里格式是公开的规范TIFF/IFD 结构。我能精确解析出每个字段、精确地把那段字节删掉、再精确地验证删干净了且没删坏图片。每一步都能用断言卡住不是感觉清掉了。选题三条铁律一秒看懂、有客观真值、别撞车。隐私清除这个赛道获奖名单里没有跟文件管家也不同——那是整理文件这是面向隐私的字节级解析器且清除结果可被第三方工具复核。三、先跑起来看看零依赖纯 Node 20 ES Modulegitclone https://atomgit.com/wangleizi/exif-scrubber.gitcdexif-scrubbernode--test# 单元 集成测试nodeevidence/verify.mjs# 19 项断言验证清除彻底nodebin/scrub.mjs in.jpg out.jpg# 命令行清除命令行node bin/scrub.mjs一条命令把一张带 GPS 的图洗成干净的。前端页面白底拖入图片能可视化看到清除前有哪些隐私字段、清除后还剩什么。这里先给个直观感受一张 354 字节的最小测试 JPEG含 GPS、机型、时间清除后只剩 33 字节——那 300 多字节全是你的隐私元数据图片画面本身一个像素没动。真实手机照片里这个 APP1 段可能有几十 KB塞着经纬度、精确到秒的拍摄时间、你手机的具体型号、甚至序列号。四、怎么钉码道 Agent老规矩提示词钉到函数级结尾挂铁律直接建文件并git add -A git commit git push不要只描述、不要贴代码正文只回复 git log 文件树 测试 pass/fail。第一轮提示词原样【R1 · EXIF 隐私清除器 exif-scrubber 核心字节解析层】 建 public 仓库 exif-scrubber。Node 20 ES Module零依赖自己解析字节不引第三方 exif 库。 - src/jpeg.mjs parseSegments校验 SOI(FFD8)遍历 marker(FFEx APPn/FFC0 SOF/FFD9 EOI) - src/tiff.mjs parseIfd解析 TIFF header(II/MM 字节序 魔数 0x2A IFD 偏移)读 IFD0 - src/scrub.mjs 移除 APP1/EXIF 段保留 SOI/SOF/SOS/图像数据/EOI - test 用手工构造的最小 JPEG Buffer 断言能识别 APP1、scrub 后 hasExiffalse 且 SOI/EOI 仍在 直接建文件并 git commit git push只回复 git log 文件树 测试 pass/fail切三轮喂R1 字节解析核心、R2 白底前端命令行对拍、R3 README集成测试。每轮本地复核。五、架构把 JPEG 当成一串带标记的块JPEG 不是一坨像素它是一串带 marker 的段SOI 开头、中间若干 APPn 段EXIF 就藏在 APP1、SOF 存尺寸、SOS 之后才是压缩的图像数据、EOI 结尾。清除 EXIF 的本质就是找到那个 APP1 段、把它的字节整段抠掉别碰其它段。想通这一点整个项目就简单了它不是图像处理是二进制文件结构解析。我不需要懂 JPEG 怎么压缩像素我只需要认得每个 marker 的魔数、按声明的长度跳过对应的段。APP1 段自己会告诉你它有多长我从头顺着 marker 走一遍遇到 EXIF 的那个就记下起止位置最后把这些字节挖掉、其余原样拼回去。图像数据段SOS 之后那一长串我根本不碰所以画面不可能被弄坏——这也是为什么清除后尺寸仍是 800×600这条断言几乎必然成立。而 APP1 里面又是另一套结构它开头是Exif\0\0六个字节后面跟一个完整的 TIFF 文件。TIFF 又分 IFD0相机厂商、型号这些、Exif 子 IFD拍摄时间这些、GPS 子 IFD经纬度这些彼此用偏移量指针串起来。所以解析 EXIF 其实是在 JPEG 里解析一个 TIFF两层结构套娃。这也是这个项目最有意思、也最容易翻车的地方。bytes(大小端读) → jpeg(marker 段遍历) → tiff(IFD 目录解析) → fields(敏感字段表) → scrub(移除 APP1) ↓ verify(19 断言清除彻底 图片完好)六、核心代码掰开揉碎scrub 的逻辑特别干净遍历所有段凡是 EXIF 的 APP1 段记下它的字节区间然后把这段从 buffer 里剔除、其余原样拼接// src/scrub.mjsexportfunctionscrub(buf){constsegmentsparseSegments(buf);constremoveRanges[];for(constsegofsegments){if(isExifApp1(buf,seg))removeRanges.push([seg.offset,seg.offsetseg.length]);}if(removeRanges.length0)returnBuffer.from(buf);removeRanges.sort((a,b)a[0]-b[0]);constparts[];letcur0;for(const[start,end]ofremoveRanges){if(startcur)parts.push(buf.subarray(cur,start));curend;}if(curbuf.length)parts.push(buf.subarray(cur));returnBuffer.concat(parts);}哪些字段算敏感我让码道列了一张明确的表——GPS 经纬度/海拔/时间戳、拍摄时间、相机厂商型号、序列号、机主姓名共 18 个// src/fields.mjs节选exportconstSENSITIVE_TAGS[{tag:0x8825,name:GPSInfoIFDPointer,why:GPS 信息块指针含经纬度/海拔/时间戳},{tag:0x0002,name:GPSLatitude,why:GPS 纬度坐标},{tag:0x0004,name:GPSLongitude,why:GPS 经度坐标},{tag:0x9003,name:DateTimeOriginal,why:原始拍摄时间},{tag:0x010F,name:Make,why:相机制造商},{tag:0xA435,name:SerialNumber,why:相机序列号},{tag:0xA420,name:OwnerName,why:相机所有者姓名},// ... 共 18 项];这张表我特意让码道给每个字段都配了一句为什么敏感的人话说明而不是光列个 tag 号。因为对普通用户来说0x8825没有意义你家的经纬度才有意义。工具的输出要让人看懂自己在丢什么这比功能本身更重要。比如MakerNote厂商私有注记这个字段很多人不知道它里面常藏着相机序列号、对焦距离甚至拍摄者信息我也是在做这个项目的过程中才搞清楚的——这大概就是这类隐私科普型工具的额外价值它顺手把你没注意到的东西摊开给你看。TIFF/IFD 解析是最硬的一块——要处理大小端字节序、目录项偏移、有理数GPS 坐标是度/分/秒三个有理数拼的。码道这块一次写对了底层得先有个能按大小端读字节的工具。TIFF 的字节序由 header 头两个字节决定II小端 /MM大端读错端序所有偏移和数值全乱// src/bytes.mjs —— 支持大小端的字节读取器exportfunctionReader(buf,littleEndian){constu16(o)littleEndian?buf.readUInt16LE(o):buf.readUInt16BE(o);constu32(o)littleEndian?buf.readUInt32LE(o):buf.readUInt32BE(o);constrational(o)({num:u32(o),den:u32(o4)});// GPS 度分秒用return{u16,u32,rational};}verify.mjs 把两类断言落地成可跑代码——清除前必须读得到、清除后必须读不到、且图片结构必须还在// evidence/verify.mjs节选constbeforeparseIfd(findApp1(sample));assert.equal(before.Make,TestCam);// 清除前隐私在assert.ok(before.GPSLatitude);// 清除前GPS 在constcleanedscrub(sample);assert.equal(hasExif(cleaned),false);// 清除后EXIF 没了assert.equal(cleaned[0],0xFF);// 清除后SOI 还在assert.deepEqual(sizeOf(cleaned),{w:800,h:600});// 清除后尺寸没坏七、护城河不光要删了还要证明删干净了且没删坏一个隐私清除器最容易自欺的地方是它声称清除了但你怎么知道它真清干净了、又怎么知道它没把图片搞坏我让码道写了个evidence/verify.mjs用一张内置的、故意塞满 GPS/机型/时间的最小 JPEG做样本跑 19 条断言清除前解析出 GPS 30°15′N / 120°E、MakeTestCam、ModelX-100、DateTime … ✅ 清除后hasExiffalse ✅ · SOI/EOI 完整 ✅ · 尺寸仍 800×600 ✅ · APP1 段消失 ✅ · 体积 354 → 33 字节 ✅这 19 条分两类缺一不可一类证明隐私真的没了GPS/机型/时间读不出来了一类证明图片没被删坏SOI/EOI 还在、尺寸解析得出来、画面数据完整。只做前一类可能把整张图删废了也算清除成功只做后一类可能图片好好的但 EXIF 没删掉。两类一起才叫清除彻底且无损。八、测试// test/jpeg.test.mjstest(scrub 移除 APP1 且保留 SOI/EOI,(){constoutscrub(sampleJpeg);assert.equal(hasExif(out),false);assert.equal(out[0],0xFF);assert.equal(out[1],0xD8);// SOI 还在assert.equal(out.at(-2),0xFF);assert.equal(out.at(-1),0xD9);// EOI 还在});// test/tiff.test.mjs —— 小端 TIFF 能读出 MakeTESTCAM九、真实的坑坑 1又是私有仓。码道默认把 exif-scrubber 建成了 private我本地 clone 弹登录框。发 nudge 让它改 public 才匿名可访问。这毛病我三篇里遇到两回了提示词写public它也不当回事。坑 2push 认证崩。R2/R3 码道本地提交推不上去ag 的 remote token 没了我发重新绑定仓库再 push的 nudge 才救回来。做到这个系列码道 push 崩已经是保留节目。坑 3本地跑不起来。最坑的一次——我本机 git clone exif-scrubber 一直弹凭据框、被自动取消另两个仓库的凭据还缓存着能 clone这个没有raw/API/zip 下载全返回 SPA 外壳。最后我改用登录浏览器导航到 blob 页、从渲染的 DOM 里把代码抠出来才拿到源码和验证报告。这条路的折腾本身就说明验证一个工具最好别只信它自己的报告——我这篇里的数字是我从远端仓库的真实报告里抓出来的不是我本地跑出来的二者能对上才算数。坑 4字节序和偏移是雷区。TIFF 里 IFD 的偏移是相对于 TIFF header 起点的不是相对于 JPEG 的 APP1 起点。码道第一版把基准搞混过一次读出来的 Make 全是乱码。修法是解析时统一记住 TIFF 段的起始偏移所有 IFD/子目录偏移都基于它换算。这种差一个基准全错的坑AI 不太会主动防得靠测试卡。集成测试我把端到端钉死成一条链——构造带隐私的图、解析读到、清除、再解析读不到、且图片仍完好// test/integration.test.mjstest(端到端带 GPS 的图 → 清除 → 隐私消失且图片完好,(){constdirtybuildSampleJpeg({gps:[30,120],make:TestCam});assert.ok(hasExif(dirty));assert.ok(parseIfd(findApp1(dirty)).GPSLatitude);constcleanscrub(dirty);assert.ok(!hasExif(clean));assert.equal(parseSof(clean).width,800);// 画面尺寸没被删坏});说到底做隐私工具最讽刺的失败是你以为删干净了其实没删。所以我没让它只输出清除成功这句话而是逼它把清除后的字节再解析一遍、把 GPS 字段再读一次、把图片尺寸再量一次——三个动作分别对应删没删、删干净没、删坏没。少任何一个这个工具都不值得信。十、提效数据环节码道 Agent我建仓库 字节解析核心✅出题JPEG/TIFF/IFD 解析✅大小端一次对验收18 个敏感字段表✅补充19 条可验证断言✅ 实现定删干净没删坏两类改 public / 修 push❌✅ nudge抓远端报告核对数字❌✅ 浏览器 blob这张表看下来你会发现一个规律凡是有明确规范可对照的活JPEG marker、TIFF IFD、字段表、断言码道一次就做得又快又对凡是需要判断这算不算真验证、边界在哪、报告可不可信的活它一概不管甚至会把私有仓、失效 push 这些坑留给你收尾。所以用它的正确姿势是把标准明确的部分甩给它把需要较真的部分自己攥着。十一、五维自检眼前一亮你发的照片可能带着家庭住址一秒被戳中。可验证护城河19/19 断言既证隐私清除、又证图片无损node evidence/verify.mjs可复现。原创度不是文件管理器是面向隐私的字节级解析器。工程质量零依赖、纯 Buffer 操作、大小端正确处理。诚实度只处理 JPEG APP1、未覆盖 PNG/WebP、缩略图内嵌 EXIF 未处理全写明。这里特别说一下未覆盖的部分因为做隐私工具说清楚哪些没保护到比吹保护得多好重要得多。第一我只处理 JPEG 的 APP1 段PNG 的元数据在 tEXT/iTXt 块里、WebP 又在另一套结构里这个工具都不碰——你拿 PNG 来洗它会老实告诉你没检测到 EXIF而不是假装成功。第二JPEG 的缩略图EXIF 里会内嵌一张小预览图本身也可能带元数据我整段移除 APP1 时其实把缩略图一起删了所以这个反而顺带解决了但我在 README 里没把它当卖点因为它是个副作用不是设计。第三有些手机会把位置写进 XMP一种基于 XML 的元数据那不在标准 EXIF 的 IFD 里我也没处理。这些边界我全列在 README 的已知不足里——一个隐私工具最怕的就是给你一种洗干净了的虚假安全感。十二、写在最后这个小工具让我自己养成了一个习惯发照片前先洗一遍 EXIF。而它教会我最深的一课恰恰是那个本地跑不起来、只能从远端仓库抓报告的坑——当你的验证环境本身可能骗你时去信一个你没法篡改的第三方视角。我没法在本地跑 exif-scrubber所以我没有编数字而是把它推到 public 仓库、再从仓库的渲染页面把真实报告抠回来核对。这跟这个系列前几篇反复讲的别把自证当验证其实是同一句话。顺带说一句做这类字节解析工具的体会它跟做 UI、做算法都不一样几乎没有模糊空间——一个 marker 的魔数对不对、一个偏移的基准算没算错要么成要么崩测试骗不了人。也正因如此它特别适合拿来练可验证这件事你没法靠看起来能跑糊弄过去只能老老实实把每条断言写清楚、跑通。码道在这种标准明确的活儿上表现最好因为它不用猜你要什么JPEG/TIFF 规范就摆在那它照着实现、我照着验。欢迎把你手机里随手拍的照片丢进去看看那些你从没注意到的经纬度会把你吓一跳https://atomgit.com/wangleizi/exif-scrubber。洗完再发图还是那张图只是不再顺带交出你家地址了。