Files
cf-ip-geo/worker.js
2026-03-11 20:11:49 +08:00

550 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function isValidIPv4(address) {
const parts = address.split(".");
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return false;
const n = Number(part);
if (n < 0 || n > 255) return false;
if (part.length > 1 && part.startsWith("0")) return false;
}
return true;
}
function isValidIPv6(address) {
if (typeof address !== "string") return false;
let addr = address.trim();
if (addr.startsWith("[") && addr.endsWith("]")) addr = addr.slice(1, -1);
const percentIndex = addr.indexOf("%");
if (percentIndex !== -1) addr = addr.slice(0, percentIndex);
if (addr.length === 0) return false;
if (!/^[0-9a-fA-F:.]+$/.test(addr)) return false;
const hasEmbeddedIPv4 = addr.includes(".");
if (hasEmbeddedIPv4) {
const lastColon = addr.lastIndexOf(":");
if (lastColon === -1) return false;
const tail = addr.slice(lastColon + 1);
if (!isValidIPv4(tail)) return false;
addr = `${addr.slice(0, lastColon)}:ipv4`;
}
const isGroup = (g) => g === "ipv4" || /^[0-9a-fA-F]{1,4}$/.test(g);
const countGroups = (groups) =>
groups.reduce((sum, g) => sum + (g === "ipv4" ? 2 : 1), 0);
if (addr.includes("::")) {
if (addr.indexOf("::") !== addr.lastIndexOf("::")) return false;
const [left, right] = addr.split("::");
const leftGroups = left ? left.split(":").filter(Boolean) : [];
const rightGroups = right ? right.split(":").filter(Boolean) : [];
if (!leftGroups.every(isGroup) || !rightGroups.every(isGroup)) return false;
const total = countGroups(leftGroups) + countGroups(rightGroups);
return total < 8;
}
const groups = addr.split(":");
if (groups.some((g) => g.length === 0)) return false;
if (!groups.every(isGroup)) return false;
const total = countGroups(groups);
return total === 8;
}
function classifyIp(address) {
if (!address) return "unknown";
if (isValidIPv4(address)) return "ipv4";
if (isValidIPv6(address)) return "ipv6";
return "unknown";
}
function getClientIp(req) {
return (
req.headers.get("CF-Connecting-IP") ||
(req.headers.get("X-Forwarded-For") || "").split(",")[0].trim() ||
""
);
}
const CONTINENT_ZH = {
AF: "非洲", AN: "南极洲", AS: "亚洲", EU: "欧洲",
NA: "北美洲", OC: "大洋洲", SA: "南美洲",
};
const COUNTRY_ZH = {
AD: "安道尔", AE: "阿联酋", AF: "阿富汗", AG: "安提瓜和巴布达",
AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉",
AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利",
AU: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
BA: "波黑", BB: "巴巴多斯", BD: "孟加拉国", BE: "比利时",
BF: "布基纳法索", BG: "保加利亚", BH: "巴林", BI: "布隆迪",
BJ: "贝宁", BL: "圣巴泰勒米", BM: "百慕大", BN: "文莱",
BO: "玻利维亚", BQ: "荷属加勒比", BR: "巴西", BS: "巴哈马",
BT: "不丹", BV: "布韦岛", BW: "博茨瓦纳", BY: "白俄罗斯",
BZ: "伯利兹", CA: "加拿大", CC: "科科斯群岛", CD: "刚果(金)",
CF: "中非", CG: "刚果(布)", CH: "瑞士", CI: "科特迪瓦",
CK: "库克群岛", CL: "智利", CM: "喀麦隆", CN: "中国",
CO: "哥伦比亚", CR: "哥斯达黎加", CU: "古巴", CV: "佛得角",
CW: "库拉索", CX: "圣诞岛", CY: "塞浦路斯", CZ: "捷克",
DE: "德国", DJ: "吉布提", DK: "丹麦", DM: "多米尼克",
DO: "多米尼加", DZ: "阿尔及利亚", EC: "厄瓜多尔", EE: "爱沙尼亚",
EG: "埃及", EH: "西撒哈拉", ER: "厄立特里亚", ES: "西班牙",
ET: "埃塞俄比亚", FI: "芬兰", FJ: "斐济", FK: "福克兰群岛",
FM: "密克罗尼西亚", FO: "法罗群岛", FR: "法国", GA: "加蓬",
GB: "英国", GD: "格林纳达", GE: "格鲁吉亚", GF: "法属圭亚那",
GG: "根西岛", GH: "加纳", GI: "直布罗陀", GL: "格陵兰",
GM: "冈比亚", GN: "几内亚", GP: "瓜德罗普", GQ: "赤道几内亚",
GR: "希腊", GS: "南乔治亚和南桑威奇群岛", GT: "危地马拉", GU: "关岛",
GW: "几内亚比绍", GY: "圭亚那", HK: "中国香港", HM: "赫德岛和麦克唐纳群岛",
HN: "洪都拉斯", HR: "克罗地亚", HT: "海地", HU: "匈牙利",
ID: "印度尼西亚", IE: "爱尔兰", IL: "以色列", IM: "马恩岛",
IN: "印度", IO: "英属印度洋领地", IQ: "伊拉克", IR: "伊朗",
IS: "冰岛", IT: "意大利", JE: "泽西岛", JM: "牙买加",
JO: "约旦", JP: "日本", KE: "肯尼亚", KG: "吉尔吉斯斯坦",
KH: "柬埔寨", KI: "基里巴斯", KM: "科摩罗", KN: "圣基茨和尼维斯",
KP: "朝鲜", KR: "韩国", KW: "科威特", KY: "开曼群岛",
KZ: "哈萨克斯坦", LA: "老挝", LB: "黎巴嫩", LC: "圣卢西亚",
LI: "列支敦士登", LK: "斯里兰卡", LR: "利比里亚", LS: "莱索托",
LT: "立陶宛", LU: "卢森堡", LV: "拉脱维亚", LY: "利比亚",
MA: "摩洛哥", MC: "摩纳哥", MD: "摩尔多瓦", ME: "黑山",
MF: "法属圣马丁", MG: "马达加斯加", MH: "马绍尔群岛", MK: "北马其顿",
ML: "马里", MM: "缅甸", MN: "蒙古", MO: "中国澳门",
MP: "北马里亚纳群岛", MQ: "马提尼克", MR: "毛里塔尼亚", MS: "蒙特塞拉特",
MT: "马耳他", MU: "毛里求斯", MV: "马尔代夫", MW: "马拉维",
MX: "墨西哥", MY: "马来西亚", MZ: "莫桑比克", NA: "纳米比亚",
NC: "新喀里多尼亚", NE: "尼日尔", NF: "诺福克岛", NG: "尼日利亚",
NI: "尼加拉瓜", NL: "荷兰", NO: "挪威", NP: "尼泊尔",
NR: "瑙鲁", NU: "纽埃", NZ: "新西兰", OM: "阿曼",
PA: "巴拿马", PE: "秘鲁", PF: "法属波利尼西亚", PG: "巴布亚新几内亚",
PH: "菲律宾", PK: "巴基斯坦", PL: "波兰", PM: "圣皮埃尔和密克隆",
PN: "皮特凯恩群岛", PR: "波多黎各", PS: "巴勒斯坦", PT: "葡萄牙",
PW: "帕劳", PY: "巴拉圭", QA: "卡塔尔", RE: "留尼汪",
RO: "罗马尼亚", RS: "塞尔维亚", RU: "俄罗斯", RW: "卢旺达",
SA: "沙特阿拉伯", SB: "所罗门群岛", SC: "塞舌尔", SD: "苏丹",
SE: "瑞典", SG: "新加坡", SH: "圣赫勒拿", SI: "斯洛文尼亚",
SJ: "斯瓦尔巴和扬马延", SK: "斯洛伐克", SL: "塞拉利昂", SM: "圣马力诺",
SN: "塞内加尔", SO: "索马里", SR: "苏里南", SS: "南苏丹",
ST: "圣多美和普林西比", SV: "萨尔瓦多", SX: "荷属圣马丁", SY: "叙利亚",
SZ: "斯威士兰", TC: "特克斯和凯科斯群岛", TD: "乍得", TF: "法属南部领地",
TG: "多哥", TH: "泰国", TJ: "塔吉克斯坦", TK: "托克劳",
TL: "东帝汶", TM: "土库曼斯坦", TN: "突尼斯", TO: "汤加",
TR: "土耳其", TT: "特立尼达和多巴哥", TV: "图瓦卢", TW: "中国台湾",
TZ: "坦桑尼亚", UA: "乌克兰", UG: "乌干达", UM: "美国本土外小岛屿",
US: "美国", UY: "乌拉圭", UZ: "乌兹别克斯坦", VA: "梵蒂冈",
VC: "圣文森特和格林纳丁斯", VE: "委内瑞拉", VG: "英属维尔京群岛",
VI: "美属维尔京群岛", VN: "越南", VU: "瓦努阿图",
WF: "瓦利斯和富图纳", WS: "萨摩亚", XK: "科索沃", YE: "也门",
YT: "马约特", ZA: "南非", ZM: "赞比亚", ZW: "津巴布韦",
};
const CN_REGION_ZH = {
AH: "安徽", BJ: "北京", CQ: "重庆", FJ: "福建", GD: "广东",
GS: "甘肃", GX: "广西", GZ: "贵州", HA: "河南", HB: "湖北",
HE: "河北", HI: "海南", HK: "香港", HL: "黑龙江", HN: "湖南",
JL: "吉林", JS: "江苏", JX: "江西", LN: "辽宁", MO: "澳门",
NM: "内蒙古", NX: "宁夏", QH: "青海", SC: "四川", SD: "山东",
SH: "上海", SN: "陕西", SX: "山西", TJ: "天津", TW: "台湾",
XJ: "新疆", XZ: "西藏", YN: "云南", ZJ: "浙江",
};
const CN_CITY_ZH = {
"Beijing": "北京", "Shanghai": "上海", "Tianjin": "天津", "Chongqing": "重庆",
"Shijiazhuang": "石家庄", "Tangshan": "唐山", "Qinhuangdao": "秦皇岛",
"Handan": "邯郸", "Xingtai": "邢台", "Baoding": "保定",
"Zhangjiakou": "张家口", "Chengde": "承德", "Cangzhou": "沧州",
"Langfang": "廊坊", "Hengshui": "衡水",
"Taiyuan": "太原", "Datong": "大同", "Yangquan": "阳泉",
"Changzhi": "长治", "Jincheng": "晋城", "Shuozhou": "朔州",
"Jinzhong": "晋中", "Yuncheng": "运城", "Xinzhou": "忻州",
"Linfen": "临汾", "Lüliang": "吕梁", "Lvliang": "吕梁", "Luliang": "吕梁",
"Hohhot": "呼和浩特", "Baotou": "包头", "Wuhai": "乌海",
"Chifeng": "赤峰", "Tongliao": "通辽", "Ordos": "鄂尔多斯",
"Hulunbuir": "呼伦贝尔", "Hulun Buir": "呼伦贝尔",
"Bayannur": "巴彦淖尔", "Ulanqab": "乌兰察布", "Wulanchabu": "乌兰察布",
"Hinggan": "兴安", "Xilingol": "锡林郭勒", "Xilin Gol": "锡林郭勒",
"Alxa": "阿拉善", "Alashan": "阿拉善",
"Shenyang": "沈阳", "Dalian": "大连", "Anshan": "鞍山",
"Fushun": "抚顺", "Benxi": "本溪", "Dandong": "丹东",
"Jinzhou": "锦州", "Yingkou": "营口", "Fuxin": "阜新",
"Liaoyang": "辽阳", "Panjin": "盘锦", "Tieling": "铁岭",
"Chaoyang": "朝阳", "Huludao": "葫芦岛",
"Changchun": "长春", "Jilin": "吉林", "Siping": "四平",
"Liaoyuan": "辽源", "Tonghua": "通化", "Baishan": "白山",
"Songyuan": "松原", "Baicheng": "白城", "Yanbian": "延边",
"Harbin": "哈尔滨", "Qiqihar": "齐齐哈尔",
"Jixi": "鸡西", "Hegang": "鹤岗", "Shuangyashan": "双鸭山",
"Daqing": "大庆", "Jiamusi": "佳木斯", "Qitaihe": "七台河",
"Mudanjiang": "牡丹江", "Heihe": "黑河", "Suihua": "绥化",
"Daxing'anling": "大兴安岭", "Daxinganling": "大兴安岭",
"Nanjing": "南京", "Wuxi": "无锡", "Xuzhou": "徐州",
"Changzhou": "常州", "Nantong": "南通", "Lianyungang": "连云港",
"Huai'an": "淮安", "Huaian": "淮安", "Yancheng": "盐城",
"Yangzhou": "扬州", "Zhenjiang": "镇江", "Suqian": "宿迁",
"Hangzhou": "杭州", "Ningbo": "宁波", "Wenzhou": "温州",
"Jiaxing": "嘉兴", "Huzhou": "湖州", "Shaoxing": "绍兴",
"Jinhua": "金华", "Quzhou": "衢州", "Zhoushan": "舟山", "Lishui": "丽水",
"Hefei": "合肥", "Wuhu": "芜湖", "Bengbu": "蚌埠",
"Huainan": "淮南", "Ma'anshan": "马鞍山", "Maanshan": "马鞍山",
"Huaibei": "淮北", "Tongling": "铜陵", "Anqing": "安庆",
"Huangshan": "黄山", "Chuzhou": "滁州", "Fuyang": "阜阳",
"Lu'an": "六安", "Luan": "六安", "Bozhou": "亳州",
"Chizhou": "池州", "Xuancheng": "宣城",
"Xiamen": "厦门", "Putian": "莆田", "Sanming": "三明",
"Quanzhou": "泉州", "Zhangzhou": "漳州", "Nanping": "南平",
"Longyan": "龙岩", "Ningde": "宁德",
"Nanchang": "南昌", "Jingdezhen": "景德镇", "Pingxiang": "萍乡",
"Jiujiang": "九江", "Xinyu": "新余", "Yingtan": "鹰潭",
"Ganzhou": "赣州", "Ji'an": "吉安", "Jian": "吉安", "Shangrao": "上饶",
"Jinan": "济南", "Qingdao": "青岛", "Zibo": "淄博",
"Zaozhuang": "枣庄", "Dongying": "东营", "Yantai": "烟台",
"Weifang": "潍坊", "Jining": "济宁", "Tai'an": "泰安", "Taian": "泰安",
"Weihai": "威海", "Rizhao": "日照", "Linyi": "临沂",
"Dezhou": "德州", "Liaocheng": "聊城", "Binzhou": "滨州", "Heze": "菏泽",
"Zhengzhou": "郑州", "Kaifeng": "开封", "Luoyang": "洛阳",
"Pingdingshan": "平顶山", "Anyang": "安阳", "Hebi": "鹤壁",
"Xinxiang": "新乡", "Jiaozuo": "焦作", "Puyang": "濮阳",
"Xuchang": "许昌", "Luohe": "漯河", "Sanmenxia": "三门峡",
"Nanyang": "南阳", "Shangqiu": "商丘", "Xinyang": "信阳",
"Zhoukou": "周口", "Zhumadian": "驻马店", "Jiyuan": "济源",
"Wuhan": "武汉", "Huangshi": "黄石", "Shiyan": "十堰",
"Yichang": "宜昌", "Xiangyang": "襄阳", "Ezhou": "鄂州",
"Jingmen": "荆门", "Xiaogan": "孝感", "Jingzhou": "荆州",
"Huanggang": "黄冈", "Xianning": "咸宁", "Suizhou": "随州",
"Enshi": "恩施", "Xiantao": "仙桃", "Qianjiang": "潜江",
"Tianmen": "天门", "Shennongjia": "神农架",
"Changsha": "长沙", "Zhuzhou": "株洲", "Xiangtan": "湘潭",
"Hengyang": "衡阳", "Shaoyang": "邵阳", "Yueyang": "岳阳",
"Changde": "常德", "Zhangjiajie": "张家界", "Yiyang": "益阳",
"Chenzhou": "郴州", "Yongzhou": "永州", "Huaihua": "怀化",
"Loudi": "娄底", "Xiangxi": "湘西",
"Guangzhou": "广州", "Shaoguan": "韶关", "Shenzhen": "深圳",
"Zhuhai": "珠海", "Shantou": "汕头", "Foshan": "佛山",
"Jiangmen": "江门", "Zhanjiang": "湛江", "Maoming": "茂名",
"Zhaoqing": "肇庆", "Huizhou": "惠州", "Meizhou": "梅州",
"Shanwei": "汕尾", "Heyuan": "河源", "Yangjiang": "阳江",
"Qingyuan": "清远", "Dongguan": "东莞", "Zhongshan": "中山",
"Chaozhou": "潮州", "Jieyang": "揭阳", "Yunfu": "云浮",
"Nanning": "南宁", "Liuzhou": "柳州", "Guilin": "桂林",
"Wuzhou": "梧州", "Beihai": "北海", "Fangchenggang": "防城港",
"Qinzhou": "钦州", "Guigang": "贵港", "Baise": "百色", "Bose": "百色",
"Hechi": "河池", "Laibin": "来宾", "Chongzuo": "崇左", "Hezhou": "贺州",
"Haikou": "海口", "Sanya": "三亚", "Sansha": "三沙",
"Danzhou": "儋州", "Wuzhishan": "五指山", "Wanning": "万宁",
"Dongfang": "东方", "Qionghai": "琼海", "Wenchang": "文昌",
"Chengdu": "成都", "Zigong": "自贡", "Panzhihua": "攀枝花",
"Luzhou": "泸州", "Deyang": "德阳", "Mianyang": "绵阳",
"Guangyuan": "广元", "Suining": "遂宁", "Neijiang": "内江",
"Leshan": "乐山", "Nanchong": "南充", "Meishan": "眉山",
"Yibin": "宜宾", "Guang'an": "广安", "Guangan": "广安",
"Dazhou": "达州", "Ya'an": "雅安", "Yaan": "雅安",
"Bazhong": "巴中", "Ziyang": "资阳",
"Aba": "阿坝", "Ngawa": "阿坝", "Garze": "甘孜", "Ganzi": "甘孜",
"Liangshan": "凉山",
"Guiyang": "贵阳", "Liupanshui": "六盘水", "Zunyi": "遵义",
"Anshun": "安顺", "Bijie": "毕节", "Tongren": "铜仁",
"Qianxinan": "黔西南", "Qiandongnan": "黔东南", "Qiannan": "黔南",
"Kunming": "昆明", "Qujing": "曲靖", "Yuxi": "玉溪",
"Baoshan": "保山", "Zhaotong": "昭通", "Lijiang": "丽江",
"Pu'er": "普洱", "Puer": "普洱", "Lincang": "临沧",
"Chuxiong": "楚雄", "Honghe": "红河", "Wenshan": "文山",
"Xishuangbanna": "西双版纳", "Dali": "大理", "Dehong": "德宏",
"Nujiang": "怒江", "Diqing": "迪庆", "Deqen": "迪庆",
"Lhasa": "拉萨", "Chamdo": "昌都", "Changdu": "昌都",
"Shannan": "山南", "Lhoka": "山南",
"Shigatse": "日喀则", "Xigaze": "日喀则",
"Nagqu": "那曲", "Naqu": "那曲",
"Ngari": "阿里", "Ali": "阿里",
"Nyingchi": "林芝", "Linzhi": "林芝",
"Xi'an": "西安", "Xian": "西安", "Tongchuan": "铜川",
"Baoji": "宝鸡", "Xianyang": "咸阳", "Weinan": "渭南",
"Yan'an": "延安", "Yanan": "延安", "Hanzhong": "汉中",
"Ankang": "安康", "Shangluo": "商洛",
"Lanzhou": "兰州", "Jiayuguan": "嘉峪关", "Jinchang": "金昌",
"Baiyin": "白银", "Tianshui": "天水", "Wuwei": "武威",
"Zhangye": "张掖", "Pingliang": "平凉", "Jiuquan": "酒泉",
"Qingyang": "庆阳", "Dingxi": "定西", "Longnan": "陇南",
"Linxia": "临夏", "Gannan": "甘南",
"Xining": "西宁", "Haidong": "海东", "Haibei": "海北",
"Huangnan": "黄南", "Guoluo": "果洛", "Golog": "果洛",
"Yushu": "玉树", "Haixi": "海西",
"Yinchuan": "银川", "Shizuishan": "石嘴山", "Wuzhong": "吴忠",
"Guyuan": "固原", "Zhongwei": "中卫",
"Urumqi": "乌鲁木齐", "Karamay": "克拉玛依",
"Turpan": "吐鲁番", "Turfan": "吐鲁番",
"Hami": "哈密", "Changji": "昌吉",
"Bortala": "博尔塔拉", "Bole": "博乐",
"Bayingol": "巴音郭楞", "Bayingolin": "巴音郭楞",
"Aksu": "阿克苏", "Kizilsu": "克孜勒苏",
"Kashgar": "喀什", "Hotan": "和田",
"Ili": "伊犁", "Yili": "伊犁",
"Tacheng": "塔城", "Altay": "阿勒泰",
"Shihezi": "石河子", "Aral": "阿拉尔", "Alar": "阿拉尔",
"Tumxuk": "图木舒克", "Tumushuke": "图木舒克",
"Wujiaqu": "五家渠", "Beitun": "北屯",
"Hong Kong": "香港", "Macau": "澳门", "Macao": "澳门",
"Taipei": "台北", "New Taipei": "新北", "Kaohsiung": "高雄",
"Taichung": "台中", "Tainan": "台南", "Hsinchu": "新竹",
"Keelung": "基隆", "Chiayi": "嘉义", "Taoyuan": "桃园",
"Pingtung": "屏东", "Changhua": "彰化", "Hualien": "花莲",
"Yilan": "宜兰", "Nantou": "南投", "Miaoli": "苗栗",
"Yunlin": "云林", "Taitung": "台东", "Penghu": "澎湖", "Kinmen": "金门",
};
const CN_CITY_DISAMBIG = {
"Taizhou": { JS: "泰州", ZJ: "台州" },
"Fuzhou": { FJ: "福州", JX: "抚州" },
"Yichun": { HL: "伊春", JX: "宜春" },
"Yulin": { GX: "玉林", SN: "榆林" },
"Suzhou": { JS: "苏州", AH: "宿州" },
};
function getRegionName(country, region, regionCode) {
if (country === "CN" && regionCode) return CN_REGION_ZH[regionCode] || region || "";
return region || "";
}
function getCityName(country, city, regionCode) {
if (country !== "CN" && country !== "HK" && country !== "MO" && country !== "TW") return city || "";
const disambig = CN_CITY_DISAMBIG[city];
if (disambig && regionCode && disambig[regionCode]) return disambig[regionCode];
return CN_CITY_ZH[city] || city || "";
}
function jsonResponse(payload, reqUrl, { status = 200, headers = {} } = {}) {
const pretty = reqUrl.searchParams.get("pretty");
const body = pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload);
return new Response(body, {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
...headers,
},
});
}
export default {
async fetch(request) {
const url = new URL(request.url);
const path = url.pathname;
// --- CORS给 API 路径用) ---
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
};
// 预检
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
// 只提供 GET
if (request.method !== "GET") {
return new Response("Method Not Allowed", { status: 405 });
}
// 取公网 IP优先 CF-Connecting-IP
const ip = getClientIp(request);
const ipVersion = classifyIp(ip);
// Cloudflare 地理/网络信息(在边缘注入;本地 dev 可能为空)
const cf = request.cf || {};
const apiPayload = {
ip,
ipVersion,
userAgent: request.headers.get("User-Agent") || "",
// 常见 cf 字段(不同账号/计划/场景可用字段会有差异)
geo: {
country: cf.country || "",
countryName: COUNTRY_ZH[cf.country] || cf.country || "",
region: cf.region || "",
regionName: getRegionName(cf.country, cf.region, cf.regionCode),
regionCode: cf.regionCode || "",
city: cf.city || "",
cityName: getCityName(cf.country, cf.city, cf.regionCode),
postalCode: cf.postalCode || "",
timezone: cf.timezone || "",
continent: cf.continent || "",
continentName: CONTINENT_ZH[cf.continent] || cf.continent || "",
latitude: cf.latitude || "",
longitude: cf.longitude || "",
},
network: {
asn: cf.asn || "",
asOrganization: cf.asOrganization || "",
colo: cf.colo || "",
httpProtocol: cf.httpProtocol || "",
},
ts: new Date().toISOString(),
// 如果你想“全量返回”,可把 cf 全部带回去(注意体积)
// cf,
};
// API/api
if (path === "/api") {
return jsonResponse(apiPayload, url, { headers: { ...corsHeaders } });
}
// 仅返回 IPv4/ipv4
if (path === "/ipv4") {
const expected = "ipv4";
if (!ip || ipVersion === "unknown") {
return jsonResponse(
{
ok: false,
expected,
got: ipVersion,
ip,
error: "IP not found",
ts: new Date().toISOString(),
},
url,
{ status: 400, headers: { ...corsHeaders } },
);
}
if (ipVersion !== expected) {
return jsonResponse(
{
ok: false,
expected,
got: ipVersion,
ip,
error: "Expected IPv4, but request reached Cloudflare over a non-IPv4 connection.",
hint:
"服务器无法强制切换 IP 协议栈;请在客户端侧使用 IPv4 访问(例如 curl -4或检查网络/DNS 的优先级。",
ts: new Date().toISOString(),
},
url,
{ status: 400, headers: { ...corsHeaders } },
);
}
return jsonResponse(
{ ok: true, ip, ipVersion, ts: new Date().toISOString() },
url,
{ headers: { ...corsHeaders } },
);
}
// 仅返回 IPv6/ipv6
if (path === "/ipv6") {
const expected = "ipv6";
if (!ip || ipVersion === "unknown") {
return jsonResponse(
{
ok: false,
expected,
got: ipVersion,
ip,
error: "IP not found",
ts: new Date().toISOString(),
},
url,
{ status: 400, headers: { ...corsHeaders } },
);
}
if (ipVersion !== expected) {
return jsonResponse(
{
ok: false,
expected,
got: ipVersion,
ip,
error: "Expected IPv6, but request reached Cloudflare over IPv4.",
hint: [
"服务器端无法把一次 IPv4 请求“改成”IPv6是否走 v4/v6 由客户端网络与 DNS 决定Happy Eyeballs。",
`命令行可用curl -6 ${url.origin}/ipv6`,
"若你需要“强制 IPv6”建议绑定一个仅 AAAA 解析的域名/子域名到本 Worker再用该域名访问 /ipv6。",
],
ts: new Date().toISOString(),
},
url,
{ status: 400, headers: { ...corsHeaders } },
);
}
return jsonResponse(
{ ok: true, ip, ipVersion, ts: new Date().toISOString() },
url,
{ headers: { ...corsHeaders } },
);
}
// 网页:/
if (path === "/" || path === "/index.html") {
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>cf-ip-geo</title>
<style>
:root{color-scheme:light dark;}
body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;max-width:920px;margin:40px auto;padding:0 16px;line-height:1.55;}
h1{font-size:28px;margin:0 0 8px;}
p{margin:10px 0;}
a{color:inherit}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:0.95em;}
pre{background:rgba(17,24,39,.95);color:#e5e7eb;padding:14px;border-radius:12px;overflow:auto;}
.muted{color:rgb(107,114,128);font-size:13px}
.card{border:1px solid rgba(229,231,235,.9);border-radius:14px;padding:16px 16px;margin:16px 0;}
ul{margin:10px 0 0 18px;padding:0}
li{margin:6px 0}
details{border:1px solid rgba(229,231,235,.9);border-radius:12px;padding:12px 12px;margin-top:14px;}
summary{cursor:pointer;font-weight:600}
</style>
</head>
<body>
<h1>cf-ip-geo</h1>
<p class="muted">Cloudflare Workers: 根据访问者 IP 返回地理/网络信息(数据来自 <code>request.cf</code> 与 <code>CF-Connecting-IP</code>)。</p>
<div class="card">
<h2>HTTP API</h2>
<ul>
<li><code>GET /api</code>:返回 JSON支持 <code>?pretty=1</code>)。</li>
<li><code>GET /ipv4</code>:返回 JSON仅允许 IPv4 连接;否则 400。</li>
<li><code>GET /ipv6</code>:返回 JSON仅允许 IPv6 连接;否则 400。</li>
</ul>
<p class="muted">API 默认允许跨域(<code>Access-Control-Allow-Origin: *</code>)。</p>
<p class="muted">提示:浏览器/系统会自动选择 IPv4/IPv6可能优先走 IPv4。如需强制测试 IPv6请用 <code>curl -6</code> 或使用仅 AAAA 解析的域名访问。</p>
<h3>Examples</h3>
<pre><code>curl ${url.origin}/api?pretty=1
curl -4 ${url.origin}/ipv4?pretty=1
curl -6 ${url.origin}/ipv6?pretty=1</code></pre>
</div>
<details>
<summary>Live: GET /api?pretty=1</summary>
<pre id="out">Loading…</pre>
</details>
<script>
async function run(){
const out = document.getElementById('out');
out.textContent = 'Loading…';
try{
const r = await fetch('/api?pretty=1', { cache: 'no-store' });
const t = await r.text();
out.textContent = t;
}catch(e){
out.textContent = 'Failed: ' + (e && e.message ? e.message : String(e));
}
}
run();
</script>
</body>
</html>`;
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
// 其他路径404
return new Response("Not Found", { status: 404 });
},
};