Files
lisglosips/scripts/import-number-library.mjs

185 lines
7.7 KiB
JavaScript

import fs from 'node:fs';
import zlib from 'node:zlib';
import crypto from 'node:crypto';
import { PrismaClient } from '../packages/database/dist/index.js';
const args = Object.fromEntries(process.argv.slice(2).map((entry) => {
const [key, ...value] = entry.replace(/^--/, '').split('=');
return [key, value.length ? value.join('=') : true];
}));
if (!args.admin || !args.phone) {
throw new Error('Usage: node scripts/import-number-library.mjs --admin=/path/area.json.gz --phone=/path/phone.dat [--dry-run]');
}
const batchId = String(args.batch || 'number-library-20260827');
const adminSource = '国家统计局区划代码整理库 2024 (2023-06-30)';
const phoneSource = 'pangongzi/phone phone.dat 2025-02';
const adminTree = JSON.parse(zlib.gunzipSync(fs.readFileSync(String(args.admin))).toString('utf8'));
const sixDigitCode = (code) => String(code).slice(0, 6);
const normalize = (value) => String(value || '')
.replace(/\s+/g, '')
.replace(/特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|土家族苗族自治州|苗族侗族自治州|藏族自治州|蒙古族藏族自治州|柯尔克孜自治州|哈萨克自治州|自治州|省|市|地区|盟|县$/g, '');
const cities = [];
for (const province of adminTree) {
for (const city of province.children || []) {
const base = {
code: sixDigitCode(city.code),
provinceCode: sixDigitCode(province.code),
provinceName: province.name,
cityCode: sixDigitCode(city.code),
cityName: city.name === '市辖区' ? province.name : city.name,
cityLevel: 'PREFECTURE',
status: 'ENABLED'
};
cities.push(base);
if (/直辖县级行政区划/.test(city.name)) {
cities.pop();
for (const countyCity of city.children || []) {
cities.push({ ...base, code: sixDigitCode(countyCity.code), cityCode: sixDigitCode(countyCity.code), cityName: countyCity.name, cityLevel: 'COUNTY_DIRECT' });
}
}
}
}
const phoneBuffer = fs.readFileSync(String(args.phone));
const indexOffset = phoneBuffer.readUInt32LE(4);
const phoneRecords = [];
for (let offset = indexOffset; offset + 9 <= phoneBuffer.length; offset += 9) {
const segment7 = String(phoneBuffer.readUInt32LE(offset)).padStart(7, '0');
const dataOffset = phoneBuffer.readUInt32LE(offset + 4);
const end = phoneBuffer.indexOf(0, dataOffset);
const [provinceName, cityName, zipCode, areaCode] = phoneBuffer.subarray(dataOffset, end).toString('utf8').split('|');
phoneRecords.push({ segment7, provinceName, cityName, zipCode, areaCode, type: phoneBuffer[offset + 8] });
}
const byProvince = new Map();
for (const city of cities) {
const key = normalize(city.provinceName);
const values = byProvince.get(key) || [];
values.push(city);
byProvince.set(key, values);
}
function findCity(record) {
const provinceCities = byProvince.get(normalize(record.provinceName)) || [];
const aliasKey = `${normalize(record.provinceName)}|${normalize(record.cityName)}`;
const cityAliases = {
'山东|莱芜': '济南',
'新疆|巴州': '巴音郭楞',
'新疆|博州': '博尔塔拉',
'新疆|克州': '克孜勒苏',
'新疆|奎屯': '伊犁',
'青海|格尔木': '海西'
};
const cityName = cityAliases[aliasKey] || normalize(record.cityName || record.provinceName);
const exact = provinceCities.find((city) => normalize(city.cityName) === cityName);
if (exact) return exact;
const partial = provinceCities.filter((city) => normalize(city.cityName).startsWith(cityName) || cityName.startsWith(normalize(city.cityName)));
return partial.length === 1 ? partial[0] : provinceCities.length === 1 ? provinceCities[0] : null;
}
const directMatches = phoneRecords.map((record) => ({ record, city: findCity(record) }));
const areaVotes = new Map();
for (const { record, city } of directMatches) {
if (!city || !record.areaCode) continue;
const key = `${record.areaCode}|${city.cityCode}`;
areaVotes.set(key, (areaVotes.get(key) || 0) + 1);
}
const areaCity = new Map();
for (const [key, count] of areaVotes) {
const [areaCode, cityCode] = key.split('|');
const current = areaCity.get(areaCode);
if (!current || count > current.count) areaCity.set(areaCode, { cityCode, count });
}
const cityByCode = new Map(cities.map((city) => [city.cityCode, city]));
const unmatched = [];
const carrierMap = { 1: 'MOBILE', 2: 'UNICOM', 3: 'TELECOM', 4: 'MVNO', 5: 'MVNO', 6: 'MVNO', 7: 'BROADCAST', 8: 'MVNO' };
const segments = [];
for (const match of directMatches) {
if (!/^1\d{6}$/.test(match.record.segment7)) continue;
const city = match.city || cityByCode.get(areaCity.get(match.record.areaCode)?.cityCode);
if (!city) {
unmatched.push(match.record);
continue;
}
segments.push({
segment7: match.record.segment7,
cityCode: city.cityCode,
provinceName: city.provinceName,
cityName: city.cityName,
carrier: carrierMap[match.record.type] || 'UNKNOWN',
source: phoneSource,
batchId
});
}
const areaCodes = [...areaCity.entries()].map(([areaCode, vote]) => {
const city = cityByCode.get(vote.cityCode);
return { areaCode, cityCode: city.cityCode, provinceName: city.provinceName, cityName: city.cityName, source: phoneSource, batchId };
}).filter((item) => /^0\d{2,3}$/.test(item.areaCode));
const prefixVotes = new Map();
for (const segment of segments) {
const prefix = segment.segment7.slice(0, 3);
const key = `${prefix}|${segment.carrier}`;
prefixVotes.set(key, (prefixVotes.get(key) || 0) + 1);
}
const prefixWinners = new Map();
for (const [key, count] of prefixVotes) {
const [prefix, carrier] = key.split('|');
const current = prefixWinners.get(prefix);
if (!current || count > current.count) prefixWinners.set(prefix, { carrier, count });
}
const prefixes = [...prefixWinners.entries()].map(([prefix, value]) => ({ prefix, carrier: value.carrier, priority: 100, source: phoneSource, batchId }));
const report = {
batchId,
adminSource,
phoneSource,
phoneVersion: phoneBuffer.subarray(0, 4).toString('utf8'),
cities: cities.length,
sourcePhoneRecords: phoneRecords.length,
segments: segments.length,
unmatched: unmatched.length,
areaCodes: areaCodes.length,
prefixes: prefixes.length,
unmatchedLocations: [...new Map(unmatched.map(({ provinceName, cityName, areaCode }) => [`${provinceName}|${cityName}|${areaCode}`, { provinceName, cityName, areaCode }])).values()]
};
console.log(JSON.stringify(report, null, 2));
if (args['dry-run']) process.exit(0);
if (unmatched.length) throw new Error(`Refusing partial import: ${unmatched.length} phone segments could not be mapped to an administrative city.`);
const prisma = new PrismaClient();
try {
const current = {
cities: await prisma.geoCity.count(),
segments: await prisma.phoneNumberSegment.count(),
areaCodes: await prisma.phoneAreaCode.count(),
prefixes: await prisma.carrierPrefixRule.count()
};
if (Object.values(current).some(Boolean)) throw new Error(`Number library is not empty: ${JSON.stringify(current)}`);
await prisma.geoCity.createMany({ data: cities, skipDuplicates: true });
for (let offset = 0; offset < segments.length; offset += 1000) {
await prisma.phoneNumberSegment.createMany({ data: segments.slice(offset, offset + 1000), skipDuplicates: true });
}
await prisma.phoneAreaCode.createMany({ data: areaCodes, skipDuplicates: true });
await prisma.carrierPrefixRule.createMany({ data: prefixes, skipDuplicates: true });
await prisma.outboxEvent.create({
data: {
id: `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`,
aggregateType: 'number_library_config',
aggregateId: 'number-library',
eventType: 'number_library.seeded',
payload: report
}
});
console.log(JSON.stringify({ imported: report }, null, 2));
} finally {
await prisma.$disconnect();
}