Appearance
招投标系统怎么用 IP 识别围标串标?投标文件制作 IP 的聚合预警(Python 实现)
公共资源交易平台的运营方和做电子招投标 SaaS 的团队,几乎都会被同一个问题找上门:「这几家投标人的文件制作 IP 一模一样,是不是串标?」
这个问题不好答。IP 相同确实是最常见的一条线索,但只把 IP 拿出来做字符串比对,往往会掉进两个坑:一是把「同一家代理公司给三家投标人做标书」这种合规情形判成串标;二是三家公司用了同一个云桌面、同一个网吧、或者同一个地市运营商的出口,也会被判成串标,投诉和质疑很快就来了。法院的判例态度也很明确——IP 一致是线索,不是定性结论。
所以真正能用的做法是:先把 IP 做属地化(拿到省市、运营商),再在项目维度上做聚合分析,输出「有理由的怀疑」和可复核的证据描述,最后交给人工判断。这篇文章给一套 Python 实现,输入是投标记录表,输出是按项目排好序的预警报告。
一、招投标场景里,哪些 IP 特征值得怀疑
先说清楚判断依据,再看代码。围标串标在 IP 层面留下的痕迹,大致有这几类,可靠度从高到低:
同一 IP 出现在多家投标人身上。 最硬的一条。不同公司用同一台机器(或者同一个出口)制作并上传标书,这本身就说明它们共享了制作环境。要注意的是,如果这个 IP 是招标代理机构所在地或者公共资源交易中心的便民服务终端,那就属于正常——这类出口 IP 要建白名单。
同一 /24 网段下的多家投标人。 同一 C 段通常意味着同一个机房、同一条专线、同一个小区宽带。它比「IP 完全相同」弱,但比「同城」强得多。云桌面厂商的出口往往就是若干个 C 段。
同一 IP 的提交时间高度接近。 三家公司在半小时内用同一个 IP 上传标书,这个组合几乎没法解释成巧合;如果是同一天但间隔好几小时,可信度就降一档。
多家投标人的制作 IP 都落在机房/云出口。 用云服务器或云桌面做标书,本身不违规(有些投标人就习惯在云上办公),但「多家投标人 + 同一城市的机房出口」这个组合值得提示核验——真实的黑产链条里,代做标书的工作室就是这么干的。
而下面这些是正常现象,规则不能一刀切:
- 投标人在异地出差,用酒店 WiFi 上传标书,IP 属地和他公司注册地完全不一致。
- 公司用运营商专线,出口 IP 显示为省会城市,实际公司在下面的地级市。
- 用 VPN 或者企业零信任网络上传,出口显示成总部城市。
- 招标代理机构统一提供标书上传服务,多家投标人的 IP 天然相同。
二、方案设计
整体就四步,没必要上复杂的架构:
投标记录(项目、投标人、制作IP、提交时间)
→ 清洗:剥端口、去空格、区内网地址
→ 属地化:SQLite 缓存 + 令牌桶限速,失败返回空而不是抛异常
→ 聚合:按项目分组,跑 4 条规则,累加风险分
→ 输出:按分数排序的预警报告(控制台 + JSON 留档)三个设计上的取舍:
属地化必须带缓存和限速。 一个交易平台一年也就几万个投标 IP,重复率极高(代理机构、云桌面、地市运营商出口都是反复出现),缓存 30 天能把接口调用量压到原始调用的一成左右。限速用令牌桶压在 55 次/分钟,避免触发免费版 60 次/分钟的上限。
接口失败不能中断批处理。 一条 IP 查不到(格式不对、被限速、网络抖动)就让整份报告挂掉,这在生产里是不可接受的。所以解析器统一返回 None,规则层把「属地为空」当成「本次没有位置信号」,只降权不报错。
输出的是证据描述,不是结论。 每条预警都要能直接读给评标委员会或者监管部门听——所以证据里必须带 IP、属地、涉及哪几家投标人、时间差多少分钟。原始接口返回也要落库留存,后面真要走质疑/投诉流程,这些就是原始记录。
三、Python 实现
下面这份脚本只依赖标准库(连 requests 都不用装),把 SAMPLE 换成自己的 CSV 就能跑。分三个部分:限速器、属地解析器、规则引擎。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""招投标场景:投标文件制作 IP 的属地化 + 聚合预警"""
import csv
import ipaddress
import json
import sqlite3
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime
IP9_API = "https://ip9.com.cn/get"
GEO_TTL = 30 * 86400 # 投标 IP 复用率高,缓存 30 天
API_TIMEOUT = 3.0
RATE_PER_MIN = 55 # 免费版 60 次/分钟,留余量
IDC_HINTS = ("云", "计算", "数据中心", "IDC", "机房", "CDN")
class TokenBucket:
def __init__(self, rate_per_min):
self.capacity = float(rate_per_min)
self.rate = rate_per_min / 60.0
self.tokens = float(rate_per_min)
self.ts = time.monotonic()
self.lock = threading.Lock()
def take(self):
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.rate
time.sleep(min(wait, 0.5))
class GeoResolver:
"""IP → 属地:SQLite 缓存 + 令牌桶限速,查不到返回 None"""
def __init__(self, db_path="tender.db"):
self.db = sqlite3.connect(db_path, check_same_thread=False)
self.db.execute("CREATE TABLE IF NOT EXISTS ip_geo (ip TEXT PRIMARY KEY, payload TEXT, ts INTEGER)")
self.db.commit()
self.bucket = TokenBucket(RATE_PER_MIN)
self.api_calls = 0
self.errors = defaultdict(int)
def lookup(self, ip):
row = self.db.execute("SELECT payload, ts FROM ip_geo WHERE ip=?", (ip,)).fetchone()
if row and time.time() - row[1] < GEO_TTL:
return json.loads(row[0])
self.bucket.take()
self.api_calls += 1
url = IP9_API + "?ip=" + urllib.parse.quote(ip)
try:
req = urllib.request.Request(url, headers={"User-Agent": "tender-risk/1.0"})
with urllib.request.urlopen(req, timeout=API_TIMEOUT) as resp:
body = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
self.errors["http_%d" % e.code] += 1 # 400/403/429
return None
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError):
self.errors["network"] += 1
return None
if body.get("ret") != 200:
self.errors["ret_%s" % body.get("ret")] += 1
return None
data = body.get("data") or {}
geo = {
"country": data.get("country") or "",
"prov": data.get("prov") or "",
"city": data.get("city") or "",
"isp": data.get("isp") or "",
"lat": data.get("lat") or "",
"lng": data.get("lng") or "",
"big_area": data.get("big_area") or "",
"raw": data, # 原始响应留证
"queried_at": int(time.time()),
}
if not geo["city"]:
self.errors["empty_location"] += 1 # 内网/保留地址、海外 IP
self.db.execute("INSERT OR REPLACE INTO ip_geo VALUES (?,?,?)",
(ip, json.dumps(geo, ensure_ascii=False), int(time.time())))
self.db.commit()
return geo
def net24(ip):
"""取 /24 网段;IPv6 取 /48。拿不到就返回 None"""
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return None
prefix = 24 if addr.version == 4 else 48
return str(ipaddress.ip_network("%s/%d" % (ip, prefix), strict=False))
def parse_ts(s):
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return int(datetime.strptime(s, fmt).timestamp())
except ValueError:
continue
return None
def analyze_project(project_id, rows):
"""rows: [{bidder, ip, ts, geo}],返回该项目的预警结果"""
score, evidence = 0, []
# 规则 1:同一 IP 出现在多家投标人(最硬的一条)
by_ip = defaultdict(set)
for r in rows:
by_ip[r["ip"]].add(r["bidder"])
shared_ip = {ip: b for ip, b in by_ip.items() if len(b) >= 2}
for ip, bidders in shared_ip.items():
score += 45
g = next(r["geo"] for r in rows if r["ip"] == ip) or {}
where = "%s%s" % (g.get("prov", ""), g.get("city", "")) or "属地未知"
evidence.append("IP %s(%s / %s)被 %d 家投标人共用:%s"
% (ip, where, g.get("isp", "-"), len(bidders), "、".join(sorted(bidders))))
# 规则 2:同一 /24 网段(同机房出口的强特征)
by_net = defaultdict(set)
for r in rows:
n = net24(r["ip"])
if n:
by_net[n].add(r["bidder"])
for n, bidders in ({k: v for k, v in by_net.items() if len(v) >= 2}).items():
if any(r["ip"] in shared_ip for r in rows if net24(r["ip"]) == n):
continue # 已被规则 1 覆盖,不重复计分
score += 25
evidence.append("同网段 %s 下 %d 家投标人:%s" % (n, len(bidders), "、".join(sorted(bidders))))
# 规则 3:同一出口 IP 提交时间高度接近(30 分钟内)
by_ip_rows = defaultdict(list)
for r in rows:
by_ip_rows[r["ip"]].append(r)
for ip, rs in by_ip_rows.items():
rs.sort(key=lambda x: x["ts"])
for a, b in zip(rs, rs[1:]):
if b["bidder"] != a["bidder"] and b["ts"] - a["ts"] <= 1800:
score += 20
evidence.append("同一 IP %s 在 %d 分钟内有 %s、%s 两家提交标书"
% (ip, (b["ts"] - a["ts"]) // 60, a["bidder"], b["bidder"]))
# 规则 4:多家投标人的制作 IP 落在同一城市的机房/云出口(弱信号)
idc_cities = defaultdict(set)
for r in rows:
g = r["geo"] or {}
if any(h in (g.get("isp") or "") for h in IDC_HINTS):
idc_cities[g.get("city", "-")].add(r["bidder"])
for city, bidders in idc_cities.items():
if len(bidders) >= 2:
score += 10
evidence.append("有 %d 家投标人的制作 IP 落在 %s 的机房/云出口(需核验是否使用代理或云桌面)"
% (len(bidders), city))
return {"project_id": project_id, "score": min(score, 100),
"level": "high" if score >= 60 else ("medium" if score >= 30 else "low"),
"bidders": sorted({r["bidder"] for r in rows}), "evidence": evidence}主体逻辑到这里就完整了。跑起来的方式是把投标记录读进来逐条属地化,再按项目聚合:
python
# 样表:真实可查的 IP,模拟「同一项目里两家用了同一个出口 IP」
SAMPLE = """project_id,bidder,doc_ip,submit_time
2026-JS-018,甲建设有限公司,114.114.114.114,2026-09-16 09:10:00
2026-JS-018,乙工程有限公司,114.114.114.114,2026-09-16 09:22:00
2026-JS-018,丙建筑公司,119.29.29.29,2026-09-16 09:31:00
2026-JS-018,丁安装公司,202.96.128.86,2026-09-16 14:02:00
2026-JS-019,戊装饰公司,8.8.8.8,2026-09-16 10:00:00
"""
def main():
import io
reader = csv.DictReader(io.StringIO(SAMPLE))
resolver = GeoResolver()
projects = defaultdict(list)
for row in reader:
ip = row["doc_ip"].strip().split(":")[0] # 关键:先剥掉端口
geo = resolver.lookup(ip)
projects[row["project_id"]].append({
"bidder": row["bidder"].strip(), "ip": ip,
"ts": parse_ts(row["submit_time"].strip()) or int(time.time()), "geo": geo})
g = geo or {}
print("属地化 %-16s → %s%s / %s" % (ip, g.get("prov", ""), g.get("city", ""), g.get("isp", "-")))
reports = [analyze_project(pid, rows) for pid, rows in projects.items()]
reports.sort(key=lambda r: -r["score"])
with open("risk_report.json", "w", encoding="utf-8") as f:
json.dump(reports, f, ensure_ascii=False, indent=2)
print("\n===== 预警报告 =====")
for r in reports:
print("[%s] %s 风险分 %d,投标人:%s" % (r["level"].upper(), r["project_id"], r["score"],
"、".join(r["bidders"])))
for e in r["evidence"]:
print(" · " + e)
print("\n接口调用 %d 次,异常统计:%s" % (resolver.api_calls, dict(resolver.errors)))
if __name__ == "__main__":
main()用一份构造的样表(同一项目里甲、乙两家用同一个 IP,间隔 12 分钟;各家 IP 都是真实可查的地址)跑一遍,实际输出是这样:
属地化 114.114.114.114 → 江苏南京 / 114DNS
属地化 114.114.114.114 → 江苏南京 / 114DNS
属地化 119.29.29.29 → 广东广州 / 腾讯云/DNSPod DNS+
属地化 202.96.128.86 → 广东广州 / 中国电信/DNS
属地化 8.8.8.8 → / Google Cloud
===== 预警报告 =====
[HIGH] 2026-JS-018 风险分 65,投标人:丁安装公司、丙建筑公司、乙工程有限公司、甲建设公司
· IP 114.114.114.114(江苏南京 / 114DNS)被 2 家投标人共用:乙工程有限公司、甲建设有限公司
· 同一 IP 114.114.114.114 在 12 分钟内有 甲建设有限公司、乙工程有限公司 两家提交标书
[LOW] 2026-JS-019 风险分 0,投标人:戊装饰公司
接口调用 4 次,异常统计:{'empty_location': 1}第二条记录用的是 8.8.8.8,返回 country=美国、isp=Google Cloud,但 prov/city 是空的——海外 IP 在国内省市字段上本来就是空的,脚本把它归到 empty_location 计数里,不参与计分,也不会让整批任务中断。这一条正好说明为什么规则层要容忍「属地为空」。
四、落地时容易踩的坑
IP 字段先洗干净再入库。 电子招投标系统里记录下来的制作 IP,经常带着端口(8.8.8.8:80)、CIDR 斜杠、前后空格,甚至有的是 X-Forwarded-For 的整串内容。带端口和非法格式的字符串查接口会直接返回 HTTP 400,白耗一次调用。代码里 row["doc_ip"].strip().split(":")[0] 这一步看着土,但能省一批无谓请求;更严谨的做法是用 ipaddress.ip_address() 先校验。
内网地址会「查得到但没有位置」。 192.168.1.1、10.0.0.1、127.0.0.1 这类地址,接口会返回 ret=200,country 是「保留」、isp 是「内网地址」,但 prov/city/经纬度全是空串。如果代码只判断 ret == 200 就当成功,后面按城市聚合时会多出一堆空城市的假分组。判断标准应该是「city 非空才算拿到位置」。
共享出口 IP 一定要设白名单。 招标代理机构、交易中心服务终端、大型企业园区出口,这三类 IP 天然会被多家投标人共用。上线前把已知的合规出口建成白名单,命中就只记录不告警,否则第一批预警就会全是误报,业务方三天之内就把规则关掉了。
海外投标或境外节点要注意字段差异。 走海外网络参与国内项目的情况这几年变多了。海外 IP 通常只有 country 和运营商,没有国内省市,big_area 也是空的。这类记录建议单独出一个「境外制作」标签,而不是塞进城市聚合里。
阈值和权重按你的项目规模调。 上面的 45/25/20/10 只是起点:投标人少的小项目(3~5 家)里「同 IP」几乎是决定性的,权重可以再高;几百家投标人的大项目里,同一 /24 网段可能只是同一栋写字楼的宽带,权重就要降。建议先只记录不告警,跑两三个项目统计各条规则的实际命中率,再决定打开哪几条。
证据要留原始记录。 前面 geo 里特意存了 raw(接口完整返回)和 queried_at(查询时间戳)。真要走质疑投诉流程时,能拿出「某年某月某日某时查询某 IP 得到的省市、运营商」这样的原始记录,比事后重新查一遍有说服力得多——IP 数据是会随时间变化的。
IP 只能当线索,写进制度里。 这一点必须写进产品说明和提示文案里:IP 一致、网段一致,都只能作为「疑似串通投标」的线索,需要结合投标文件相似度、报价规律、投标保证金来源、法定代表人关联关系等多维证据综合判断,最终由评标委员会或监管部门认定。平台的定位是「提供线索和复核工具」,不是「输出定性结论」。
合规上别越界。 采集和留存的 IP 属于个人信息,用途要在隐私政策里写明(招投标活动监管、异常行为识别),留存期限也要有说法。别顺手把 IP 归属地拿去做别的营销用途,交易平台的合规审计不是摆设。
总结
这套东西的价值不在规则多复杂,而在三件事做实了:属地化带缓存和限速(省接口额度)、失败不中断批处理(生产可用)、输出的预警带可读证据(业务方能复核)。
按部署顺序建议这么走:先把属地化管道打通、只记录不告警;再统计各条规则在真实项目上的命中率;最后逐个打开规则,同时配套建白名单和人工复核流程。
IP 属地查询用 IP9 免费版就行:https://ip9.com.cn/get?ip=<IP> 查指定 IP(IPv4/IPv6 都支持),不带参数则返回请求方自己的归属地;返回国家、省、市、邮编、区号、运营商、城市中心经纬度、long_ip、big_area(华东、华南这类大区)等字段,免注册、无需鉴权、60 次/分钟。要做区县级比对,或者需要用 ip_type 判断某个 IP 是 IDC 机房、企业专线还是家庭宽带(招投标场景里判断「是不是云桌面/代理」很有用),那就得上 VIP 版;18 万次/分钟、SLA 99.95%,接口地址和字段用法都不变。官网:https://www.ip9.com.cn