Назад в Центр знаний
Отпечатки

ICU: псевдонимы часовых поясов и TZ, который вы отдаете

Спросите ICU, какую идентичность часового пояса читает страница: как схлопываются псевдонимы, когда побеждает TZ и почему фиксированное смещение совпадает с летним поясом.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser поддерживает повтор разрешенного fixture на том же origin под объявленным профилем и сохраняет ограниченные наблюдения о том, что сообщает страница, но он не может заставить ICU вернуть имя, с которым он не собран, изменить, какое написание конкретная версия ICU считает каноническим, или сделать пояс с фиксированным смещением похожим на пояс с переводом часов только потому, что в январе оба читают одинаково. Этот разбор запускает ICU из Node на фиксированном списке имен поясов, сообщает, что отвечает Intl.DateTimeFormat().resolvedOptions().timeZone для каждого, и разделяет три вещи, которые обычно смешивают: имя, которое читает страница, смещение, которое она выводит, и переменную окружения, которая дала значение по умолчанию.

TL;DR

  • Почти все исторические написания еще работают, но ICU отвечает своим каноническим именем: Asia/Kolkata сообщает Asia/Calcutta, Europe/Kyiv сообщает Europe/Kiev.
  • UTC, Etc/UTC, Etc/GMT, GMT и Zulu схлопываются в UTC, поэтому сообщаемая строка их не различает.
  • Установка process.env.TZ меняет значение по умолчанию для следующего вызова Intl; явная опция timeZone побеждает для этого вызова и не меняет значение по умолчанию.
  • Etc/GMT+5 и America/New_York дают одинаковые часы в январе и расходятся на час в июле, поэтому одной зимней пробы недостаточно, чтобы отделить фиксированное смещение от пояса с переводом часов.
  • Смещения не ограничены целыми часами: Asia/Katmandu сообщает GMT+5:45, а Pacific/Chatham сообщает GMT+13:45.
  • Все это свойство конкретной пары ICU и tzdata; о том, как сайт оценивает сессию, это не говорит ничего.

Contents

TZ_ENV питает DEFAULT_ZONE, явная опция его перекрывает, а ICU канонизирует ASIA_KOLKATA в ASIA_CALCUTTA

Какой ICU использовался

Проба печатает версии ICU и tzdata до всего остального, потому что каждое утверждение ниже ограничено этой парой. Запуск сообщил icu=78.3, tz=2026a, на node=v26.9.0. Другая сборка ICU может нести другую каноническую таблицу: написание, которое сегодня считается псевдонимом, может стать каноническим позже, и такое случалось при выводе устаревших имен.

Эта строка с версиями самый дешевый способ держать разбор часовых поясов честным. Отчет, который говорит, что страница «сообщает Europe/Kiev», не указывая, какой ICU и какой tzdata это дали, нельзя перепроверить, потому что ответ это поиск в таблице, поставляемой с рантаймом, а не вычисление по часам. Запись обеих версий превращает рассказ в воспроизводимое наблюдение и объясняет, почему две машины с одной версией браузера могут расходиться.

Спрашивайте у ICU каноническое имя, а не входное

Проба пропускает 21 имя через new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone. Интересное свойство в том, что ответ часто не равен входу:

Asia/Calcutta -> Asia/Calcutta
Asia/Kolkata -> Asia/Calcutta
Europe/Kiev -> Europe/Kiev
Europe/Kyiv -> Europe/Kiev
Asia/Rangoon -> Asia/Rangoon
Asia/Yangon -> Asia/Rangoon
Asia/Saigon -> Asia/Saigon
Asia/Ho_Chi_Minh -> Asia/Saigon
Pacific/Ponape -> Pacific/Ponape
Pacific/Pohnpei -> Pacific/Ponape

Каждая пара это один пояс под старым и новым написанием, и ICU сводит оба к одному каноническому имени. Направление важно: в этой сборке побеждает старое написание, поэтому страница, сообщающая Asia/Calcutta, вполне могла быть настроена на Asia/Kolkata, а страница с Europe/Kiev могла быть настроена на Europe/Kyiv. Сообщаемая строка свидетельствует о том, во что канонизирует этот ICU, а не о том, что передал вызывающий код.

Схлопывание не ограничено устаревшими именами. UTC, Etc/UTC, Etc/GMT, GMT и Zulu все возвращают UTC, а US/Eastern возвращает America/New_York. Пять разных входов дают один выход, поэтому сравнение, которое читает только разрешенное имя, их не различит, а запись, хранящая только это поле, потеряла сведения, которые уже не восстановит.

