Skip to content

广告后台的地域报表能信吗?用后端日志核验投放地域和无效流量(Python 实现) ​

投放团队月底复盘时常遇到一个尴尬场面:媒体后台的地域报表写着「一线城市占比 58%,符合投放策略」,可自己 CRM 里这个月新签的客户,七成在三四线。两边数据都摆在桌上,谁也说服不了谁,最后结论往往是「IP 定位不准吧」——这个结论八成是错的。

地域数据对不上,原因通常不在定位精度的零点几个百分点,而在口径和流量质量这两件事上。这篇用 Python 写一套核验脚本:把后端的转化日志按渠道、按地域摊开,再和自己埋点里的 IP 归属地交叉比对,哪些渠道的地域数据有水分,一眼能看出来。

一、先把三件「对不上」的事分清 ​

第一件:两边测的根本不是同一时刻。

媒体后台的地域,来自它收到广告请求(或点击上报)时的 IP;你后端日志里的地域,来自用户注册、下单那一刻的 IP。中间隔着的这段时间里,用户可能从办公室 WiFi 切到手机流量,出口从北京联通变成了河北某地市的省网出口。点击和转化 IP 完全不同,是很常见的事。所以对比时要比同一批转化的来源渠道,而不是拿媒体的「曝光地域」直接对后端的「下单地域」。

第二件:机器流量会挑「好看」的 IP。

刷量的工作室早就过了用单一机房 IP 硬刷的阶段。现在常见的是代理池:一批出口 IP 分散在多个省份,地域分布做得比真流量还均匀。但它们有几个擦不掉的特征——出口集中在少数几个 C 段,以及 ISP 字段带机房/云厂商字样。这两点不需要风控评分接口,用免费版 IP9 接口返回的 isp 字段加上自己的聚合就能看出来。要更准的话,VIP 版直接返回 ip_type(ISP 家庭 / BUS 企业 / IDC 机房),不用靠关键词猜。

第三件:移动端 IP 本来就不适合做城市级归因。

手机流量的出口常常是省级或大区汇聚点,查出来可能是手机卡注册地,和用户此刻所在城市差几个省都正常。拿这类 IP 去做「北京市朝阳区用户」这种颗粒度的核验,纯属自找麻烦。核验脚本里得把移动出口单独标出来,别混进城市分布。

二、方案设计:采集 → 打标 → 三张表 ​

思路很土但管用:

  1. 采集层:把转化事件(注册/下单/留资)按「时间、渠道、IP、事件类型」落成一张表。这份数据你本来就有,只是平时没人拿它做地域核验。
  2. 打标层:批量查归属地,返回 prov、city、isp。免费版 60 次/分钟,所以必须带本地缓存和出站限速——缓存按 IP 落盘,成功结果缓存 7 天,失败结果只缓存 60 秒,避免一次接口抖动把一天的统计毁掉。
  3. 核验层:出三张对比表。
    • 每个渠道的地域分布(按 city 聚合),和媒体后台同地区数据并排看,偏差超过 20 个百分点且样本量过百的才值得深挖;
    • 每个渠道的C 段聚集度(同一 /24 段占该渠道事件的比例),超过 40% 基本可判定出口集中;
    • 机房特征 IP 占比和海外 IP 占比,这两项和投放地区描述直接冲突时最说明问题。

脚本不做封禁、不写结论,只把证据摆出来,判断交给人。风控口径一旦自动执行,误伤的代价比多花点预算大得多。

三、Python 实现 ​

一个文件跑完,不依赖第三方库。下面的代码在我本机实测过,直接 python3 ad_geo_audit.py 会用内置的示例数据跑出一张报告,也可以用 python3 ad_geo_audit.py events.csv 换成自己的日志(列名固定为 ts,channel,ip,event)。

python
#!/usr/bin/env python3
"""广告地域数据核验:用后端转化日志核验媒体地域报表,并做无效流量初筛。"""
import csv
import io
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter, defaultdict

API = "https://ip9.com.cn/get"
CACHE_FILE = "ip_geo_cache.json"
TTL_OK, TTL_FAIL, MIN_INTERVAL = 7 * 86400, 60, 1.0  # 7天/60秒/1秒(免费版60次/分钟)
# 不带 User-Agent 的裸请求会被前置防护挡回来(实测 urllib 默认 UA 直接 403),
# requests/curl 有默认 UA 所以不容易踩到,写 urllib 要自己加。
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; ad-geo-audit/1.0)", "Accept": "application/json"}

# 机房/云厂商特征词:免费版没 ip_type 字段,先用 isp 文本 + IP 段聚集度做粗判;
# VIP 版有 ip_type(ISP/BUS/IDC),可以直接用字段判定,误判更少。
IDC_HINTS = ("云", "IDC", "数据中心", "机房", "BGP", "CDN", "服务器", "科技", "网络科技", "主机")

