ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无需验证码三网话费余额查询:HTML源码+接口解析

无需验证码三网话费余额查询:HTML源码+接口解析 简介面向网站站长、个人开发者和有自助查询需求的用户这份源码无需验证码即可完成移动、电信、联通三网手机话费余额的实时查询并能自动识别运营商携号转网用户通过手动选择同样能获得准确结果。整个压缩包共六个文件包含一个主HTML页面、两个JavaScript脚本、一个CSS样式文件和两张图片页面基于Bootstrap和jQuery搭建界面简洁、兼容手机与PC包体大小仅133KB。目前已有六百一十一人学习下载。资源将接口调用封装在页面内使用者只需将源码中的apiKey替换为自己注册获取的密钥即可独立运行并享受平台赠送的五次免费查询额度对于开发者而言这也是理解运营商API对接、AJAX请求和前端展示流程的轻量示例可快速改造用于积分查询、会员中心、账单展示等场景。1. 聊一个能直接落地的资源移动、电信、联通三家运营商的话费余额查询 HTML 源码 接口聊一个能直接落地的资源移动、电信、联通三家运营商的话费余额查询 HTML 源码 接口。所谓“无需验证码”不是没有鉴权而是把图形验证码、短信验证码从查询链路里摘掉让页面输入手机号就能直接回余额。给内部系统做话费管账、给工作室做充值前后台或者单纯想省掉每次手动登录运营商 App 的麻烦这套东西都合适。适合两类人一是想快速搭查询页面的后端或前端开发者二是需要一个可对接的查询接口做自动化巡检的运维。它能解决的是“查一次余额”这件事的完整链路而不是只给你一个空壳页面。2. 先拆查询链路HTML 是脸接口是魂2.1 一条话费查询请求到底经过几个环节很多人拿到这种源码第一反应是打开 HTML 文件填个手机号点查询结果页面转圈、报错然后就开始怀疑资源有问题。其实绝大多数问题出在链路没走通。一条完整的话费查询请求至少要经过四个环节浏览器页面发起请求、后端接口接收参数、接口向数据源取数、数据源返回余额与状态码。这里最关键的是第二和第三环。HTML 页面本身只是一个壳它负责收集手机号、组装参数、渲染结果真正能不能查到余额取决于后端接口有没有把参数正确地转发给数据源以及数据源给不给你返回数据。个人开发者基本拿不到运营商的直连接口常见做法是走有资质的聚合服务商或者用自己手机的登录态去换数据。这套源码按照前一种思路设计查询动作本身不再弹验证码但前提是你得有一个合法的数据源授权否则它永远只能返回测试数据。2.2 三种可选数据来源与选型理由话费余额这种数据理论上可以走三条路数据来源稳定性鉴权方式说明运营商官方 App 内置接口差登录态 Cookie/Tokon需要持续维护会话经常掉线不推荐聚合数据服务商 API好appkey 签名需要实名和付费按次计费有 QPS 限制自建采集脚本极差无合规风险高封号风险高不讨论选哪条路决定了你的“无需验证码”能不能成立。运营商官方 App 的接口虽然能免验证码但登录态是黑匣子今天能用明天就可能失效你得不断去逆向它的参数加密维护成本极高。聚合服务商 API 则把验证码环节下沉到了注册和授权阶段之后每次查询只需要带上 appkey 和签名这就是“查询时无需验证码”的真相。我一般会建议选聚合服务商 API原因有三个一是接口文档公开字段稳定二是它有 SLA 承诺不会半夜突然挂掉三是它天然支持三家运营商统一查询不用你分别对接三套协议。资源里默认填的就是这一类接口的报文格式。2.3 源码里的请求参数与返回字段这套源码的请求参数不算复杂核心就四个字段参数名类型含义phonestring11 位手机号需要提前清洗不能带空格和 86 前缀appkeystring服务商分配的密钥相当于你的接口身份signstring签名串一般由 appkey 加时间戳做 MD5 生成carrier_typeint运营商类型1 代表移动2 代表电信3 代表联通页面拿到这四个参数后以 JSON 格式提交给后端代理接口。后端返回的数据结构通常是这样的{ code: 10000, msg: success, data: { balance: 68.5, status: 1, phone: 13800138000, operator: 移动 } }这份 JSON 是整套页面的渲染依据。前端拿到后只需要判断 code 是否等于 10000然后取 data.balance 显示在页面上。注意 status 字段它表示号码当前的状态比如 1 是正常、2 是停机、3 是欠费。千万别只读 balance一旦遇到停机或欠费号balance 可能返回 0.00 甚至空值你光判断余额会把停机和真欠费搞混。3. 把源码跑起来本地起服务、填接口、第一次查询成功3.1 目录结构与运行方式资源解压后的目录结构一般长这样虽然不同打包版本里文件命名会有一点差异但核心组成是固定的query/ ├── index.html # 主查询页面 ├── app.js # 页面交互与请求逻辑 ├── style.css # 页面样式 ├── proxy_server.py # 后端代理示例Python Flask └── config.example.json # 接口配置模板这里有一个新手最容易踩的坑直接双击 index.html 用 file:// 协议打开然后发现 fetch 请求直接跨域报错。这不是源码的问题而是浏览器安全策略限制。HTML 里的页面通过相对路径请求后端接口时必须在一个 http 服务环境下运行。常见做法是在项目根目录起一个本地静态服务然后单独跑后端代理。静态服务用 Python 自带模块就够了cd query python3 -m http.server 8080这条命令会在 8080 端口起一个静态文件服务浏览器访问 http://localhost:8080/index.html 就能正常打开页面。注意这个命令只负责托管 HTML、JS、CSS它不做接口转发。接口转发需要交给另一个服务进程也就是下面的代理脚本。3.2 前端发起查询的核心代码段页面核心逻辑在 app.js 里它做三件事校验手机号、组装 appkey 和签名、把请求发给后端代理。核心代码可以浓缩成这样async function queryBalance(phone, appkey, secret) { const cleanedPhone phone.trim().replace(/\s/g, ); if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) { throw new Error(手机号格式不正确); } const timestamp Math.floor(Date.now() / 1000); const sign md5(${appkey}${timestamp}${secret}); const res await fetch(/api/query, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ phone: cleanedPhone, appkey: appkey, sign: sign, carrier_type: detectCarrier(cleanedPhone) }) }); const result await res.json(); if (result.code 10000) { renderBalance(result.data); } else { renderError(result.msg); } }这里的逻辑分四层。第一层是手机号清洗把用户输入里的空格去掉再用正则判断是不是合法的 11 位手机号第二层是签名生成把 appkey、时间戳、secret 拼接后做 MD5目的是让服务商确认请求来自你本人而不是别人盗用 appkey 乱刷第三层是请求发送注意这里打的是/api/query这个相对路径它并不是服务商网关的地址而是你自己的后端代理第四层是结果分发code 为 10000 才渲染余额否则渲染错误信息。detectCarrier 是运营商识别函数它根据号段前缀返回 1、2、3 三种类型这个函数在下一章专门讲。前端把识别逻辑放在这里是为了让后端代理少做一次判断各司其职。3.3 后端代理示例解决跨域与密钥隐藏为什么要加一层后端代理两个原因。第一是跨域问题服务商接口通常不允许浏览器直接跨域调用中间加一层后端转发可以绕过浏览器的跨域限制第二是密钥保护appkey 和 secret 如果直接写在前端代码里等于把钥匙挂在大门上任何打开页面的人都能看到。代理脚本用 Flask 写最省事这是资源里常见做法from flask import Flask, request, jsonify import requests, hashlib, time app Flask(__name__) API_URL https://api.example.com/query # 服务商网关地址 APPKEY your_appkey SECRET your_secret app.route(/api/query, methods[POST]) def proxy_query(): data request.get_json() phone data.get(phone) carrier_type data.get(carrier_type) sign data.get(sign) # 代理层不做业务判断只负责时间戳校验和转发 payload { phone: phone, appkey: APPKEY, carrier_type: carrier_type, sign: sign, timestamp: int(time.time()) } resp requests.post(API_URL, jsonpayload, timeout5) return jsonify(resp.json()) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)这段代码的关键点是签名由前端生成并传过来代理层不重新生成签名只负责把请求原样转发给服务商。这样设计的好处是前端可以直接复用服务商的签名规范不用在代理层再写一套。代理层唯一的额外职责是加了一个 timestamp用来配合服务商做时间窗校验防止请求重放。如果你要跑通它先把 config.example.json 里的 APPKEY 和 SECRET 替换成你自己的然后启动 Flask 服务。这里的 SECRET 是服务商签名用的密钥绝对不能暴露在前端代码里否则任何人都能伪造请求。3.4 第一次联调用 curl 验证接口再上页面不要一上来就开浏览器点点点。先关掉浏览器用 curl 直接打后端代理确认接口链路是通的再回页面这样排查问题时能少一半干扰。curl -X POST http://localhost:5000/api/query \ -H Content-Type: application/json \ -d {phone:13800138000,appkey:your_appkey,sign:your_sign,carrier_type:1}观察三点第一HTTP 状态码是不是 200第二返回 JSON 里的 code 是不是 10000第三返回的 balance 是不是一个正常数值。如果 curl 能返回但页面查询时报错问题一定出在前端 JS 的组装环节检查 fetch 的路径、请求头、序列化方式。如果 curl 也报错问题就出在代理层或服务商配置上再往底层排查。这一套顺序下来基本五分钟内能定位是前端问题还是后端问题。4. 参数与边界手机号校验、运营商识别、状态码语义4.1 手机号清洗与 11 位校验话费查询接口的报错里出现频率最高的是“号码格式不正确”和“号码为空”。原因是用户输入五花八门有人带空格有人带破折号有人从通讯录复制过来带了 86 前缀。如果不清洗直接发给服务商对方大概率返回参数错误。清洗逻辑分成两步。第一步是去掉所有非数字字符第二步是判断是否为 1 开头的 11 位号码。下面这段校验函数可以直接抄进 app.jsfunction cleanPhone(rawInput) { let digits rawInput.replace(/[^\d]/g, ); if (digits.startsWith(86) digits.length 13) { digits digits.slice(2); } if (!/^1[3-9]\d{9}$/.test(digits)) { return null; } return digits; }这段代码先剔除所有非数字字符再把类似 8613800138000 这种 13 位带国家码的号码还原成 11 位。注意最后回归一下规则首位必须是 1第二位必须是 3 到 9 之间的数字因为现在的号段已经扩展到 19 开头了。清洗函数放在前端的好处是后端收到的一定是标准格式减少一次无效查询的费用消耗。4.2 运营商自动识别与 carrier_type 映射用户不知道自己手机号属于哪家运营商很正常所以页面要做自动识别。运营商的号段规则不是一成不变的但常用前缀相对稳定足以覆盖大多数情况运营商常见号段前缀carrier_type移动134-139, 147, 148, 150-152, 157-159, 178, 182-184, 187-188, 195, 197, 1981电信133, 149, 153, 173, 174, 177, 180-181, 189, 190, 191, 193, 1992联通130-132, 145, 146, 155-156, 166, 167, 171, 175-176, 185-186, 1963识别函数按前三位前缀匹配即可不要做复杂判断function detectCarrier(phone) { const prefix phone.slice(0, 3); const mobile [134,135,136,137,138,139,147,148,150,151,152,157,158,159,178,182,183,184,187,188,195,197,198]; const telecom [133,149,153,173,174,177,180,181,189,190,191,193,199]; const unicom [130,131,132,145,146,155,156,166,167,171,175,176,185,186,196]; if (mobile.includes(prefix)) return 1; if (telecom.includes(prefix)) return 2; if (unicom.includes(prefix)) return 3; return 0; }这里有一个容易翻车的细节携号转网用户。用户从移动转到电信号段前缀不变但运营商已经变了。如果按前缀识别会传给服务商错误的 carrier_type导致查询失败。解决方法是把识别结果和查询结果做一次交叉校验第一次查询后拿到服务商返回的真实运营商把它缓存到本地后续查询以缓存为准。这个方案不算完美但能在多数场景下兜底。4.3 返回值里容易忽略的状态码很多人在页面里只写if (code 10000)然后渲染 balance忽略了完整状态码语义结果上线后被各种边缘情况打得措手不及。常见的状态码语义如下code含义页面处理建议10000查询成功渲染余额10001号码不存在提示用户检查号码10002号码已销号提示号码已失效10003号码停机显示停机标识不显示余额10004号码欠费显示欠费状态可展示待缴金额10005接口限流提示稍后重试实际开发里最容易漏的是 10003 和 10004。停机号码的 balance 字段通常返回 0.00但如果你把 0.00 展示成“余额零元”用户会认为号码还有钱但打不出电话这就是误导。欠费号码同理balance 可能是一个负值页面渲染时要加绝对值处理否则显示-68.5 元非常奇怪。建议你在页面里维护一张状态码映射表把 code、msg、页面提示文案各自独立存放方便后面按服务商文档调整。别写死在前端逻辑里不然每次改状态码都要重新打包。5. 避坑话费查询联调中最容易踩的五个坑5.1 鉴权与请求层三个高频坑第一个坑是接口报 10001 但号码明明存在。现象是curl 配置完全正确服务商文档里查不到该错误码的业务含义但返回 10001原因大概率是手机号被后端代理二次处理时加了多余内容比如 JSON 序列化后 phone 字段带了空格或者清洗函数把前导的 1 当国家码截掉了解决方法是代理层打印一份原始请求报文逐字对照服务商示例报文重点检查 phone 字段的长度和首位数。第二个坑是本地能查、部署到服务器就超时。现象是本地 Flask 跑得好好的换到云服务器上请求直接 timeout原因是服务商接口做了 IP 白名单限制你本地 IP 在测试时被加进了白名单服务器的公网 IP 没加解决方法是登录服务商控制台把服务器出口 IP 加进白名单同时确认服务器能访问外网别被安全组规则挡了 443 端口。第三个坑是签名对不上。现象是返回报文提示 sign 校验失败原因是前端生成的 sign 与服务商要求的签名串拼接顺序不一致常见情况是时间戳字段名对不上或者 secret 被多了空格解决方法是先关掉前端手动按文档里的样例拼接一次签名用 curl 验证确认无误后再把这套拼接逻辑原封不动搬回前端。5.2 数据解析与页面层两个隐蔽坑第四个坑是查询结果返回成功但 balance 是 null。现象是code 为 10000但 data 里的 balance 字段是 null页面渲染直接白屏原因是该号码处于一种中间状态比如刚刚销号但数据还没完全更新服务商返回空值但不报错解决方案是做空值兜底渲染时判断balance null就显示“暂无法获取”不要直接用 innerHTML 插入 null。第五个坑是页面轮询查询时第二次请求永远失败。现象是每次查询第一次都成功紧接着查第二个号码必失败刷新页面后恢复原因是服务商对同一 appkey 有频率限制比如每秒一次前端第一次查询的响应还没完全结束第二次请求就发出去了解决方法是加一个简单的前端互斥锁用isLoading标志位阻止重复提交同时把请求超时调到 10 秒以上给后端留足取数时间。6. 从“能查”到“好用”低成本的余额预警与批量查询6.1 用定时任务把查询变成每日巡检单次查询做通之后价值有限。真正的场景是把这套接口接进定时任务每天早上自动查一遍所有号码的余额和状态低于阈值就推一条告警。加一个简单的定时脚本就能实现import requests, time phones [13800138000, 13900139000] def check_all(): for phone in phones: resp requests.post(http://localhost:5000/api/query, json{phone: phone, appkey: key, sign: sign, carrier_type: 1}, timeout10) data resp.json() if data.get(data) and data[data][balance] 20: print(f告警{phone} 余额不足 {data[data][balance]} 元) if __name__ __main__: check_all()再配合 cron 或者 Windows 任务计划程序每天早上 9 点跑一次话费巡检就算自动化了。这里唯一的坑是别把代理服务和定时脚本放在同一台机器上还共用同一份 appkey否则请求频率叠加后会触发限流。6.2 阈值判断与结果落库另一个提升使用体验的做法是给查询结果加一个状态字段落库方便追溯历史。用 SQLite 就够了不用上 MySQLimport sqlite3 conn sqlite3.connect(balance.db) conn.execute(CREATE TABLE IF NOT EXISTS check_log (phone TEXT, balance REAL, status INTEGER, check_time TEXT)) def save_result(phone, balance, status): conn.execute(INSERT INTO check_log VALUES (?, ?, ?, datetime(now)), (phone, balance, status)) conn.commit()这里有两点提醒一是时间字段最好用数据库的当前时间不要用前端传上来的时间防止时钟偏差二是 balance 字段要存 REAL不要存成字符串否则后续做聚合统计排序时会出问题。做完这两步这套查询资源就不只是演示用了而是具备了一个内部工具的基本雏形。从那以后我每次联调这类余额查询接口都会习惯性先跑一遍状态码枚举和一个空字段用例再进页面联调因为状态码的语义永远比你想的多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进