Десять из 21 проверенного имени возвращаются точно как написаны, больше, чем следует из пар выше: UTC, Etc/GMT+5, Etc/GMT-5, Asia/Calcutta, Europe/Kiev, America/Buenos_Aires, America/New_York, Asia/Rangoon, Pacific/Ponape и Asia/Saigon. Буквальная группа не означает «уже каноническое», она означает «уже то написание, которое предпочитает эта таблица». Оба варианта GMT в ней и при этом несут противоположные смещения. Это разные пояса с противоположными смещениями, и соглашение POSIX меняет знак относительно привычки. В парном тесте ниже Etc/GMT+5 выводит 07:00, когда UTC равен 12:00, а Etc/GMT-5 выводит 17:00.

Неизвестное имя падает громко, вместо того чтобы нормализоваться: Mars/Olympus бросает RangeError, и более близкие опечатки Asia/Calcuttta, America/New_Yrok, Europe/Kievv и Europe/Berlin1 тоже. Разрешаются только варианты регистра, поэтому asia/kolkata и ASIA/KOLKATA оба отвечают Asia/Calcutta; имя с лишними пробелами отклоняется как любая другая неизвестная строка. Следствие уже, чем кажется: неверно написанный пояс отклоняется, а не отображается молча, но правильно написанный псевдоним переназначается молча, и страница не отличит два исхода по одной сообщаемой строке. Значит, страница с правдоподобным поясом прошла одну проверку, что имя существует, и не провалила ни одной, потому что сообщаемое имя все еще может отличаться от настроенного.

Посчитайте, сколько входов попадает в одну группу

Структуру псевдонимов проще понять, если считать, а не читать по одному имени. Второй проход группирует девять кандидатов по разрешенному ответу:

bucket Asia/Calcutta <- Asia/Kolkata, Asia/Calcutta
bucket REJECTED <- Asia/Bombay
bucket Asia/Dhaka <- Asia/Dacca, Asia/Dhaka
bucket Europe/Kiev <- Europe/Kyiv, Europe/Kiev, Europe/Uzhgorod, Europe/Zaporozhye

Из этих четырех строк следуют четыре находки. Индийский пояс принимает два написания; длинное имя для Asia/Calcutta это India Standard Time, а смещение GMT+5:30, поэтому старое имя не описывает другую страну. Старое имя Мумбаи Asia/Bombay в этой таблице отсутствует и отклоняется сразу, что показывает: таблица не является полной историей всех когда-либо использованных имен. Дакка принимает старое написание Asia/Dacca. А пояс Киева принимает четыре входа, включая два города на западе Украины, которые географически разные, но разрешаются в одну каноническую запись.

Последняя группа ломает наивное рассуждение. Кто считает, что каноническое имя описывает место, прочтет Europe/Kiev как «сессия утверждает, что она в Киеве». В этой сборке оно покрывает и сессию, настроенную на Europe/Uzhgorod, примерно в 600 километрах. Каноническая строка это запись таблицы, а не утверждение о том, где стоит машина.

Случай отказа полезен как контрпример по другой причине. Если бы таблица принимала все исторические псевдонимы, отказ был бы сильным доказательством неверной настройки. Поскольку это не так, отказ доказывает лишь, что конкретного написания нет в этой сборке ICU. Безопасное утверждение узкое: этот вход здесь не принят, значит сообщаемый пояс пришел другим путем.

Где действительно побеждает переменная TZ

Вторая проба отделяет значение по умолчанию от явной опции, и именно здесь рождается большая часть путаницы о том, «в каком поясе этот браузер».

TZ as inherited: resolved=America/Toronto toTimeString=07:00:00 GMT-0500 offset=GMT-5
TZ set at runtime: resolved=Asia/Tokyo toTimeString=21:00:00 GMT+0900 offset=GMT+9
explicit option wins over env: Europe/Berlin
default after explicit call still: Asia/Tokyo
invalid zone rejected: RangeError
long name part: Eastern Standard Time

В этих шести строках видно три поведения. Мутация process.env.TZ во время выполнения меняет то, что сообщает следующий вызов Intl без опций, и в resolvedOptions(), и в toTimeString(), поэтому значение окружения в этом рантайме не фиксируется при старте процесса. Явная опция timeZone побеждает окружение для этого вызова и оставляет значение по умолчанию нетронутым для следующих вызовов, которые все еще сообщают Asia/Tokyo. А длинное имя для America/New_York в январе это Eastern Standard Time, имя, которое само меняется с датой.

