做站久了就会发现,光盯着 UV 和 PV 真没什么用——量再大,CPM 低到离谱,一天几千 UV 看着热闹,月底一算账连服务器钱都兜不住。后来我想明白一件事:广告联盟的点击率和转化数据才值钱。于是拿爬虫把这些数据扒下来,反向喂给本地挂机脚本,让它自己学着调整点击时段和频次,慢慢就摸出一套接近真实用户分布的流量节奏。

流量这东西,光有数字没用,得看质量。我见过一个站,每天 3000 个独立访客,看着挺猛,但全是机器刷的僵尸粉,联盟给出的出价直接被压到地板——不到 0.5 元/千次展示。这钱难挣得跟便秘一样,根源就一个:你只有访问量,没有含金量。

广告收益上不去?先看看你的流量质量

有时候真不是你广告位摆得不好,也不是单价低,是进来的那批人压根儿不是广告主想要的。新手最容易掉进去的坑,就是过分迷信点击率。CTR 红彤彤往上蹿就兴奋得不行,但后续转化跟不上,这种虚假繁荣分分钟触发风控。现在的反作弊算法早就不吃那套了,它看你用户的停留时长、页面浏览深度、行为轨迹连贯性,一样不对就判定无效流量。轻则扣款,重则直接封号,解释机会都不给你。

要把这笔账算明白,单纯追高点击率等于牺牲用户体验,盲目拉长曝光时间又会导致跳出率飙升。真正能打的都在死磕两个指标:点击通过率和转化效率之间的平衡点。下面这段逻辑推演,能帮你看清不同维度的数据怎么互相拉扯,最终影响收入:

def calculate_revenue(impressions, ctr, cvr, cpm_base):
    # 模拟真实环境下的收益波动模型
    clicks = impressions * ctr
    conversions = clicks * cvr
    # 考虑到流量质量权重对底价的影响因子
    quality_score = (ctr + cvr) / 2 # 简化的质量评分
    effective_cpm = cpm_base * (1 + quality_score * 0.5)
    return (impressions / 1000) * effective_cpm

# 场景对比:同样是3000 UV,高质量 vs 垃圾流量
print("Low Quality:", calculate_revenue(10000, 0.8, 0.01, 0.8)) # CTR极低且无转化
print("High Quality:", calculate_revenue(5000, 2.5, 0.15, 1.2)) # 精准人群带来的溢价

看懂这个公式你就明白了:为什么有些站长日均 UV 只有几千,活得比那些几万 UV 的大站还滋润。关键就是让每一次请求都带上真实的商业意图标签,而不是堆砌无效的数字。

Python web scraping code on screen

用 Python 爬虫采集点击率与转化数据,搭你自己的数据管道

光会算模型没用,得有真数据喂进去。联盟后台那张漂亮的报表曲线,导出来一看全是日汇总,小时级甚至分钟级的点击流?人家根本不想让你拿到这个粒度,一旦你掌握了,就能反向推算出他们的流量定价逻辑。

那就自己爬。

挑联盟,先看它给不给你这个脸

Google AdSense 算大方的,官方 API 能拉到小时级报告,但得申请 OAuth2 凭据,走一遍 Google Cloud Console 那套流程。国内联盟像百度联盟、穿山甲,能直接开放 API 的少之又少——要么只能导出 CSV,要么连接口都没有。但网页端总有一张报表页面吧?有页面就能爬。

我当时搞一个穿山甲的小站点,后台每天只给三个数字:昨日收益、展示数、点击数。想看每个广告位在一天不同时段的波动,只能自己写脚本模拟登录,每隔 30 分钟抓一次那三个数,存下来自己画曲线。土是土了点,管用。

爬虫设计的坑,一个比一个恶心

先说登录。联盟后台普遍走验证码加二次验证。验证码我试过打码平台,成功率大概 85%,但每隔三天就要重新扫码,因为 token 过期。二次验证更麻烦——我直接关掉手机动态码,改成固定备用码,配合 Selenium 模拟人工输入,笨是笨,但稳定跑了一个月没掉线。

反爬方面,联盟的防护级别比一般站点高一个量级。请求频率稍微密集一点,直接弹滑块验证。我的策略是:每次请求随机等 3 到 8 秒,加上随机 User-Agent 池,用 requests.Session 保持 Cookie。别上多线程,别碰异步,单线程慢慢磨,最不容易触发风控。我试过 aiohttp 并发抓 5 个页面,两分钟之后 IP 就被临时封了。

