UNPKG

zoningjs

Version:

中华人民共和国行政区划:省市区、乡镇街道

294 lines (268 loc) 13 kB
/** * 民政部全国行政区划信息查询平台 https://dmfw.mca.gov.cn/XzqhVersionPublish.html * 浏览器打开以上链接(必须保持在该域名页面下),在控制台粘贴以下代码运行 * * 输出两个结果: * 0.json 省市区(三级) * 4.json 省市区 + 街道/乡镇(四级) * * 抓取频率 QoS 限制为 1(串行请求),关键步骤有进度输出与语音播报 */ let nrcZoning = { tsConfig: { LIST_API: "https://dmfw.mca.gov.cn/xzqh/getList", // 接口1:省市区 CODE_API: "https://dmfw.mca.gov.cn/xzqh/getCodeList", // 接口2:街道/乡镇 TABLE_NAME: "Xzqh20251231", // 行政区划快照版本(接口2必填) MAX_LEVEL: 3, REQUEST_INTERVAL: 2000, // 每次真实请求最小间隔(毫秒),QoS=1 串行抓取,缓存命中不计 MAX_RETRY: 3, // 单个请求失败重试次数 VOICE: true, // 语音播报开关 DOWNLOAD: false, // 完成后自动下载 0.json / 4.json LOCALFORAGE_CDN: "https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js", CACHE_NAME: "nrcZoning", // localforage 库名 CACHE_STORE: "xzqh_cache", // localforage 存储表名 }, tsTree: [], // 接口1原始树(省级节点列表) tsOutput: {}, // 0.json 输出 tsOutput4: {}, // 4.json 四级部分(key 为区县代码) tsQueue: [], // 四级抓取队列 { key, parentCode, name } tsFailed: [], // 抓取失败队列 tsStore: null, // localforage 缓存实例 init: async () => { nrcZoning.speak("开始抓取行政区划数据"); await nrcZoning.onLoadCache(); await nrcZoning.onReady(); nrcZoning.onBuild(); await nrcZoning.onCrawl(); nrcZoning.onOutput(); }, // 加载 localforage 并初始化缓存 onLoadCache: async () => { let cfg = nrcZoning.tsConfig; if (!window.localforage) { await new Promise((resolve, reject) => { let script = document.createElement("script"); script.src = cfg.LOCALFORAGE_CDN; script.onload = resolve; script.onerror = () => reject(new Error("localforage 加载失败")); document.head.appendChild(script); }); } nrcZoning.tsStore = window.localforage.createInstance({ name: cfg.CACHE_NAME, storeName: cfg.CACHE_STORE, }); console.info(`[0/4] 缓存就绪(按 code 缓存,优先读缓存,未命中再请求)`); }, // 清空缓存:控制台执行 await nrcZoning.onClearCache() onClearCache: async () => { await nrcZoning.tsStore.clear(); console.info("缓存已清空"); }, // 接口1:获取省市区三级 onReady: async () => { let cfg = nrcZoning.tsConfig; let cacheKey = "list:0"; let cached = await nrcZoning.tsStore.getItem(cacheKey); if (cached) { nrcZoning.tsTree = cached; console.info(`[1/4] 省市区数据命中缓存,省级节点 ${nrcZoning.tsTree.length} 个`); return; } let url = `${cfg.LIST_API}?code=0&trimCode=true&maxLevel=${cfg.MAX_LEVEL}&_=${Date.now()}`; console.info(`[1/4] 请求省市区数据 ${url}`); let resp = await fetch(url); let json = await resp.json(); if (json.status !== 200 || !json.data || !json.data.children) { throw new Error(`省市区数据请求失败: ${json.message || resp.status}`); } nrcZoning.tsTree = json.data.children; await nrcZoning.tsStore.setItem(cacheKey, nrcZoning.tsTree); console.info(`[1/4] 省级节点 ${nrcZoning.tsTree.length} 个,已写入缓存`); }, // 组装 0.json(省市区),并生成四级抓取队列 onBuild: () => { let output = {}; let queue = []; // 省级(deep 1):过滤非数字代码(如台湾省“资料暂缺”) let provinces = nrcZoning.tsTree.filter(x => /^\d+$/.test(x.code)); nrcZoning.tsTree.filter(x => !/^\d+$/.test(x.code)).forEach(x => { console.warn(`跳过无代码节点: ${x.name} (${x.code})`); }); provinces.sort((a, b) => parseInt(a.code) - parseInt(b.code)); output["0"] = provinces.map(x => ({ c: parseInt(x.code), n: x.name })); let lv2Count = 0, lv3Count = 0; provinces.forEach(p => { let cities = []; // 地级(deep 2) let directGroups = {}; // 省直辖县级节点按前 4 位分组(直辖市区/省直辖县) (p.children || []).forEach(c => { if (c.code.length <= 4) { // 地级市 cities.push({ c: parseInt(c.code), n: c.name }); lv2Count++; let districts = (c.children || []) .filter(x => /^\d{6}$/.test(x.code)) .map(x => ({ c: parseInt(x.code), n: x.name })); if (districts.length) { districts.sort((a, b) => a.c - b.c); output[c.code] = districts; districts.forEach(d => queue.push({ key: String(d.c), parentCode: String(d.c), name: `${p.name}/${c.name}/${d.n}`, })); lv3Count += districts.length; } else { // 直筒子市(东莞市/中山市/儋州市/嘉峪关市):无区县,以 code+"00" 直接抓街道 let key = c.code + "00"; queue.push({ key, parentCode: key, name: `${p.name}/${c.name}` }); } } else if (/^\d{6}$/.test(c.code)) { // 直辖市的区 / 省直辖县级行政单位 let g = c.code.slice(0, 4); (directGroups[g] = directGroups[g] || []).push(c); } }); // 合成虚拟地级市节点:与历史 0.json 结构保持一致 Object.keys(directGroups).sort().forEach(g => { let name = p.type === "直辖市" ? (g.endsWith("02") ? "县" : "市辖区") : "省直辖县级行政单位"; cities.push({ c: parseInt(g), n: name }); let districts = directGroups[g].map(x => ({ c: parseInt(x.code), n: x.name })); districts.sort((a, b) => a.c - b.c); output[g] = districts; districts.forEach(d => queue.push({ key: String(d.c), parentCode: String(d.c), name: `${p.name}/${name}/${d.n}`, })); lv3Count += districts.length; }); if (cities.length) { cities.sort((a, b) => a.c - b.c); output[p.code] = cities; } else { console.warn(`${p.name} 无下级数据`); } }); nrcZoning.tsOutput = output; nrcZoning.tsQueue = queue; console.info(`[2/4] 0.json 组装完成: 省 ${provinces.length} / 市 ${lv2Count} / 区县 ${lv3Count},待抓街道队列 ${queue.length} 个`); nrcZoning.speak(`省市区数据抓取完成,开始抓取街道乡镇,共 ${queue.length} 个区县`); }, // 接口2:串行抓取街道/乡镇(QoS=1) onCrawl: async () => { let cfg = nrcZoning.tsConfig; let queue = nrcZoning.tsQueue; let total = queue.length; let t0 = Date.now(); let lastVoicePct = -1; for (let i = 0; i < total; i++) { let task = queue[i]; let result = await nrcZoning.fetchStreet(task.parentCode); if (result === null) { nrcZoning.tsFailed.push(task); console.warn(`[${i + 1}/${total}] 失败 ${task.key} ${task.name}`); } else { let list = result.rows .filter(x => /^\d{9}$/.test(x.code)) .map(x => ({ c: parseInt(x.code), n: x.name })); list.sort((a, b) => a.c - b.c); if (list.length) { nrcZoning.tsOutput4[task.key] = list; } let pct = ((i + 1) / total * 100).toFixed(1); let elapsed = ((Date.now() - t0) / 1000).toFixed(0); let tag = result.cached ? "[缓存]" : "[请求]"; console.log(`[${i + 1}/${total}] ${pct}% 已用时${elapsed}s ${tag} ${task.key} ${task.name} -> ${list.length} 条`); let pctInt = Math.floor((i + 1) / total * 100); if (pctInt % 10 === 0 && pctInt !== lastVoicePct) { lastVoicePct = pctInt; nrcZoning.speak(`已完成 ${pctInt}%`); } } // 仅真实请求后等待,保证接口请求间隔至少 REQUEST_INTERVAL 毫秒;缓存命中不限速 if (result === null || !result.cached) { await nrcZoning.sleep(cfg.REQUEST_INTERVAL); } } let ok = total - nrcZoning.tsFailed.length; console.info(`[3/4] 街道/乡镇抓取完成: 成功 ${ok} / 失败 ${nrcZoning.tsFailed.length},耗时 ${((Date.now() - t0) / 60000).toFixed(1)} 分钟`); }, // 失败重试:全部完成后可在控制台执行 await nrcZoning.onRetry() onRetry: async () => { if (!nrcZoning.tsFailed.length) { console.info("无失败项"); return; } console.info(`重试 ${nrcZoning.tsFailed.length} 个失败项`); nrcZoning.tsQueue = nrcZoning.tsFailed; nrcZoning.tsFailed = []; await nrcZoning.onCrawl(); nrcZoning.onOutput(); }, // 接口2 单条请求(含重试、按 parentCode 缓存),返回 { rows, cached } 或 null fetchStreet: async (parentCode) => { let cfg = nrcZoning.tsConfig; let cacheKey = `street:${parentCode}`; let cached = await nrcZoning.tsStore.getItem(cacheKey); if (cached) { return { rows: cached, cached: true }; } let body = `parentCode=${parentCode}&placeCode=&title=&tableName=${cfg.TABLE_NAME}&type=2`; for (let attempt = 1; attempt <= cfg.MAX_RETRY; attempt++) { try { let resp = await fetch(cfg.CODE_API, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); let json = await resp.json(); if (json.status === 200 && Array.isArray(json.data)) { await nrcZoning.tsStore.setItem(cacheKey, json.data); return { rows: json.data, cached: false }; } throw new Error(json.message || `HTTP ${resp.status}`); } catch (e) { console.warn(`请求失败(${attempt}/${cfg.MAX_RETRY}) parentCode=${parentCode}`, e); await nrcZoning.sleep(1000 * attempt); } } return null; }, // 输出 0.json / 4.json onOutput: () => { let output4 = Object.assign({}, nrcZoning.tsOutput, nrcZoning.tsOutput4); console.debug("Output 0.json (省市区):"); console.debug(JSON.stringify(nrcZoning.tsOutput)); console.debug("Output 4.json (省市区 + 街道/乡镇):"); console.debug(JSON.stringify(output4)); if (nrcZoning.tsConfig.DOWNLOAD) { nrcZoning.download("0.json", nrcZoning.tsOutput); nrcZoning.download("4.json", output4); } if (nrcZoning.tsFailed.length) { console.warn(`有 ${nrcZoning.tsFailed.length} 个区县抓取失败,可执行 await nrcZoning.onRetry() 重试`); nrcZoning.speak(`抓取完成,但有 ${nrcZoning.tsFailed.length} 个失败项`); } else { nrcZoning.speak("全部抓取完成"); } console.info("[4/4] 完成,结果已通过下载/控制台输出"); }, download: (filename, obj) => { let blob = new Blob([JSON.stringify(obj, null, 4)], { type: "application/json" }); let a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); }, speak: (text) => { console.info(`[播报] ${text}`); if (!nrcZoning.tsConfig.VOICE || !window.speechSynthesis) return; let utter = new SpeechSynthesisUtterance(text); utter.lang = "zh-CN"; window.speechSynthesis.speak(utter); }, sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), }; await nrcZoning.init();