Следствие для fixture: «страница сообщает пояс X» двусмысленно, если запись не говорит, как X был задан. Процесс, запущенный с TZ и без опции в вызове, сообщает значение окружения; страница с явной опцией сообщает решение в коде. Оба дают одну и ту же строку в resolvedOptions().timeZone, когда совпадают, и только записанный вход их различает. Поэтому отчет поддержки, который цитирует только пояс, нельзя воспроизвести: одна и та же строка приходит по любому из двух путей, и первый вопрос в том, где значение попало в процесс.

Есть ловушка второго порядка в той же области. Поскольку TZ читает рантайм, а не страница, значение, унаследованное из профиля оболочки, может пережить тест, который считал, что проверяет значение из профиля браузера. Проба показывает унаследованный пояс как America/Toronto, он пришел из окружения хоста, а не из опции страницы. Fixture, который записывает только разрешенный пояс, припишет это тестируемому профилю. Проверка окружения до запуска и отметка, задана ли переменная при старте или изменена позже, это разница между измерением профиля и измерением оболочки.

Явная опция также не переписывает значение по умолчанию навсегда. Проба задает значение по умолчанию через TZ, форматирует с явным поясом, затем спрашивает новый форматер о значении по умолчанию и снова получает значение окружения. Два последовательных чтения в одном скрипте могут поэтому расходиться, если одно передало опцию, и сравнение, которое чередует обе формы вызова без пометок, будет выглядеть так, будто пояс изменился между чтениями.

Читайте смещение, а не только имя

Идентичность пояса обычно записывают одной строкой, но один и тот же пояс может выводить шесть форм имени в зависимости от вопроса:

name shortOffset: GMT-5
name longOffset: GMT-05:00
name short: EST
name long: Eastern Standard Time
name shortGeneric: ET
name longGeneric: Eastern Time

Это не взаимозаменяемые свидетельства. GMT-5 и GMT-05:00 несут смещение и не несут сезон, поэтому стабильны весь год. EST встраивает зимнее обозначение и в июле читался бы как EDT. ET и Eastern Time нейтральны к сезону и подходят к обоим. Отчет, который записывает «страница говорит EST» и затем сравнивает с летней пробой, сравнил две разные метки и может показать ложное изменение.

Смещения также не ограничены целыми часами, хотя это распространенное допущение ломается на реальных поясах:

zone Asia/Kathmandu -> Asia/Katmandu GMT+5:45
zone Australia/Eucla -> Australia/Eucla GMT+8:45
zone Pacific/Chatham -> Pacific/Chatham GMT+13:45
zone Asia/Kolkata -> Asia/Calcutta GMT+5:30

Катманду вдобавок канонизируется в другое написание, Asia/Katmandu, и выводит смещение в 45 минут. Чатем выводит GMT+13:45 в январе, потому что его правила перевода часов относятся к южному полушарию. Валидатор, ожидающий смещений по целым часам или положительного знака только ниже определенного размера, прочтет все четыре строки пробы неверно, не сообщив об ошибке.

Измерение локали добавляет третью переменную. Один и тот же момент и пояс выводятся по-разному в зависимости от локали, и меняется и порядок, и соглашение о часах:

locale en-US: hourCycle=h12 hour12=true -> Jan 15, 2026, 7:00 AM
locale en-GB: hourCycle=h23 hour12=false -> 15 Jan 2026, 07:00
locale de-DE: hourCycle=h23 hour12=false -> 15.01.2026, 07:00
locale fr-FR: hourCycle=h23 hour12=false -> 15 janv. 2026, 07:00
locale ja-JP: hourCycle=h23 hour12=false -> 2026/01/15 7:00

en-US единственная строка с 12-часовым циклом, а ja-JP печатает 7:00 без ведущего нуля, тогда как другие строки h23 печатают 07:00. Сравнение, которое нормализует даты как строки между локалями, отметит четыре различия, и все они относятся к формату, а не к состоянию сессии.

Где фиксированное смещение имитирует пояс с переводом часов

Последняя секция берет две даты с разницей в год, это самый дешевый способ отделить фиксированное смещение от пояса, который переводит часы:

dst America/New_York: 07:00 / 08:00
dst Etc/GMT+5: 07:00 / 07:00
dst Europe/Berlin: 13:00 / 14:00

America/New_York и Etc/GMT+5 оба выводят 07:00 для январского момента, поэтому одна зимняя проба их не разделяет. В июле они расходятся на час, и Europe/Berlin показывает такой же сдвиг с 13:00 на 14:00. Отчет, который берет один момент и объявляет пояс согласованным, измерил совпадение на одну дату, а не идентичность пояса.

