Appearance
R 语言怎么查 IP 归属地?用 base R + jsonlite 给数据表打地域标签(完整可跑代码)
上个月帮一个做问卷调研的老师处理数据,表里 11 万行,只有用户填的省份,没有 IP 归属地。他说想核对一下"填写人自报的省份"和"实际网络出口所在省"的差异 —— 这个分析本身不复杂,麻烦的是他只会 R。
我们搜了一圈"R 语言查 IP 归属地",出来的全是 Python 教程、Java 的 RestTemplate 示例,还有一堆讲离线 IP 库怎么选的文章。R 用户在这个场景里基本是失语的:离线库没有顺手的 R 包,在线接口的教程又都是别的语言。
这篇就把这件事补上。整段代码只用 jsonlite 一个包,其余全是 base R,跑完能直接拿到一张带地域标签的表,外加一张省份分布图。
一、场景问题:为什么 R 用户卡在这一步
数据科学的活通常长这样:手上有一份带 IP 的原始数据(问卷导出、网站访问日志、App 埋点、实验记录),想把 IP 变成省/市/运营商,再按地域切数据看分布。
卡点有三个:
第一,接口有速率限制,不能无脑循环。 IP9 免费版是 60 次/分钟/IP,一份 11 万唯一 IP 的数据表,一秒一个请求要跑 30 多个小时。所以"先去重、先缓存、按天增量"不是优化项,是必需品。
第二,R 的报错习惯和别的语言不一样。 请求一个非法 IP,接口返回 HTTP 400,Python 的 requests 会给你 status_code,Go 会给你 resp.StatusCode,而 R 的 url() 会先弹 warning 再抛 error,tryCatch 抓到的错误信息里根本没有状态码。不知道怎么处理就整批中断一次,非常难受。
第三,统计口径容易搞混。 接口对 192.168.1.1 这类地址不报错,而是返回 country=保留、isp=内网地址;对 203.0.113.9 这种文档示例段返回 isp=文档地址。这些行如果跟"查询失败"混在一起统计,分布图就废了。
二、方案设计
思路很直白,分成四层:
- 抓取层:
GET https://ip9.com.cn/get?ip=<ip>一次查一个 IP,用fromJSON解析。批量场景就是循环调用单 IP 接口,接口本身没有批量入口。 - 限速层:请求之间
Sys.sleep(1),把速率压在 1 次/秒,稳稳落在免费版 60 次/分钟的额度内。限速放在抓取函数里,业务代码不用关心。 - 缓存层:
list做进程内缓存 + 落盘ip_cache.csv。同一个 IP 跨天重跑不重复请求 —— 实际数据里重复 IP 比例常常过半,这一层省下的是成倍的时间。 - 打标层:查到的结果
merge回原表(按 IP 左连接,保证行数不变),然后table()出省份/运营商分布,barplot()直接出图。
用到的字段:country、prov、city、isp、big_area、lng、lat。区县 area 免费版返回为空,需要区县粒度得看官网上的 VIP 版字段说明。
三、R 语言实现
完整脚本(ip_geo_tag.R):
r
#!/usr/bin/env Rscript
# 给只有 IP 的数据表打 IP 归属地标签,并做地域分布分析
library(jsonlite) # 唯一的外部依赖
API <- "https://ip9.com.cn/get"
CACHE_FILE <- "ip_cache.csv"
RATE_SLEEP <- 1.0 # 免费版 60 次/分钟/IP,1 秒 1 次最稳
load_cache <- function() {
if (file.exists(CACHE_FILE)) {
df <- read.csv(CACHE_FILE, stringsAsFactors = FALSE, colClasses = "character")
cat(sprintf("缓存载入:%d 条\n", nrow(df)))
return(split(df, df$ip))
}
list()
}
save_cache <- function(cache) {
df <- do.call(rbind, lapply(cache, function(r) r[1, , drop = FALSE]))
write.csv(df, CACHE_FILE, row.names = FALSE)
}
# 查一个 IP:任何异常都返回 ok=FALSE 的行,不让整批中断
query_ip <- function(ip) {
full <- paste0(API, "?ip=", utils::URLencode(ip, reserved = TRUE))
blank <- data.frame(ip = ip, ok = FALSE, country = NA_character_, prov = NA_character_,
city = NA_character_, isp = NA_character_, big_area = NA_character_,
lng = NA_character_, lat = NA_character_, note = NA_character_,
stringsAsFactors = FALSE)
tryCatch({
con <- url(full, open = "rb", encoding = "UTF-8", method = "libcurl")
on.exit(close(con), add = TRUE)
body <- paste(readLines(con, warn = FALSE), collapse = "")
if (nchar(body) < 2) stop("空响应")
j <- fromJSON(body, simplifyVector = TRUE)
if (!identical(as.integer(j$ret), 200L)) stop(paste0("接口返回 ret=", j$ret))
d <- j$data
data.frame(ip = ip, ok = TRUE,
country = ifelse(is.null(d$country), "", d$country),
prov = ifelse(is.null(d$prov), "", d$prov),
city = ifelse(is.null(d$city), "", d$city),
isp = ifelse(is.null(d$isp), "", d$isp),
big_area = ifelse(is.null(d$big_area), "", d$big_area),
lng = ifelse(is.null(d$lng), "", d$lng),
lat = ifelse(is.null(d$lat), "", d$lat),
note = "", stringsAsFactors = FALSE)
}, error = function(e) { blank$note <- conditionMessage(e); blank })
}
# 给整列 IP 打标签:命中缓存不请求,请求之间强制限速
tag_ips <- function(ips, cache = list(), verbose = TRUE) {
out <- vector("list", length(ips))
for (i in seq_along(ips)) {
ip <- ips[[i]]
if (!is.null(cache[[ip]])) {
out[[i]] <- cache[[ip]]
if (verbose) cat(sprintf("[%d/%d] %-24s 缓存命中\n", i, length(ips), ip))
next
}
rec <- query_ip(ip)
if (rec$ok && rec$country == "保留") {
rec$note <- paste("非公网地址:", rec$isp) # 内网/文档段单独标记
}
if (rec$ok) cache[[ip]] <- rec
out[[i]] <- rec
if (verbose) {
cat(sprintf("[%d/%d] %-24s %s %s %s %s %s\n", i, length(ips), ip,
ifelse(rec$ok, "OK", "FAIL"),
ifelse(is.na(rec$country), "-", rec$country),
ifelse(is.na(rec$prov), "-", rec$prov),
ifelse(is.na(rec$city), "-", rec$city),
ifelse(is.na(rec$isp), "-", rec$isp)))
}
Sys.sleep(RATE_SLEEP)
}
do.call(rbind, out)
}
main <- function() {
users <- read.csv("users.csv", stringsAsFactors = FALSE) # 换成你的真实数据源
cat(sprintf("原始数据 %d 行,去重后 %d 个 IP\n", nrow(users), length(unique(users$ip))))
cache <- load_cache()
geo <- tag_ips(unique(users$ip), cache)
save_cache(cache)
merged <- merge(users, geo, by = "ip", all.x = TRUE) # 左连接,行数不变
prov_tab <- sort(table(merged$prov[merged$country == "中国"]), decreasing = TRUE)
isp_tab <- sort(table(merged$isp), decreasing = TRUE)
cat("\n—— 省份分布 ——\n"); print(prov_tab)
cat("\n—— 运营商分布 ——\n"); print(head(isp_tab, 6))
cat("\n—— 无法定位/非公网 ——\n")
print(subset(merged, !ok | country == "保留", select = c(ip, country, isp, note)))
png("prov_dist.png", width = 900, height = 480, res = 110)
barplot(prov_tab, las = 2, col = "#3e63dd", border = NA,
main = "用户省份分布", ylab = "人数")
dev.off()
write.csv(merged, "users_tagged.csv", row.names = FALSE)
cat("\n已写出 users_tagged.csv 与 prov_dist.png\n")
}
main()环境准备就一行(Debian/Ubuntu 源里直接有,不用碰 CRAN 编译):
bash
sudo apt install -y r-base-core r-cran-jsonlite
Rscript ip_geo_tag.R四、实测输出(真跑出来的)
拿 11 行样例数据跑了一遍,9 个唯一 IP:
原始数据 11 行,去重后 9 个 IP
[1/9] 114.114.114.114 OK 中国 江苏 南京 114DNS
[2/9] 223.5.5.5 OK 中国 浙江 杭州 AliDNS/DoH/DoT/阿里云
[3/9] 58.213.1.1 OK 中国 江苏 南京 中国电信
[4/9] 180.101.49.12 OK 中国 江苏 南京 中国电信
[5/9] 240e:ff:e02c:1:0:ff:b0e4:20f OK 中国 广东 广州 中国电信
[6/9] 192.168.1.1 OK 保留 内网地址
[7/9] 203.0.113.9 OK 保留 文档地址
[8/9] 45.153.160.2 OK 捷克 AT&T
[9/9] 999.1.1.1 FAIL - - - -
—— 省份分布 ——
江苏 浙江 广东
4 2 1(IPv6 地址同样能查,240e:... 那条直接返回广东广州。)
整批耗时 12.4 秒:8 次真实请求 + 1 次被接口拒掉 + 若干次缓存命中。这里踩了一个 R 特有的坑:
Warning message:
In url(full, open = "rb", ...) :
cannot open URL 'https://ip9.com.cn/get?ip=999.1.1.1&lang=zh': HTTP status was '400 Bad Request'999.1.1.1 是非法的,接口按约定返回 HTTP 400,body 是 {"data":[],"ret":400}。R 的 url() 遇到 4xx 的姿势是:warning 里带状态码,error 里只有一句"打不开这个连接"。也就是说 tryCatch 的 conditionMessage(e) 抓到的文本是:
cannot open the connection to 'https://ip9.com.cn/get?ip=999.1.1.1&lang=zh'状态码 400 只存在于 warning 里。目前的做法是:只要 url() 抛错就按"这个 IP 没查到"处理,把报文写进 note 列人工看。如果想精确区分 400(非法 IP,不该重试)和 429(限速,要退避重试),得用 withCallingHandlers 把 warning 捞出来解析,或者干脆换成 httr2(会多一个依赖)。
五、落地时要注意的几件事
先 distinct 再请求,这一步能省掉一半以上的调用。 11 万行数据里唯一 IP 常常只有三四万。免费版 60 次/分钟意味着每小时 3600 次、一天不到 9 万次,所以"先按唯一 IP 查一遍、结果落盘、之后永远走缓存"是必须的流程。真到十万量级再说 VIP 版(0.05 元/千次起,12 万次大约 6 元,18 万次/分钟的并发上限),或者用离线库兜底。
merge 一定要 all.x = TRUE。 少了这个参数,查不到地域的行会被静默丢掉,最后你得到一张"干净"但少了几千行的表,主题分析全歪。
统计时把三类"没有地域"分清楚。 内网地址(country=保留、isp=内网地址)、文档示例段(isp=文档地址)、查询失败(ok=FALSE)是三种不同的东西。问卷数据里混进测试 IP 很正常,别把它们算进省份分布,也别直接当成缺失值丢掉 —— 前者要单独列出来核对,后者才知道是数据问题还是接口问题。
经纬度是城市中心点,不是设备位置。 lng/lat 拿来在省市级地图上打点没问题,做"用户到某地标的距离"这类分析就完全跑偏了。要区县级精度只能上 VIP 版,再细的(街道、楼栋)任何 IP 库都给不了。
别把原始 IP 写进结果表。 论文/报告里给地域分布就够,原始 IP 属于个人信息,能不留就不留。上面脚本的中间缓存文件放本地、不进版本库,发布前记得清掉 —— 这一点在做数据出境自查或对外交付数据时特别要紧。
要做的事其实就这些:一个接口、一层缓存、一层限速,剩下的都是 R 自己的活儿。接口 GET https://ip9.com.cn/get?ip=<ip> 免费版不用注册、60 次/分钟/IP,字段清单(prov、city、isp、big_area、经纬度等)在官网 https://www.ip9.com.cn 上有完整说明。下一次再有人问"R 怎么查 IP 归属地",直接把这段脚本丢过去就行。