This commit is contained in:
2026-03-11 20:11:49 +08:00
commit cc4326140b
4 changed files with 610 additions and 0 deletions

549
worker.js Normal file
View File

@@ -0,0 +1,549 @@
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 });
},
};