
本文详解如何从 GitHub 开源的 countries.csv 文件中准确提取第 14 列(timezones)的非标准 JSON 字符串,通过正则与字符串预处理将其修复为合法 JSON,再批量解析为结构化时区对象数组。
本文详解如何从 github 开源的 `countries.csv` 文件中准确提取第 14 列(timezones)的非标准 json 字符串,通过正则与字符串预处理将其修复为合法 json,再批量解析为结构化时区对象数组。
在实际开发中,我们常需从公开数据集(如 dr5hn/countries-states-cities-database)获取地理元数据。其中 countries.csv 的第 14 列 timezones 存储了以类 JSON 形式表示的多个时区信息,但其格式并非合法 JSON:字段名无引号(如 zoneName)、字符串值使用单引号(如 'Asia -> Kabul')、且整体被包裹在方括号内(如 [{'zoneName':...}, {'zoneName':...}])。直接调用 json_decode() 会失败,必须先进行标准化转换。
✅ 正确处理流程
- 下载并读取 CSV 文件(推荐远程流式读取,避免本地文件依赖)
- 跳过表头,逐行解析第 14 列(索引 13)
-
对原始字符串执行三步清洗:
- 将单引号 ' 替换为双引号 "(适用于无嵌套单引号的场景)
- 为无引号的键名(如 zoneName, gmtOffset)添加双引号
- 确保首尾为合法 JSON 数组格式(如补全缺失的 [ 和 ])
以下为 Laravel 控制器中可直接复用的核心实现:
public function timezone(): array
{
$csvUrl = 'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/master/csv/countries.csv';
$timezones = [];
// 使用 file() 直接读取远程 CSV(生产环境建议加缓存或本地备份)
$lines = file($csvUrl, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!$lines) {
throw new RuntimeException('Failed to fetch countries.csv from GitHub');
}
// 跳过表头(第 0 行)
for ($i = 1; $i < count($lines); $i++) {
$row = str_getcsv($lines[$i]);
if (!isset($row[13]) || trim($row[13]) === '') continue;
$rawTimezones = trim($row[13]);
// 预处理:确保是数组格式,补全缺失的方括号
if (strpos($rawTimezones, '[') !== 0) {
$rawTimezones = '[' . $rawTimezones;
}
if (substr($rawTimezones, -1) !== ']') {
$rawTimezones .= ']';
}
// 修复为合法 JSON
$fixedJson = $this->fixJson($rawTimezones);
$decoded = json_decode($fixedJson, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
foreach ($decoded as $tz) {
if (is_array($tz) && isset($tz['zoneName'], $tz['gmtOffset'])) {
$timezones[] = $tz;
}
}
}
}
// 去重(基于 zoneName + gmtOffset 组合,避免重复时区)
$uniqueTimezones = [];
$seen = [];
foreach ($timezones as $tz) {
$key = $tz['zoneName'] . '|' . $tz['gmtOffset'];
if (!isset($seen[$key])) {
$seen[$key] = true;
$uniqueTimezones[] = $tz;
}
}
return $uniqueTimezones;
}
private function fixJson(string $str): string
{
// Step 1: 单引号 → 双引号(仅当不破坏内部转义时安全,本数据集无嵌套单引号)
$str = str_replace("'", '"', $str);
// Step 2: 为 unquoted keys 添加双引号,匹配形如 `key:` 或 `,key:` 的模式
// 使用正则:(?<=\{|\,) —— 前瞻断言,确保前面是 { 或 ,
// (\w+) —— 匹配键名(字母/数字/下划线)
// (?=\:) —— 后瞻断言,确保后面是冒号
return preg_replace('/(?<=\{|\,)(\w+)(?=\:)/', '"$1"', $str);
}⚠️ 关键注意事项
- 安全性提醒:str_replace("'", '"') 在数据含撇号(如 O'Neil)时会出错。本数据集的 zoneName、tzName 等字段不含单引号,故可安全使用;若未来数据变更,应改用更鲁棒的解析器(如自定义 tokenizer)。
- 性能优化:file() 会将整个 CSV 加载到内存。对于大文件,建议改用 fopen() + fgetcsv() 流式处理。
- 错误处理:务必检查 json_last_error(),避免静默失败;对空/无效字段做 isset() 和 trim() 防御。
- 去重逻辑:同一时区(如 Europe -> Berlin)可能在多国记录中重复出现,按 zoneName + gmtOffset 去重比单纯 array_unique() 更精准。
✅ 输出示例(片段)
调用 $this->timezone() 后,你将得到如下结构化的 PHP 数组(可直接 json_encode() 输出为 API 响应):
[
{
"zoneName": "Asia -> Kabul",
"gmtOffset": 16200,
"gmtOffsetName": "UTC+04:30",
"abbreviation": "AFT",
"tzName": "Afghanistan Time"
},
{
"zoneName": "Europe -> Tirane",
"gmtOffset": 3600,
"gmtOffsetName": "UTC+01:00",
"abbreviation": "CET",
"tzName": "Central European Time"
}
]该方案兼顾准确性、可维护性与 Laravel 生态兼容性,适用于国际化应用的时区初始化、下拉筛选等典型场景。


