Парный тест смещений говорит то же с другой стороны:

pair Etc/GMT+5 vs America/New_York: 07:00 / 07:00
pair Etc/GMT-5 vs Asia/Karachi: 17:00 / 17:00
pair UTC vs Etc/UTC: 12:00 / 12:00

Каждая пара совпадает на взятом моменте, описывая разные пояса. Две из трех пар совпали бы и в июле, только первая разошлась бы, потому что только одна сторона переводит часы. Поэтому сравнение только зимой это ловушка: оно дает согласованный результат для каждой пары в этой таблице, включая ту, что разойдется через полгода.

Если fixture может позволить только одну пробу, честное утверждение звучит как «эти две совпали на эту дату». Вторая проба в противоположный сезон стоит почти ничего и превращает его в «эти две описывают одни и те же правила смещения», а именно это большинство разборов часовых поясов и хочет утверждать.

Южное полушарие делает правило отбора строже, чем «возьмите лето и зиму». Pacific/Chatham выводит GMT+13:45 в январе, а это его лето, поэтому читатель из северного полушария, принявший значение за зимнее, перепутал сезон. Australia/Eucla держит постоянное GMT+8:45, потому что не сдвигается вовсе. Пара проб с разницей в полгода все равно их различает, но только если запись хранит момент рядом с выведенным значением, а не помечает одну пробу как «зима» по предположению.

Выбор моментов тоже важен. Проба в 12:00 UTC оставляет две даты чисто сравнимыми, тогда как проба около местной полуночи может сдвинуть и дату, и час, и сравнение, хранящее только час, прочтет сдвиг даты как пропавший день. Запись точного момента ISO снимает обе двусмысленности без лишних затрат.

Часовой пояс это одно поле рядом с двумя соседями, которые следуют другим правилам, поэтому fixture, записывающий только его, неполон. Часовой пояс, локаль и язык описывает взаимодействие трех, а navigator language и локализация приложений показывает, что список языков имеет собственную задачу канонизации, где упорядоченный массив сравнивают с одним согласованным заголовком.

Как читать идентичность пояса без преувеличения

Чтение пояса подтверждает узкое утверждение: эта сборка ICU с этим входом сообщила это каноническое имя и это выведенное смещение для этого момента. Оно не утверждает, какой псевдоним был настроен, переводит ли пояс часы, какая локаль дала строку и как сайт обходится с сессией, которая его сообщает.

Храните пять полей в записи, и чтение останется проверяемым. Версии ICU и tzdata, потому что каноническая таблица и правила перевода часов идут с ними. Входное имя как передано, потому что канонизация его отбрасывает. Взятый момент, потому что фиксированное смещение и пояс с переводом часов совпадают зимой. Форма вызова, потому что и значение окружения, и явная опция заполняют одно и то же поле вывода. И локаль с ее hourCycle, потому что выведенная строка зависит от обеих.

Страница документации описывает, как BotBrowser держит часовой пояс, локаль и язык согласованными с загруженным профилем. Этот разбор добавляет сторону чтения: что ICU делает с именами, которые дают эти настройки, какие написания схлопываются в один ответ, сколько входов делят группу и где одна проба не различает два разных пояса. Согласованная настройка и правильная интерпретация сообщаемой строки это разные вопросы, и fixture, который хранит только разрешенное имя, не отвечает полностью ни на один.

Для любого решения о реальной сессии используйте разрешенный интеграционный результат системы, которая решает, вместо экстраполяции из этой пробы. Каноническое имя, записанное в fixture, и производственный вердикт это разные виды свидетельства, и именно в разрыве между ними происходит преувеличение.

Practical conclusion

Записывайте версии ICU и tzdata, входное имя, взятый момент, форму вызова и локаль с ее циклом часов. В этой сборке старые написания побеждают канонизацию в четырех группах, Asia/Bombay отклоняется сразу, пять входов вида UTC схлопываются в UTC, TZ задает значение по умолчанию, которое явная опция перекрывает без изменения, смещения включают значения в 45 минут, а фиксированное смещение совпадает с поясом с переводом часов в январе и расходится в июле.

Sources

Next reading

Используйте часовой пояс, локаль и язык для взаимодействия трех полей и navigator language и локализацию приложений для языковой стороны того же профиля.

#ICU#Часовой Пояс#Intl#fingerprinting#Идентичность Браузера

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.