还有一个细节:有些联盟后台的点击率不是实时刷新的,有 15 分钟到 1 小时的延迟。爬得太勤会发现数值一点没变,白费力气。我踩过这个坑,后来在代码里加了个判断——连续三次抓到的 CTR 完全相同,就跳过这次写入,避免污染本地库。

清洗和存盘,比抓数据更磨人

原始页面拿到手,BeautifulSoup 解析出来的是带逗号和百分号的字符串,比如 "2,345""1.23%"。转成 float,得先去逗号、去百分号、除以 100。这一步看着简单,但联盟哪天改一版前端展示格式,小数点位数变了,清洗逻辑就得跟着改。

存储我选 SQLite,一张表结构长这样:

CREATE TABLE ad_hourly (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    fetch_time TEXT NOT NULL,          -- 采集时间,格式 2025-07-21 14:30:00
    ad_slot_id TEXT,                   -- 广告位 ID
    impressions INTEGER,
    clicks INTEGER,
    revenue REAL,
    ctr REAL,                          -- 已算好的浮点数
    cpm REAL                           -- 千次展示收入
);

每天凌晨跑一次脚本,把昨天逐小时数据从联盟后台爬下来插入这张表。周末再跑一次周汇总,按天聚合写入另一张 ad_daily 表。到了训练挂机脚本那一步,直接查 ad_hourly 就能拿到每个时段的历史 CTR 和转化变化,不用去翻原始日志。

下面这段代码是入门最小版本——用 requests 登录穿山甲后台(假设没有滑块),抓取某个广告位的今日数据,清洗写入 CSV。别指望它直接能跑通,每个联盟页面结构都不一样,但骨架就是这个:

import requests
from bs4 import BeautifulSoup
import csv
import time
from datetime import datetime

LOGIN_URL = "https://partner.pangle.com/login"
DATA_URL = "https://partner.pangle.com/report/hourly?ad_slot=xxxxx"

session = requests.Session()
# 模拟登录,这里假设你已经有 cookie
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
# 登录成功后,访问数据页
resp = session.get(DATA_URL)
soup = BeautifulSoup(resp.text, "html.parser")

# 解析表格,这里需要根据实际页面结构调整选择器
rows = soup.select("table.data-table tbody tr")
data_list = []
for row in rows:
    cols = row.find_all("td")
    if len(cols) < 5:
        continue
    hour = cols[0].text.strip()
    impressions = int(cols[1].text.replace(",", ""))
    clicks = int(cols[2].text.replace(",", ""))
    ctr = float(cols[3].text.replace("%", "")) / 100
    revenue = float(cols[4].text.replace("¥", ""))
    data_list.append([hour, impressions, clicks, ctr, revenue])
    time.sleep(2)  # 单线程慢爬

