Skip to content

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=文档地址。这些行如果跟"查询失败"混在一起统计,分布图就废了。

二、方案设计 ​

思路很直白,分成四层:

  1. 抓取层:GET https://ip9.com.cn/get?ip=<ip> 一次查一个 IP,用 fromJSON 解析。批量场景就是循环调用单 IP 接口,接口本身没有批量入口。
  2. 限速层:请求之间 Sys.sleep(1),把速率压在 1 次/秒,稳稳落在免费版 60 次/分钟的额度内。限速放在抓取函数里,业务代码不用关心。
  3. 缓存层:list 做进程内缓存 + 落盘 ip_cache.csv。同一个 IP 跨天重跑不重复请求 —— 实际数据里重复 IP 比例常常过半,这一层省下的是成倍的时间。
  4. 打标层:查到的结果 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 归属地",直接把这段脚本丢过去就行。