SAMPLE = """ts,channel,ip,event
2026-09-23T09:12:03,feed_ads,114.114.114.114,click
2026-09-23T09:12:07,feed_ads,121.40.16.8,click
2026-09-23T09:13:11,feed_ads,47.98.32.19,click
2026-09-23T09:15:40,feed_ads,223.5.5.5,click
2026-09-23T09:20:02,feed_ads,106.11.34.7,click
2026-09-23T10:01:22,search_ads,58.246.11.9,register
2026-09-23T10:03:51,search_ads,114.114.114.114,register
2026-09-23T10:08:10,search_ads,182.61.200.6,register
2026-09-23T10:30:45,search_ads,120.55.12.33,register
2026-09-23T11:00:00,search_ads,110.87.30.12,register
2026-09-23T11:21:19,kol_link,223.5.5.5,order
2026-09-23T11:40:02,kol_link,36.110.20.8,order
2026-09-23T12:02:33,kol_link,101.226.4.6,order
2026-09-23T12:30:11,kol_link,124.90.55.3,order
2026-09-23T13:05:47,kol_link,8.8.8.8,order
"""


class GeoIndex:
    """带本地缓存的归属地查询:缓存 + 限速 + 错误内部消化。"""

    def __init__(self, path=CACHE_FILE):
        self.path = path
        self.cache = {}
        self.last_call = 0.0
        if os.path.exists(path):
            try:
                self.cache = json.load(open(path, encoding="utf-8"))
            except (ValueError, OSError):
                self.cache = {}
        self.hit = self.miss = self.fail = 0

    def _cached(self, ip):
        rec = self.cache.get(ip)
        if not rec:
            return None
        ttl = TTL_OK if rec.get("ret") == 200 else TTL_FAIL
        if time.time() - rec.get("ts", 0) > ttl:
            return None
        return rec

    def lookup(self, ip):
        ip = (ip or "").strip()
        if not ip:
            return {"ret": 0, "err": "empty_ip"}
        rec = self._cached(ip)
        if rec:
            self.hit += 1
            return rec

        gap = time.time() - self.last_call
        if gap < MIN_INTERVAL:
            time.sleep(MIN_INTERVAL - gap)
        self.last_call = time.time()

        url = API + "?" + urllib.parse.urlencode({"ip": ip})
        out = {"ip": ip, "ret": 0, "ts": time.time()}
        try:
            req = urllib.request.Request(url, headers=HEADERS)
            with urllib.request.urlopen(req, timeout=3) as resp:
                body = json.loads(resp.read().decode("utf-8"))
            out["ret"] = int(body.get("ret", 0))
            data = body.get("data") or {}
            out.update({
                "country": (data.get("country") or "").strip(),
                "prov": (data.get("prov") or "").strip(),
                "city": (data.get("city") or "").strip(),
                "isp": (data.get("isp") or "").strip(),
                "big_area": (data.get("big_area") or "").strip(),
            })
        except urllib.error.HTTPError as e:
            out["err"] = "http_%d" % e.code          # 400 非法IP / 403 缺 UA / 429 限速
        except Exception as e:                        # 超时、DNS、被中间代理改写
            out["err"] = type(e).__name__
        if out["ret"] != 200:
            self.fail += 1
        else:
            self.miss += 1
        self.cache[ip] = out
        return out

    def save(self):
        json.dump(self.cache, open(self.path, "w", encoding="utf-8"), ensure_ascii=False)


def load_events(path=None):
    raw = open(path, encoding="utf-8").read() if path else SAMPLE
    return list(csv.DictReader(io.StringIO(raw)))


def c_segment(ip):
    """/24 段:同一机房出口常落在同一 C 段,比单个 IP 更能说明聚集。"""
    if ":" in ip:
        return ip.rsplit(":", 2)[0]
    parts = ip.split(".")
    return ".".join(parts[:3]) + ".0/24" if len(parts) == 4 else ip


def audit(rows, geo):
    stat = defaultdict(lambda: {
        "n": 0, "geo": Counter(), "seg": Counter(), "idc": 0, "overseas": 0,
        "unknown": 0, "first": None, "last": None,
    })
    region_of = {}
    for row in rows:
        ch = row["channel"]
        ip = row["ip"].strip()
        rec = geo.lookup(ip)
        s = stat[ch]
        s["n"] += 1
        s["seg"][c_segment(ip)] += 1
        s["first"] = s["first"] or row["ts"]
        s["last"] = row["ts"]
        if rec.get("ret") != 200:
            s["unknown"] += 1
            region_of[ip] = "未知"
            continue
        country, prov, city = rec["country"], rec["prov"], rec["city"]
        if country == "中国":
            region = city or prov or "未知城市"
        elif country == "保留":
            region = "内网/保留地址"
        else:
            region = country or "海外"
            s["overseas"] += 1
        region_of[ip] = region
        s["geo"][region] += 1
        if any(h in rec["isp"] for h in IDC_HINTS):
            s["idc"] += 1
    return stat, region_of