# 写入 CSV
with open("ad_data.csv", "a", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    for row in data_list:
        writer.writerow([datetime.now().isoformat()] + row)

这段代码运行时记得把 Cookie 提前从浏览器开发者工具里复制出来,手动塞进 session.headers。第一次跑完,打开 CSV 看一眼数据格式对不对,再去调后续清洗逻辑。

数据管道搭起来之后,你手里就有了最原始的「流量指纹」。下一步,就该轮到这些历史数据反过来教挂机脚本怎么装孙子、什么时候该露脸了。

automation script scheduling tasks

反向训练挂机脚本:让点击时段和频次自动适配真实流量分布

上一章把 CSV 拿到手,接下来就是把「什么时候该点、点几下」从玄学变成可执行的时间表。别小看这一步,广告联盟对「匀速傻瓜点击」几乎零容忍,你得让脚本看起来像真人——有时路过顺手点一下,有时盯着看半分钟再点,节假日和工作日节奏也不一样。

先把时间切成能看明白的片儿

打开 ad_data.csv,你会看到 hour、impressions、clicks、ctr、revenue 这些列。先做两件事:把时间解析成 datetime,然后按小时(或半小时)聚合。别急着画图,先算两个核心指标:每个时段的 CTR(clicks/impressions),以及「转化差」(当前时段 CTR - 全局平均 CTR)。正的表示比平时热,负的表示凉。

import pandas as pd
df = pd.read_csv("ad_data.csv")
df["ts"] = pd.to_datetime(df["hour"], format="%Y-%m-%d %H:%M:%S", errors="coerce")
df["hour"] = df["ts"].dt.hour
# 过滤异常行:曝光为0直接丢弃
df = df[df["impressions"] > 0].copy()
# 计算每小时 CTR
df["ctr"] = df["clicks"] / df["impressions"]
hourly = df.groupby("hour").agg(
    ctr_mean=("ctr", "mean"),
    ctr_std=("ctr", "std"),
    volume=("impressions", "sum")
).fillna(0)
# 与全局平均的差值,用于判断冷热
global_ctr = df["ctr"].mean()
hourly["ctr_diff"] = hourly["ctr_mean"] - global_ctr

有了这层切片,你就能看见一天里哪几个小时是热区,哪些是冷板凳。再加一个 volume(曝光总量)太小的时段别去凑热闹,样本不足容易把噪声当规律。

让脚本学会「挑时候」而不是瞎勤快

知道热区之后,控制逻辑就一句话:热区降低频率、提高停留;冷区减少动作甚至暂停。Python 这边最省心的是 schedule,配合 time 跑循环即可。下面给出可直接落地的骨架,记得把访问频率控制在真人区间:每次点击间隔建议 15~90 秒不等,页面停留 3~12 秒,在不同标签页之间切换时加一点随机抖动。

import schedule
import time
import random
from collections import deque

def click_with_human_delay():
    # 此处放你的点击逻辑(定位元素、滚动可视、等待渲染、模拟人类移动轨迹等)
    time.sleep(random.uniform(0.8, 2.5))  # 人类反应抖动
    # ... 执行点击 ...
    time.sleep(random.uniform(3, 12))   # 停留

def adjust_interval_by_hour():
    hr = int(time.strftime("%H"))
    # 读取你在数据分析阶段得到的阈值表(JSON/CSV均可)
    # hot_start, hot_end, base_interval, low_interval...
    if is_hot_hour(hr):
        interval = random.choice([15, 22, 30, 45])
    else:
        interval = random.choice([60, 75, 90])
    return interval

def job():
    click_with_human_delay()
    next_in_sec = adjust_interval_by_hour()
    schedule.run_pending()
    time.sleep(next_in_sec)

schedule.every().day.at("09:00").do(job)
while True:
    schedule.run_pending()
    time.sleep(1)

如果你需要多实例协同或者远程启停,挂一个 FastAPI 控制面会非常省事。启动命令就是 ,然后通过 /control 接口传 {"mode":"peak","speed":2}{"mode":"off"} 动态改策略,不用去服务器翻日志。

  • POST /click:触发一次带抖动的点击
  • GET /status:返回当前模式、下一次计划时间、最近错误计数
  • PATCH /control:修改运行模式与速度档位

算法侧最简单的做法是查表:小时 -> 期望 CTR 区间 -> 对应间隔与停留。复杂一点,就把 hourly 的 ctr_diff 映射成概率分布,用泊松过程生成点击时刻,让宏观上「看起来就是随缘」。别忘了周五晚上和工作日中午完全是两种生物钟,模型至少要区分工作日/周末两套参数集。

上线前先用历史回放做 A/B,再开闸

拿上周的数据回放:按你写的调度策略重放一遍,统计命中率、平均间隔、极端峰值有没有消失。如果原来凌晨三点还在咔咔点,现在能把 95% 的点击挪到热区,封号概率自然就下来了。真到线上也别一口气把频率拉满,先小流量试两天,看看联盟那边的反馈,再慢慢放开手脚。

多 IP 切换与防封策略:让脚本行为更像真实用户

脚本写好了,点击时机也对了,但你打开联盟后台一看——IP 固定、UA 万年不变的同一个 Chrome,上午九点到下午六点整整齐齐每小时点一次。这数据扔给任何一家反作弊系统,一查一个准。我第一版脚本上线跑了三天,两个号直接提示「异常流量操作」,收益清零。

防封不是玄学,是细节。你得让脚本看起来像个活人,而且最好是个作息规律的活人,不是住在服务器里的机器人。

先解决 IP:动态住宅代理是底线

机房 IP 一查就死,尤其是阿里云、腾讯云的出口 IP,联盟那边直接标红。试过免费代理池,爬个普通网页还行,但点击广告这种高敏感操作,免费代理存活率低得离谱,而且很多本身就是黑名单里的肉鸡。

花钱是最省事的方案。我用的是动态住宅代理,按流量计费,每次请求换一个 IP,目标看到的就是普通家庭宽带用户。选的时候注意两点:第一,代理池 IP 归属地要跟你的目标流量分布一致(比如主刷一线城市,就买一线城市 IP 占比高的套餐);第二,别买最便宜的,那些往往被滥用得很厉害,连 Google 验证码都过不去。

# 伪代码:每次点击前随机换IP
import requests
proxies_list = [
    "http://user:pass@res-proxy1.xxx.com:5000",
    "http://user:pass@res-proxy2.xxx.com:5000"
]
def get_random_proxy():
    return {"http": random.choice(proxies_list), "https": random.choice(proxies_list)}

def click_with_proxy(url):
    session = requests.Session()
    session.proxies = get_random_proxy()
    # 再加上下面的指纹伪装
    session.headers = generate_fake_headers()
    session.get(url)

代码里每次请求前重新取代理,还不行就加个重试逻辑:第一次失败换下一个 IP 重试,连续三次失败直接暂停脚本,等十分钟再试。别硬撑,硬撑就是封号。

浏览器指纹:光改 UA 不够,Canvas 和 WebGL 也要动

很多新手以为改个 User-Agent 就完事了。实际上对方用 fingerprintjs 这类库一抓,你屏幕分辨率、Canvas 指纹、WebGL 渲染器、音频上下文全是固定的,改一百个 UA 也没用。

我这边用的是 undetected-chromedriver 配合自定义修改。启动浏览器之前,把 navigator.webdriver 标志位干掉,Canvas 绘图加一点随机噪点,WebGL 的 vendor 和 renderer 改成常见值(比如 Intel Iris OpenGL Engine)。

options = uc.ChromeOptions()
options.add_argument('--disable-blink-features=AutomationControlled')
# 更多配置:随机窗口尺寸
width = random.choice([1366, 1440, 1920])
height = random.choice([768, 900, 1080])
options.add_argument(f'--window-size={width},{height}')
driver = uc.Chrome(options=options)
# 注入JS覆盖指纹
driver.execute_script("""
    Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
    // Canvas噪点:在随机坐标画一个极淡的像素
    var original_getImageData = CanvasRenderingContext2D.prototype.getImageData;
    CanvasRenderingContext2D.prototype.getImageData = function(x, y, w, h) {
        var imageData = original_getImageData.call(this, x, y, w, h);
        imageData.data[0] += Math.floor(Math.random() * 2);
        return imageData;
    };
""")

别小看这一行 Canvas 噪点,很多指纹识别系统就是靠精确的 Canvas 哈希值来判断设备是否重复。你每次启动都换一个随机噪点,指纹就变了。另外,语言、时区、字体列表也要跟代理 IP 的地区一致——你用的北京 IP,浏览器语言却是英文美国,这就不太合理了。

行为模拟:鼠标不是瞬移的,页面也不是秒开的

点击操作最容易被抓的特征就是:鼠标 cursor 从屏幕外瞬移到按钮正中央,然后秒点。真实用户不会这样。鼠标轨迹要有加速度曲线,从屏幕边缘快速移进目标区域,然后慢下来微调,最后点下去。用 pyautogui 或者 selenium 的 ActionChains 做都很简单,关键是你得加随机偏移。

页面滚动也一样。不能打开页面就滚到底,要模拟人眼的阅读节奏:先停两秒,慢慢滚动,偶尔回滚看上一段,在广告附近停留的时间比别处长一点。这些参数我全都写进配置文件里,按小时微调——凌晨时段滚动速度放慢 30%,因为用户半夜本来就看得慢。

def human_scroll(driver, target_y, speed=0.3):
    current_y = 0
    while current_y < target_y:
        step = random.randint(40, 120)  # 每次滚动距离随机
        current_y += step
        driver.execute_script(f"window.scrollTo(0, {current_y})")
        time.sleep(random.uniform(0.1, 0.4) + speed)  # 加随机延时
    # 最后再小幅度回滚一下,像在确认内容
    driver.execute_script(f"window.scrollTo(0, {target_y - random.randint(10, 30)})")
    time.sleep(random.uniform(0.8, 1.5))

点击热区也要覆盖页面上其他非广告元素。一个只点广告从不点文章链接的脚本,跟一个偶尔点个「关于我们」、翻翻评论区、甚至误点一下空白区域的脚本,哪个更像真人?显然是后者。我每天会在随机时间段往脚本里塞几个「无效操作」——点一下页脚链接、打开侧边栏、关闭一个弹窗,反正别太「精准」。

风险监控:让脚本自己知道什么时候该停

最怕的事不是被检测,而是被检测了还在跑。我一开始没做监控,周末出去玩了两天,回来发现脚本被封了六个号,其中一个还因为点击频率太高被联盟拉进了黑名单,连带我的站长账号都收到了警告邮件。

现在我的做法是在脚本里嵌入一个轻量的巡检线程,每次点击后都检查联盟后台的收益是否异常:如果连续三次点击后收益增长为零,或者点击率突然飙升到平时的两倍以上,就立刻暂停脚本并发送钉钉通知。说的直白点,宁可少赚一天,也别让号死透。

def check_anomaly(last_hour_ctr, current_hour_ctr):
    # 如果当前小时点击率比上一小时高出50%且收益没变,触发告警
    if current_hour_ctr > last_hour_ctr * 1.5 and revenue_no_change():
        send_alert("点击率异常飙升,已暂停脚本")
        os._exit(0)  # 直接终止进程

阈值设得保守一点。刚开始我设的是超出两倍才告警,结果一个号被警告了才反应过来,亏了整整六小时的流量。后来改成一倍半就停,宁可误报不要漏报。误报顶多浪费半天时间手动重启,漏报就是号没了。

说到底,防封这件事没有银弹。你只能把每个细节都做到位:代理、指纹、行为、监控,四条腿走路,少一条都容易摔。联盟的反作弊团队也在进化,你脚本跑得越久,越需要不断调整参数跟上节奏。这不是一次性的事,是场持久战。

从数据到收益:持续优化闭环与常见坑

走到这一步,我们已经把「像人」的动作拆得七七八八:随机滚动、误点、延时、指纹伪装,外加一个随时刹车的巡检线程。剩下的事就简单了——让数据告诉你下一步怎么调,而不是靠拍脑袋。

A/B 测试别停

我把脚本按时段切成两条线:一条跑早上 8-11 点,另一条守晚上 7-10 点,各挂 50 个 IP。跑了一周,晚高峰的 eCPM 高出 9%,但转化率掉 2%。原因很简单:晚上抢量的人多,广告主出价高;可用户耐心差,落地页跳失也快。于是我把晚间点击频次降 15%,保留高价位,再把凌晨 2-4 点的低质流量切掉。第二周整体收益涨了 6.8%,没动过一行核心代码,只是改了调度表。

CELERY_BEAT_SCHEDULE = {
    'night-mode': {
        'task': 'tasks.browse',
        'schedule': crontab(hour='20-22'),
        'args': [{'freq': 0.9, 'delay_range': (1.2, 2.5)}],
    },
    'morning-mode': {
        'task': 'tasks.browse',
        'schedule': crontab(hour='8-10'),
        'args': [{'freq': 1.0, 'delay_range': (0.8, 1.5)}],
    }
}

每周末一次数据复盘

周日晚上导出 Scrapy 捞回来的点击日志,再跟联盟后台的转化报表扔进 Jupyter Notebook 里对一下——先瞄小时级点击率还有没有蹲在 1.2% 那条线附近,再扫一眼转化漏斗有没有哪个环节突然塌了。要是翻到某个 IP 段连续两天点击率飙高 30% 却一个转化都没,下周直接把它权重拉到底,顺手丢进黑名单。前前后后十分钟的事,比市面上那些玄学养号靠谱多了。

坑你别踩

  • 不要追求极致点击率。高于行业 120% 就会被盯上,宁可留 20% 冗余给系统波动。
  • 代理池记得轮换。同一个出口 IP 一天内请求超过 500 次,再怎么模拟人类也白搭。我用 ProxyBroker 每小时自动换一批,顺手把弱网节点剔除。

这活儿跟养鱼有点像——你给脚本喂的数据越真实,它游得就越稳。别指望一步登天,从一条数据管道慢慢调,总能找到那个平衡点。