接触过关键词按天扣费这门生意的人,多半都在两个问题上纠结过:一是想自己搭一套seo扣费系统源码,把排名监测、计费、出账这几件事串成自动化流水线;二是心里始终存着一个疑问——关键词按天扣费就一定靠谱吗。前一个是工程问题,后一个是商业判断题,而现实里绝大多数纠纷,恰恰是因为把这两个问题混在一起谈。我见过不少团队,系统做得挺花哨,排名一抓一大把,但真到月底出账的时候,客户一句“我这关键词昨天明明在首页”就能把整个账单推翻重来。也见过另一些团队,规则写得清清楚楚,偏偏检测端三天两头漏抓、重抓,账目对不上,最后只能人工兜底。
这篇东西想干的事很简单:把“关键词按天扣费”这个模式彻底拆开,从技术实现角度讲清楚一套扣费系统源码应该长什么样,从落地角度讲清楚哪些坑是必然会踩的,最后再回到那个原始疑问上——按天计费这套逻辑本身,到底哪些地方站得住,哪些地方从一开始就埋着雷。适合正在做或准备做这类系统的开发者,也适合作为服务采购方去判断一套系统、一份报价单值不值得信。技术细节我会给到可以照着写的程度,商业判断我会给到可以拿去谈判的程度,两者不混着说。
1. 把“按天扣费”翻译成技术需求
1.1 这个模式到底在卖什么
先把生意模型说透。关键词按天扣费的本质,是服务方承诺“某个关键词在约定搜索引擎的约定位置上保持展示”,然后按“达标的天数”向客户收钱。达标就扣,不达标不扣,掉出约定位置当天不计费,重新回到位置再接着算。听上去对客户极其友好,实际上它的所有争议都藏在“达标”这两个字的定义里。
所以一套扣费系统真正在处理的,不是“扣钱”这个动作,而是“判定某一天是否达标”这个事实认定。钱只是这个事实的附属产物。很多源码把重心放在计费公式、余额扣减、账单生成上,检测环节草草了事,结果就是账算得再精准,依据是错的,一样白搭。我在实际项目里见过最典型的翻车方式:检测脚本用了一个固定的请求头,搜索引擎返回的是移动端简化结果页,自然结果位结构和桌面端完全不同,脚本却按桌面端的 DOM 结构去解析,抓出来的位置全是错的,连续一周给客户按“首页达标”扣费,客户自己截图一对比,直接要求全额退款。
这件事给我的教训很直接:判定逻辑的可靠性,决定了整套系统的可信度上限,计费逻辑做得再优雅也补不回来。
1.2 系统必须回答的四个问题
把需求往下拆,一套能用的系统至少要能稳定回答四个问题,缺一个就落不了地。
第一个问题是“谁在什么条件下查”。同一个关键词,不同城市、不同设备、不同时间点查出来的结果可能完全不同,系统必须把查询的地域、设备类型、是否登录、是否开启个性化这些维度固定下来,形成一套可复现的口径。口径不固定,后面所有讨论都是空的。
第二个问题是“查到第几名算达标”。这个不能含糊成“首页”,要落到具体的自然结果位序号,比如前 10 位、前 20 位,还要明确是否排除推广位、是否排除聚合卡片。行业内的大量扯皮都源于这里:客户理解的“首页”包含顶部推广,服务方理解的“首页”只算自然结果,双方各说各话。
第三个问题是“哪天该扣、扣多少”。这涉及日切时间点、补扣规则、冻结额度、退款路径,是一套独立的状态机,必须和检测彻底分开。
第四个问题是“凭什么证明”。每一次检测都要留下可回溯的证据——时间戳、请求参数、返回快照、解析结果。没有快照,一切争议都只能靠嘴。
这四个问题对应四层能力:采集、判定、计费、存证。后面所有章节基本都围绕这四层展开。
2. 整体架构:检测、判定、计费、对账四层拆开
2.1 模块划分与数据流
我习惯把这套系统切成五个独立进程,进程之间只通过数据库和消息队列通信,任何一个环节挂掉都不影响其他环节继续跑。这个切法不是为了炫技,而是因为这类系统的故障模式极其不对称:采集端因为网络和反爬,天生不稳定;计费端则必须绝对稳定,一分钱都不能错。把稳定的和不稳定的绑在一起,等于让不稳定的部分去污染钱。
五个部分大致是这样分工的:
- 调度层:负责按关键词、按地域、按设备维度生成检测任务,控制并发和频率,避免对目标站点造成压力。
- 采集层:执行实际请求,拿到原始返回,原样入库,不做任何解析。
- 解析判定层:从原始返回里提取目标关键词的排名位置,结合达标规则给出“达标/不达标/异常”三态结论。
- 计费层:按日读取判定结果,生成扣费流水,处理余额、冻结、退款。
- 对账层:生成日报、月报、异常清单,对外提供查询接口和快照回放。
数据流是单向的:调度 → 采集 → 解析 → 判定 → 计费 → 对账。单向流的好处是每一步都可以重放。某天解析规则写错了,只要原始快照还在,改完规则重新跑一遍解析就行,不用重新去请求目标站点,也不用动已经生成的账单——账单可以按新结论反向冲正。
注意:采集层“不做解析、原样入库”这条纪律必须严格执行。很多源码为了省存储,解析完就把原始返回丢了,一旦解析出错,连回溯的机会都没有。原始 HTML 压缩后单条通常几十 KB,一天几万个关键词也就是几个 G 的量级,这个成本绝对值得花。
2.2 数据表设计:三张核心表撑起全部逻辑
表结构不用设计得太复杂,抓住三张核心表就能跑起来,其余都是辅助。
第一张是检测任务表,记录每一次检测的输入参数:关键词、目标域名、搜索引擎标识、地域编码、设备类型、计划执行时间、状态。这张表的关键是“输入可复现”,只要参数一致,理论上结果就应该一致。
第二张是检测结果表,记录输出:任务 ID、实际执行时间、排名位置、命中 URL、页面快照地址、结论状态、失败原因。排名位置用一个整数表示,未找到用 -1,异常用 NULL 并附带原因码。把“未找到”和“异常”分开非常重要,前者是明确的事实(关键词不在前列),后者是系统没查清楚,前者可以参与计费判定,后者绝对不能。
第三张是计费流水表,记录钱:客户 ID、关键词 ID、账期日期、计费状态、扣费金额、关联的结果 ID、幂等键。这张表必须只增不改,任何修正都通过新增冲正记录实现。
三张表之间靠关键词 ID 和日期串起来,逻辑关系非常清晰。我见过一些源码把判定结果直接冗余进计费表,看起来省了一次关联查询,实际上一旦判定被修正,历史账单就出现数据不一致,排查起来极其痛苦。
2.3 为什么检测和扣费必须解耦
这一点值得单独强调。检测是不确定事件,可能失败、可能延迟、可能判错;扣费是确定性事件,必须一次做对。如果让扣费流程依赖“检测刚刚成功”这个前提,那么采集端的一次抖动就会直接变成财务上的漏扣或错扣。
正确的做法是:扣费只认“账期日结束时,该关键词在该日是否存在一条有效的、结论为达标的判定记录”。检测在一天里的什么时候完成、重试了几次,扣费完全不关心。这中间隔着一层“日终结算”的屏障,所有不确定性都在屏障之前被吸收掉。
这个设计还有一个额外好处:补扣变得极其自然。某天因为采集失败导致判定缺失,只要在允许的补采窗口内(比如次日上午)补上了结果,重新跑一次结算就能把这天的费用补进去,不需要任何特殊逻辑。
3. 排名检测环节的实操细节
3.1 采集端要处理的四类干扰
采集是整个系统里最脏最累的活,我把它遇过的干扰归成四类,每一类都得单独处理。
第一类是结果个性化。同一个关键词,不同网络环境、不同浏览历史查出来的顺序可能不一样。处理办法是让请求尽可能“干净”:不带任何登录态、不带个性化标识参数、使用固定的请求头组合、清空所有本地状态。有些团队会专门维护一批干净出口,每个出口对应一个地域,这点投入是必要的。
第二类是结果结构变化。搜索结果页的版式会不定期调整,今天自然结果从某个容器开始,明天可能插入了新的卡片模块。解析规则必须写成“可容错”的形式:先定位自然结果容器列表,再逐条比对标题和链接域名,而不是硬编码第几个节点。只要结构一变就报错退出,让它变成“异常”而不是错误地给出一个排名。
第三类是反爬与限速。频率过高会触发验证页,返回的 HTML 结构完全不同。这里的关键是识别“验证页特征”,一旦命中就直接标记为异常并且降速重试,绝不能把验证页当成正常结果去解析——那会解析出排名 -1,被误判为“掉出前列”,直接影响计费。
第四类是多地域、多设备的组合爆炸。十个关键词、三十个城市、两种设备,就是六百个任务。这套组合如果全量每天都跑,成本和风险都很高。我的做法是分级:客户明确约定的口径(比如省会城市 + 移动端)每天全量跑;其余地域做抽样,两三天轮一次,只用于监控趋势,不参与计费判定。这样既控制了成本,又保留了发现问题的手段。
3.2 一个可用的采集脚本骨架
下面这段是简化后的采集骨架,重点看错误分类和结果落库的方式,具体解析规则要按目标页面的实际结构去写。
import time import hashlib import requests from datetime import datetime CLEAN_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "text/html,application/xhtml+xml", } VERIFY_PAGE_MARKERS = ["请完成验证", "访问过于频繁", "人机校验"] def fetch_serp(keyword, region_code, device, retry=3): """返回 (html, error_code)。error_code 为 None 表示拿到正常页面。""" params = build_params(keyword, region_code, device) for attempt in range(retry): try: resp = requests.get( ENDPOINT, params=params, headers=CLEAN_HEADERS, timeout=15, ) html = resp.text if any(m in html for m in VERIFY_PAGE_MARKERS): time.sleep(3 * (attempt + 1)) # 指数退避,避免越试越封 continue if resp.status_code != 200: time.sleep(2 * (attempt + 1)) continue return html, None except requests.RequestException as e: time.sleep(2 * (attempt + 1)) last_err = str(e) return None, "FETCH_FAILED" def run_one_task(task): html, err = fetch_serp(task.keyword, task.region_code, task.device) if err: save_result(task.id, rank=None, status="ERROR", error_code=err, snapshot=None) return snapshot_key = hashlib.sha256(html.encode("utf-8")).hexdigest() upload_snapshot(snapshot_key, html) # 原始页面先落地,再解析 try: rank, hit_url = parse_rank(html, task.keyword, task.target_domain) except ParseError as e: save_result(task.id, rank=None, status="ERROR", error_code="PARSE_FAILED", snapshot=snapshot_key) return status = "HIT" if rank is not None and rank <= task.max_position else "MISS" save_result(task.id, rank=rank, status=status, error_code=None, snapshot=snapshot_key, hit_url=hit_url)这段代码里有三个设计点值得说。第一,先存快照再解析,顺序不能反,解析抛异常时快照已经安全落地;第二,验证页单独归类,它既不是请求失败也不是解析失败,混在一起会让告警失去意义;第三,重试用退避而不是固定间隔,固定间隔重试在限速场景下会加速恶化。
3.3 结果归档与快照留存
快照留存策略要根据业务量定,我给个可参考的区间:计费口径内的检测任务,原始快照至少保留 90 天,最好覆盖完整的账期争议窗口;非计费口径的抽样任务,保留 7 天足够。快照存对象存储,数据库里只存 hash 和索引,别把 HTML 塞进关系库,否则表会膨胀得没法用。
另外建议给每个快照生成一个对外可访问的只读链接(带签名和有效期)。客户质疑某天的排名时,直接把链接发过去,比来回解释半小时有效得多。这个功能实施成本不高,但在实际商务沟通里的价值极大,很多纠纷就是靠它当场化解的。
4. 扣费引擎:幂等、日切、补扣与冻结
4.1 幂等键怎么设计
扣费引擎第一原则是幂等。同一个关键词、同一个账期日期,无论结算任务被调度多少次,都只能产生一条扣费记录。实现方式就是幂等键:客户ID + 关键词ID + 账期日期 + 计费类型,做唯一索引,插入冲突就跳过。
这里有个容易忽略的细节:冲正记录也要参与幂等设计。某天判定被修正,需要把原来扣的费用退回去,这时候不能去更新原记录,而是插入一条负金额的冲正记录,它的幂等键里要带上“冲正版本号”,否则重复执行冲正会把钱退两次。我见过真实案例,一次误操作让冲正任务跑了三遍,客户余额直接翻倍,第二天出账才发现,好在金额不大,否则就是事故。
4.2 日切任务的执行顺序
日切是整个系统的心跳,建议放在每天凌晨的低峰时段,按下面的顺序串行执行,每一步都要有独立的执行日志和失败告警。
首先执行判定补齐:扫描昨天所有状态为异常或缺失的检测任务,看看是否还有补采机会,有就补跑一次解析。这一步限定时长,比如最多跑 30 分钟,超时就直接进入下一步,不要为了等一两个失败任务拖住整个结算。
然后是结算生成:按客户、按关键词逐条读取昨日判定结果,结论为达标的写入待扣流水,状态置为“待确认”。
接着执行余额校验:检查客户账户余额是否足够覆盖待扣金额。余额不足的处理策略要在合同里写清楚,系统层面通常是冻结关键词、暂停检测、保留历史欠费,而不是直接不给检测——检测停了,客户想恢复的时候没有任何历史数据,反而更麻烦。
最后是出账归档:生成本期账单明细,写入对账表,发送通知。这一步完成后,该账期进入只读状态。
注意:日切任务必须做时区统一。服务器时区、数据库时区、业务约定时区三者不一致,会导致“某一天”到底是哪一天产生分歧,进而出现跨日重复计费或漏计费。统一用业务约定时区存日期字段,其他一律转成 UTC 存储时间戳。
4.3 冻结额度和余额扣减
余额扣减建议用两段式:先冻结、后实扣。判定完成的当天先把金额冻结在账户上,让可用余额减少但钱还在账上;等到账期关闭、没有争议了,再把冻结转为实扣。这样做的好处是,客户在争议期内看到的余额是“已扣除”的,心理预期稳定;同时服务方在争议处理完之前也没有真正划走资金,双方都有回旋余地。
两段式的状态机大概是:待结算 → 已冻结 → 已实扣;异常分支是 待结算 → 已冻结 → 已释放(判定被撤销)。每个状态变更都要打时间戳和操作来源,方便审计。
5. 反作弊与对账:这套系统最容易被钻的地方
5.1 客户端与代理端常见的几种手法
按天扣费这个模式,天生不对称:客户希望“扣得少”,服务方希望“扣得顺”,中间判定权在系统手里。于是双方都有动机去影响判定结果。
客户端常见的手法之一,是利用地域口径差异做文章。合同里写的是“全国”,系统实际只检测了几个核心城市,客户就会拿一个偏远地区的查询结果说“我这儿明明没排在前面”。应对办法是在合同附件里把检测口径固化成一张表:哪几个城市、什么设备、自然结果第几位以内算达标,一个不多一个不少,让口径可枚举、可复现。
另一种手法是时间点挑选。排名在一天内是有波动的,客户可能挑自己排名最好的那个时刻截图。所以系统的判定必须明确“以当日某一次或某几次检测的结论为准”,我一般建议以“当日固定时间点的检测结果”为准,并在快照里带上精确到秒的时间戳,这样双方的依据是同一个。
代理端(服务方)这边也有动机做手脚,比较典型的是模糊“首页”的定义。把推广位、聚合卡片、相关搜索都算进前十,等于变相降低达标门槛;还有把关键词偷偷换成同义的长尾词去检测,主词根本没排上去,账单上却显示达标。这两种在源码层面都能防:判定规则里强制区分结果类型字段,关键词 ID 与检测词做一对一绑定,任何替换都要留变更记录。
5.2 对账日报与异常告警阈值
对账不是月底做一次,而是每天都要做。我一般要求系统每天生成一张日报,包含几个关键指标,超出阈值就告警。
| 指标 | 含义 | 建议告警阈值 |
|---|---|---|
| 检测成功率 | 当日成功拿到正常页面的任务占比 | 低于 92% 告警 |
| 异常率波动 | 当日异常率与近 7 日均值之差 | 超过 10 个百分点告警 |
| 达标率突变 | 某客户当日达标率和前 3 日差 | 超过 30 个百分点告警 |
| 单客户单日扣费额 | 与近 30 日均值对比 | 超过 3 倍告警 |
| 冲正笔数 | 当日冲正记录数量 | 超过日均 5 倍告警 |
这张表看着简单,实际救过好几次火。有一次某地域的采集出口出了问题,返回的页面全是简化版,解析出来的排名普遍偏后,达标率一夜之间从 70% 掉到 20%。如果没这个告警,第二天客户就会看到一堆“不达标”的判定,虽然账面是少收了钱,但客户体验极差,会觉得系统根本不可靠。告警一响,立刻停掉该出口、标记那批结果为异常,重新检测,问题就消化在内部了。
6. 按天扣费靠谱吗:技术侧的三个不确定性
6.1 排名本身是波动的
回到那个原始疑问。从技术角度看,按天扣费最根本的问题在于:它把一个连续波动的量,切成了离散的二值判断。关键词排名在一天里可能上午在第八位、下午到第十二位、晚上又回到第七位,而系统只在一个时间点采样,用这一次采样代表整天。这在统计上是非常粗糙的。
粗糙本身不是错,错的是双方对这个粗糙程度没有共识。当客户把它理解成“我一整天都稳定在首页”,而系统实际只保证“某个时刻在首页”,落差就产生了。所以我会在方案里明确写一句:本方案以每日固定时点的单次检测结果作为当日判定依据,排名在日内存在正常波动。这句话写进去,能省掉后面一大半的扯皮。
6.2 检测口径差异带来的争议
第二个不确定性是口径。即使双方都同意“看自然结果前十”,仍然有大量细节没有覆盖:是否包含图片结果、是否包含视频卡片、是否包含问答模块、地域选省会还是具体到区、移动端是哪种屏幕尺寸。这些细节在不同设备上真的会产生不同结果。
我的处理办法是把口径写成配置而不是写死在代码里,并且把配置本身作为合同附件的一部分导出。系统里每个关键词都绑定一份口径配置,检测时严格按配置执行,配置的每次变更都记录操作人和时间。这样任何一次争议,先看配置,配置一致再看快照,争议就有终点。
6.3 关键词的商业价值不等于排名
第三个不确定性更本质:排名是手段,不是目的。一个词排到首页第一位,如果这个词本身没有商业搜索量,或者搜索意图和客户业务不匹配,那就是纯粹的无效支出。按天扣费按“天”计价,天然鼓励服务方去做那些竞争小、容易上位的词,因为成本低、达标率高,而客户真正想要的那些高价值大词,反而没人愿意承诺。
这就是为什么很多客户用了一段时间之后会有强烈的“被套路感”:账单上的关键词一长串,天天都达标,但业务咨询量毫无变化。问题不在系统,在计费模式和选词策略的错配上。
7. 一份更稳妥的计费设计方案
7.1 阶梯计费与保底条款
如果让我重新设计一套对双方都更公平的计费逻辑,我会把“按天二值扣费”改成“按天阶梯扣费”。具体来说,把排名区间切成几档:前三、前五、前十、前二十,每档对应不同的日单价,排名在当前档位就按该档单价扣,掉出约定范围就不扣。这样服务方在排名靠前时有更高收益,有动力去做真正的优化,而不是卡在第十位混天数;客户也能感受到“钱花得和效果挂钩”。
同时要加保底条款,比如承诺“月度达标天数不低于 20 天,否则按比例退还服务费”。保底条款逼着服务方在关键词选择上更谨慎,不敢随便承诺做不到的词。
7.2 结算周期与退出机制
结算周期建议按自然月,配合日冻结、月实扣。退出机制要写清楚三件事:提前多少天可以终止、终止后已冻结未实扣的部分怎么处理、历史数据能保留多久供查询。这三件事在合同里写明白,后面几乎不会产生纠纷;写得含糊,再好的系统也救不回来。
系统层面要支持“一键导出对账单”,包含每个关键词每天的判定结论、排名位置、快照链接、扣费金额。这份导出的文件,是商务沟通里最有说服力的东西。
8. 常见问题速查
8.1 典型故障与处理
下面这张表是我这些年实际遇到过、并且反复出现的故障,整理出来方便直接对照排查。
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 某天达标率骤降 | 采集出口返回简化页或验证页 | 抽查当天快照内容 | 标记异常、更换出口、重跑解析 |
| 排名普遍偏后几位 | 解析规则未排除插入卡片 | 用固定样本比对人工结果 | 修正解析规则、批量重跑 |
| 同一关键词同日多条扣费 | 幂等键缺失或索引失效 | 查唯一索引是否被删 | 补唯一索引、冲正多余记录 |
| 账单金额与明细对不上 | 冲正记录未纳入汇总口径 | 核对汇总 SQL 的条件 | 汇总时统一包含负金额记录 |
| 某地域任务长期失败 | 该地域出口不可用 | 查出口健康检查日志 | 暂停该地域、切换备用出口 |
| 客户余额足够却提示不足 | 冻结金额未释放 | 查冻结记录状态机 | 补一条释放记录、修正余额 |
| 跨日重复扣费 | 时区未统一 | 对比日期字段来源 | 统一按业务时区落日期 |
8.2 几个必须提前想清楚的问题
最后补充几个我在实际落地时踩过的坑,都是文档里通常不会写、但一定会遇到的。
补采窗口要设上限。无限制补采会让历史账期一直处于可变更状态,财务没法结账。我一般设 T+1 上午 12 点为补采截止,过了这个点,缺失的判定就按“不计费”处理,并在账单里明确标注原因。宁可少收一天,也不要让账期永远开着。
解析规则要有人工复核通道。系统判定和人工抽查必然会有分歧,需要留一个入口让人工把某条结果标记为“争议”,进入独立流程处理,而不是直接改数据库。所有人工干预都要留痕。
快照存储要有成本预案。关键词数量上去之后,存储增长是线性的,别等到磁盘告警才想起来做分层归档。我的做法是计费口径内的快照存 90 天,之后转冷存储再留半年,非计费口径的 7 天就清。
判定服务独立部署。解析规则改动频繁,如果和计费混在同一个服务里,每次发版都要停计费,风险太大。拆成独立服务,改规则只重启解析服务,计费完全无感。
我在实际做这类系统的体会是,技术难度其实集中在采集和判定这两端,计费逻辑本身反而是最简单的部分,但出问题最多、影响最大的偏偏是计费。原因不复杂:采集和判定的问题,最多是当天数据不准,第二天就能修正;计费一旦出错,动的是钱,牵扯的是信任。所以我的习惯是,凡是碰钱的路径,一律按最保守的方式写——能加校验就加校验,能留痕就留痕,能多一步确认就多一步确认。慢一点没关系,错一次就很难挽回了。