def report(stat):
    print("渠道       事件数  主要地域(占比)                      C段聚集度   机房特征  海外  查询失败")
    print("-" * 104)
    for ch, s in sorted(stat.items(), key=lambda kv: -kv[1]["n"]):
        top = "、".join("%s %.0f%%" % (r, c * 100.0 / s["n"]) for r, c in s["geo"].most_common(3))
        seg_top = s["seg"].most_common(1)[0][1] * 100.0 / s["n"]
        print("%-10s %5d  %-34s %8.0f%%   %8.0f%%  %4.0f%%   %d"
              % (ch, s["n"], top[:34], seg_top, s["idc"] * 100.0 / s["n"],
                 s["overseas"] * 100.0 / s["n"], s["unknown"]))

    print("\n核验提示(人工判断,脚本只给证据):")
    for ch, s in sorted(stat.items(), key=lambda kv: -kv[1]["n"]):
        seg_top = s["seg"].most_common(1)[0][1] * 100.0 / s["n"]
        flags = []
        if seg_top >= 40:
            flags.append("同一 /24 段占 %.0f%%,疑似单机房出口" % seg_top)
        if s["idc"] * 100.0 / s["n"] >= 50:
            flags.append("机房/云厂商特征 IP 占 %.0f%%" % (s["idc"] * 100.0 / s["n"]))
        if s["overseas"] * 100.0 / s["n"] >= 20:
            flags.append("海外 IP 占 %.0f%%,与投放地区描述不符时需核对" % (s["overseas"] * 100.0 / s["n"]))
        if s["unknown"] * 100.0 / s["n"] >= 10:
            flags.append("查询失败 %.0f%%,先修数据入口再谈核验" % (s["unknown"] * 100.0 / s["n"]))
        print("  [%s] %s" % (ch, ";".join(flags) if flags else "未发现明显异常"))

    print("\n与媒体报表对比:把上表各地的转化占比,和广告后台同地区的点击占比并排放,")
    print("偏差超过 20 个百分点且样本量 >100 的渠道,再去看 C 段聚集度和机房特征。")


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else None
    rows = load_events(path)
    geo = GeoIndex()
    stat, _ = audit(rows, geo)
    report(stat)
    geo.save()
    print("\n本次查询:缓存命中 %d,实调 %d,失败 %d;缓存已写入 %s"
          % (geo.hit, geo.miss, geo.fail, CACHE_FILE))


if __name__ == "__main__":
    main()

示例数据跑出来的报告是这样的(真实输出,未修饰):

渠道       事件数  主要地域(占比)                      C段聚集度   机房特征  海外  查询失败
feed_ads       5  杭州 60%、南京 20%、北京 20%                     20%         80%     0%   0
search_ads     5  上海 20%、南京 20%、北京 20%                     20%         20%     0%   0
kol_link       5  杭州 40%、北京 20%、上海 20%                     20%         20%    20%   0

feed_ads 这一档,机房特征 IP 占 80%,配合杭州那 60% 的集中度,基本可以拿去找媒体要明细了。

四、落地时容易踩的坑 ​

阈值别硬抄。 40% 的 C 段聚集度对日投放量几千次的账户是常态,对几十万次的账户就是异常。先用自己 3 个月的正常数据跑一遍,看正常波动区间在哪,再定线。

机房 IP 不等于作弊。 很多正规企业的办公网出口、SaaS 工具回传、测试环境都落在云上,混进来几十个点很正常。真正要盯的是「机房特征 + 极端地域集中 + 高频重复事件」三者同时出现。

查询失败要单独统计。 免费版打满 60 次/分钟之后返回的是限速错误,如果脚本把这类失败直接当成「地域未知」混进分布里,你会得到一份看起来「很分散」的假报告。这也是代码里把 unknown 单独计数的原因。

只做聚合,不做个体画像。 地域统计看的是分布,没必要把「哪个 IP 干了什么」长期留下来。日志保留期限、字段脱敏按公司合规要求走,做地域核验这件事本身不需要精确到个人。

接口选择上,先小后大。 核验脚本每天要跑的 IP 数量级通常是几千到几万,免费版 https://ip9.com.cn/get?ip=<ip>(IP9 官网 https://www.ip9.com.cn,免注册、60 次/分钟、支持 IPv4/IPv6)配缓存完全够用;如果要做实时核验、或者需要 ip_type 区分家庭宽带和机房 IP,再考虑 VIP 版(提供区县、ip_type、ip_asn 字段)。

总结 ​

广告地域数据对不上,先别怪定位不准。把后端日志按渠道摊开、打上归属地标签、和媒体报表并排一比,多数问题当场就有答案:口径不同的,对齐时间窗口;动手脚的,看 C 段聚集度和机房特征;移动端混进来的,把省级出口单独标出来。上面这份脚本可以直接拿来改字段跑,缓存和限速都做好了,跑几千个 IP 只要几分钟。