#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ hb_pack.py — сборка 53 срезов «машины времени» (/istoriya/) из historical-basemaps. Исходные данные: https://github.com/aourednik/historical-basemaps (A. Ourednik и участники), файлы geojson/world_*.geojson, лицензия GNU GPL v3. Наши производные срезы data/geo/history/*.json распространяются на тех же условиях — GNU GPL версии 3 (GPL-3.0-only: у источника голый текст GPLv3 без оговорки «или любой более поздней», значит этой опции нам не давали). Сам этот скрипт — наша работа, он под GPL-3.0-or-later. Текст лицензии: build/licenses/gpl-3.0.txt (редакция gnu.org), страница проекта: https://www.real-stats.ru/istoriya/litsenziya/. Гарантии не предоставляются (GPL-3.0 §15–16). Этот файл — часть исполнения GPL-3.0 §6(d): «Corresponding Source» к нашим производным при строгом прочтении лицензии. Он свободен на тех же условиях. ──────────────────────────────────────────────────────────────────────────────── ЧЕСТНО О ПРОИСХОЖДЕНИИ ФАЙЛА (не выдавать за оригинал) Срезы собраны 08–10.07.2026 разовым конвейером, который в репозиторий не попал (дефект, пойманный юр-проверкой 06.09.2026: набор в 38 МБ был невоспроизводим). Этот скрипт — РЕКОНСТРУКЦИЯ конвейера, восстановленная 06.09.2026 по фактическим данным: параметры подобраны так, чтобы выход сходился с опубликованными срезами. Сверка 06.09.2026: 53 из 53 срезов воспроизводятся ПОБАЙТОВО — команда `python3 tools/hb_pack.py fetch && python3 tools/hb_pack.py verify` (сверяются геометрия и имена; лицензионные поля, дописанные позже, при сверке не учитываются). То есть «реконструкция» здесь означает «написан позже», а не «примерно похоже». Сверка прошла на shapely 2.1.2 / GEOS 3.13.1 / Python 3.13.7. Без shapely скрипт работает на встроенном Дуглас-Пекере и даёт близкий, но ДРУГОЙ результат — он об этом предупреждает вслух. ──────────────────────────────────────────────────────────────────────────────── КОНВЕЙЕР geojson/world_<эпоха>.geojson (WGS84, EPSG:4326) → свойства NAME/SUBJECTO/PARTOF/BORDERPRECISION → поля n/s/p/b (пустые поля добираются по цепочке, правила — в pack_feature; территория без единого имени получает «—», и движок красит её как «нет данных») → геометрия Polygon/MultiPolygon → единый список полигонов g → упрощение геометрии: shapely .simplify(0.005) — это GEOS TopologyPreservingSimplifier (порог 0.005° по lon/lat), а НЕ голый Дуглас-Пекер. Разница не косметическая: с preserve_topology=False срез 1492 теряет 174 фичи вместо 43, потому что мелкие острова схлопываются в пустоту. Встроенный Дуглас-Пекер остаётся аварийным путём на случай, если shapely не установлен, — но он даёт близкий, а НЕ побайтово тот же результат: побайтовая сверка проходит только с shapely → округление координат до 0.001° (~110 м по меридиану) → точки, слипшиеся при округлении, склеиваются; кольцо короче 4 точек выбрасывается, фича без единого кольца выбрасывается целиком → data/geo/history/<год>.json одной строкой JSON, ASCII не экранируется ПЕРЕПРОЕКЦИИ НЕТ: и вход, и выход — WGS84 (EPSG:4326). В картографическую проекцию (Natural Earth I) геометрию переводит уже браузер средствами d3-geo. НАМОТКА КОЛЕЦ НЕ МЕНЯЕТСЯ: RFC7946-порядок исходника сохраняется как есть, разворотом «планета минус страна» занимается rewind() в js/history-map.js (грабли d3, КАРТА-ПРАВОК 2026-07-08). ЧИСЛА В ФАЙЛЕ НЕ ПРАВЯТСЯ: имена держав, точность границ и сама геометрия идут из источника без редактуры. Русские названия живут отдельно в data/geo/history/ru.json и на геометрию не влияют. Использование (из build/): python3 tools/hb_pack.py fetch # скачать исходники в кэш (53 файла, 71 МБ) python3 tools/hb_pack.py build [год ...] # собрать срезы в data/geo/history/ python3 tools/hb_pack.py verify [год ...] # сверить: выход == опубликованный срез python3 tools/hb_pack.py meta # обновить лицензионные поля во всех срезах python3 tools/hb_pack.py manifest # пересобрать MANIFEST.txt (опись для скачивания) python3 tools/hb_pack.py mirror # своя копия исходников в licenses/ (§6d) Ключи: --cache DIR (по умолчанию ./hb_cache), --ref КОММИТ (по умолчанию тот, из которого собран текущий набор), --out DIR. """ import argparse, json, os, subprocess, sys try: # GEOS — та же реализация, что в исходном конвейере from shapely.geometry import shape as shapely_shape, mapping as shapely_mapping except ImportError: # без shapely работает встроенный DP (см. simplify_geom) shapely_shape = shapely_mapping = None sys.stderr.write( "ВНИМАНИЕ: shapely не установлен. Упрощение пойдёт встроенным Дуглас-Пекером,\n" "и результат будет БЛИЗКИМ, НО НЕ ТЕМ ЖЕ: побайтовая сверка не пройдёт.\n" "Для воспроизведения набора: pip install shapely (сверялось на 2.1.2 / GEOS 3.13.1).\n") REPO = "https://github.com/aourednik/historical-basemaps" RAW = "https://raw.githubusercontent.com/aourednik/historical-basemaps" # Коммит источника, из которого собран опубликованный набор. На 10.07.2026 это был # HEAD ветки master, и он же остаётся HEAD на 06.09.2026 (последняя правка репозитория — # 26.01.2026). Пин обязателен: без него «воспроизводимость» превращается в лотерею. REF = "62d8f1a03a71f2d3ff17f2d166f7553f256bce68" REF_DATE = "2026-01-26" MIRROR_NAME = "historical-basemaps-62d8f1a0-geojson.tar.gz" # наша копия исходников, см. cmd_mirror EPS = 0.005 # порог Дуглас-Пекера, градусы PREC = 3 # знаков после запятой у координат (0.001° ≈ 110 м) MIN_RING = 4 # меньше 4 точек — не кольцо (замкнутый треугольник это ровно 4), выбрасываем SRC_LABEL = "historical-basemaps (A. Ourednik et al., GPL-3.0)" # Лицензионные поля, которые едут ВНУТРИ каждого файла: GPL-3.0 §4 требует давать # копию лицензии «on each copy», §5(a)(b) — «prominent notices» о факте и дате # изменения. Тот, кто скачал 1492.json прямой ссылкой, страницу сайта не увидит. LICENSE_FIELDS = { "license": "GPL-3.0-only", "license_url": "https://www.real-stats.ru/licenses/gpl-3.0.txt", "license_page": "https://www.real-stats.ru/istoriya/litsenziya/", "source": REPO + "/tree/" + REF + "/geojson (коммит от " + REF_DATE + ")", "modified": "2026-07-10, РеалСтат (real-stats.ru): упрощение геометрии " "(GEOS TopologyPreservingSimplifier, порог 0.005°), округление " "координат до 0.001°, переупаковка схемы GeoJSON в компактную " "(n/s/p/b/g), русские названия вынесены в ru.json. Геометрия из " "источника не правилась, перепроекции нет (WGS84 на входе и на " "выходе). Скрипт сборки: " "https://www.real-stats.ru/licenses/hb_pack.py — им же набор " "воспроизводится побайтово. Копия исходников: " "https://www.real-stats.ru/licenses/" + MIRROR_NAME, "warranty": "none", "warranty_ru": "Данные предоставляются «как есть», без каких-либо гарантий " "(GPL-3.0 §15–16). Исторические границы приблизительны.", } # Соответствие «файл источника → имя нашего среза» выводится из имени: world_1492 → 1492, # world_bc323 → bc323 (год -323). Список фиксирован — ровно те 53 файла, что лежат в источнике. EPOCHS = [ "bc123000", "bc10000", "bc8000", "bc5000", "bc4000", "bc3000", "bc2000", "bc1500", "bc1000", "bc700", "bc500", "bc400", "bc323", "bc300", "bc200", "bc100", "bc1", "100", "200", "300", "400", "500", "600", "700", "800", "900", "1000", "1100", "1200", "1279", "1300", "1400", "1492", "1500", "1530", "1600", "1650", "1700", "1715", "1783", "1800", "1815", "1880", "1900", "1914", "1920", "1930", "1938", "1945", "1960", "1994", "2000", "2010", ] def year_of(epoch): return -int(epoch[2:]) if epoch.startswith("bc") else int(epoch) # ─────────────────────────────────────────────────────────────── упрощение def simplify(pts, eps=EPS): """Дуглас-Пекер на плоскости lon/lat. Без рекурсии: у крупных колец источника десятки тысяч точек, рекурсивная реализация упирается в лимит стека.""" n = len(pts) if n < 3: return list(pts) keep = [False] * n keep[0] = keep[n - 1] = True stack = [(0, n - 1)] eps2 = eps * eps while stack: a, b = stack.pop() if b <= a + 1: continue ax, ay = pts[a] bx, by = pts[b] dx, dy = bx - ax, by - ay den = dx * dx + dy * dy best, bi = -1.0, -1 for i in range(a + 1, b): px, py = pts[i] if den == 0.0: d = (px - ax) ** 2 + (py - ay) ** 2 else: t = ((px - ax) * dx + (py - ay) * dy) / den t = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t) d = (px - ax - t * dx) ** 2 + (py - ay - t * dy) ** 2 if d > best: best, bi = d, i if best > eps2: keep[bi] = True stack.append((a, bi)) stack.append((bi, b)) return [pts[i] for i in range(n) if keep[i]] def simplify_geom(geom): """Упрощение геометрии фичи. Ровно то, что делает shapely.simplify() по умолчанию — TopologyPreservingSimplifier GEOS, а НЕ голый Дуглас-Пекер: он не схлопывает мелкие острова в пустоту и держит кольца валидными. Разница видна на глаз: с preserve_topology=False срез 1492 теряет 174 фичи вместо 43. Возвращает список полигонов (список колец).""" if shapely_shape is None: # фолбэк без shapely — свой DP gtype = geom.get("type") polys = [geom["coordinates"]] if gtype == "Polygon" else geom.get("coordinates", []) return [[simplify(r) for r in poly] for poly in polys] if gtype in ("Polygon", "MultiPolygon") else [] try: g = shapely_shape(geom).simplify(EPS) except Exception: return [] if g.is_empty: return [] m = shapely_mapping(g) if m["type"] == "Polygon": return [m["coordinates"]] if m["type"] == "MultiPolygon": return list(m["coordinates"]) return [] def pack_ring(ring): """Округлить координаты кольца до 0.001° и убрать точки, слипшиеся при округлении. None — если после этого кольца не осталось (замкнутое кольцо — минимум 4 точки). float() обязателен: у целых координат источника (180, -90 на кромке карты) round() вернул бы int, и в JSON поехало бы «180» вместо «180.0».""" r = [[round(float(x), PREC), round(float(y), PREC)] for x, y in ring] out = [r[0]] + [p for i, p in enumerate(r[1:], 1) if p != r[i - 1]] return out if len(out) >= MIN_RING else None def pack_feature(feat): """Фича GeoJSON → наша запись {n,s,p,b,g}. None — если геометрии не осталось.""" polys = simplify_geom(feat.get("geometry") or {}) if not polys: return None g = [] for poly in polys: rings = [r for r in (pack_ring(ring) for ring in poly) if r] if rings: g.append(rings) if not g: return None pr = feat.get("properties") or {} # Свойства источника заполнены неровно: где-то пусто NAME, где-то SUBJECTO или # PARTOF, у Данцига-1930 — BORDERPRECISION, а у одной фичи среза 700 имя # состоит из одиннадцати пробелов. Правила добора — ровно те, по которым собран # опубликованный набор (проверено сверкой всех 53 срезов): # n — первое непустое (после strip) из NAME → SUBJECTO → PARTOF, иначе «—»; # s — SUBJECTO, а если его нет — имя n; # p — PARTOF, а если его нет — имя n; strip применяется ПОСЛЕ подстановки, # поэтому пробельный PARTOF даёт пустую строку, а не имя; # b — точность границы, при отсутствии 3 («известна точно»): именно так # размечены все прочие фичи тех срезов, где такое встречается. def s_(v): return str(v).strip() if v is not None else "" n = s_(pr.get("NAME")) or s_(pr.get("SUBJECTO")) or s_(pr.get("PARTOF")) or "—" subj = s_(pr.get("SUBJECTO")) or n part = s_(pr.get("PARTOF") or n) b = pr.get("BORDERPRECISION") try: b = 3 if b is None else int(b) except (TypeError, ValueError): b = 3 return {"n": n, "s": subj, "p": part, "b": b, "g": g} def build_slice(src_path, year, with_license=True): with open(src_path, encoding="utf-8") as fh: src = json.load(fh) feats = [f for f in (pack_feature(x) for x in src.get("features", [])) if f] rec = {"year": year, "features": feats, "src": SRC_LABEL} if with_license: rec.update(LICENSE_FIELDS) return rec def dump(rec): """Одной строкой, без пробелов, ASCII не экранируется — так лежит текущий набор.""" return json.dumps(rec, ensure_ascii=False, separators=(",", ":")) # ─────────────────────────────────────────────────────────────── команды def cmd_fetch(args): os.makedirs(args.cache, exist_ok=True) need = [] for ep in EPOCHS: dst = os.path.join(args.cache, "world_%s.geojson" % ep) if os.path.exists(dst) and os.path.getsize(dst) > 1000: continue url = "%s/%s/geojson/world_%s.geojson" % (RAW, args.ref, ep) print(" ←", url) p = subprocess.run(["curl", "-sSL", "--fail", "-o", dst, url]) if p.returncode != 0 or not os.path.exists(dst) or os.path.getsize(dst) < 1000: need.append(ep) if need: # источник недоступен (удалён, переписан, сеть) — берём нашу копию # пиннутого дерева: §6(d) обязывает нас держать исходники доступными print(" не отдал источник (%d шт.) — беру копию с нашего домена" % len(need)) tgz = os.path.join(args.cache, MIRROR_NAME) murl = "https://www.real-stats.ru/licenses/" + MIRROR_NAME if subprocess.run(["curl", "-sSL", "--fail", "-o", tgz, murl]).returncode != 0: sys.exit("не скачался ни источник, ни копия %s" % murl) import tarfile with tarfile.open(tgz) as tar: for m in tar.getmembers(): if not m.isfile(): continue name = os.path.basename(m.name) with open(os.path.join(args.cache, name), "wb") as fh: fh.write(tar.extractfile(m).read()) os.remove(tgz) print("кэш готов:", args.cache) def _epochs(args): if not args.years: return EPOCHS want = set(args.years) return [e for e in EPOCHS if e in want or str(year_of(e)) in want] def cmd_build(args): os.makedirs(args.out, exist_ok=True) for ep in _epochs(args): src = os.path.join(args.cache, "world_%s.geojson" % ep) if not os.path.exists(src): sys.exit("нет исходника %s — сначала `hb_pack.py fetch`" % src) rec = build_slice(src, year_of(ep), with_license=not args.no_license) dst = os.path.join(args.out, "%s.json" % ep) with open(dst, "w", encoding="utf-8") as fh: fh.write(dump(rec)) print(" %-10s фич %5d %6d КБ" % (ep, len(rec["features"]), os.path.getsize(dst) // 1024)) def cmd_verify(args): """Сверка воспроизводимости: собранный срез против опубликованного. Лицензионные поля при сравнении не учитываются — сверяем геометрию и имена.""" ok = bad = miss = 0 for ep in _epochs(args): src = os.path.join(args.cache, "world_%s.geojson" % ep) pub = os.path.join(args.out, "%s.json" % ep) if not os.path.exists(src) or not os.path.exists(pub): print(" %-10s пропуск (нет исходника или среза)" % ep) miss += 1 continue mine = build_slice(src, year_of(ep), with_license=False) with open(pub, encoding="utf-8") as fh: theirs = json.load(fh) theirs = {"year": theirs["year"], "features": theirs["features"], "src": theirs.get("src")} if dump(mine) == dump(theirs): print(" %-10s ✓ побайтово" % ep) ok += 1 else: print(" %-10s ✗ расхождение (фич: %d против %d)" % (ep, len(mine["features"]), len(theirs["features"]))) bad += 1 print("итог: сошлось %d, разошлось %d, пропущено %d" % (ok, bad, miss)) return 1 if bad else 0 def cmd_meta(args): """Дописать/обновить лицензионные поля в уже лежащих срезах, не пересобирая геометрию. Для 14 срезов OpenHistoricalMap (CC0) поля другие — они не GPL.""" CC0 = { "license": "CC0-1.0", "license_url": "https://creativecommons.org/publicdomain/zero/1.0/", "license_page": "https://www.real-stats.ru/istoriya/litsenziya/", "source": "https://www.openhistoricalmap.org (Overpass API)", "modified": "2026-07-10, РеалСтат (real-stats.ru): выборка границ " "admin_level=2, живых на 1 июля года, сборка мультиполигонов из " "ways, упрощение геометрии Дуглас-Пекером (порог подбирается под " "размер файла), переупаковка схемы в компактную (n/s/p/b/g). " "Скрипт сборки: https://www.real-stats.ru/licenses/ohm_pack.py", "warranty": "none", "warranty_ru": "Данные предоставляются «как есть», без каких-либо гарантий. " "Исторические границы приблизительны.", } ORDER = ["year", "src", "license", "license_url", "license_page", "modified", "source", "warranty", "warranty_ru", "features"] n_gpl = n_cc0 = n_skip = 0 for name in sorted(os.listdir(args.out)): if not name.endswith(".json") or name in ("index.json", "ru.json"): continue path = os.path.join(args.out, name) with open(path, encoding="utf-8") as fh: rec = json.load(fh) src = rec.get("src", "") if "historical-basemaps" in src: rec.update(LICENSE_FIELDS); n_gpl += 1 elif "OpenHistoricalMap" in src or "CC0" in src: rec.update(CC0); n_cc0 += 1 else: print(" ?", name, "— неизвестный src, пропуск"); n_skip += 1 continue ordered = {k: rec[k] for k in ORDER if k in rec} ordered.update({k: v for k, v in rec.items() if k not in ordered}) with open(path, "w", encoding="utf-8") as fh: fh.write(dump(ordered)) # index.json — каталог срезов: он тоже уезжает к тому, кто качает набор idx_path = os.path.join(args.out, "index.json") if os.path.exists(idx_path): with open(idx_path, encoding="utf-8") as fh: idx = json.load(fh) idx["src"] = (REPO + " (GPL-3.0) + https://www.openhistoricalmap.org (CC0); " "лицензия конкретного среза — в полях license/source внутри него") idx["license"] = "GPL-3.0-only (53 среза historical-basemaps) / CC0-1.0 (14 срезов OpenHistoricalMap)" idx["license_url"] = LICENSE_FIELDS["license_url"] idx["license_page"] = LICENSE_FIELDS["license_page"] idx["modified"] = LICENSE_FIELDS["modified"] idx["warranty"] = "none" idx["warranty_ru"] = LICENSE_FIELDS["warranty_ru"] order = ["license", "license_url", "license_page", "modified", "source", "warranty", "warranty_ru", "src", "years"] idx = {k: idx[k] for k in order if k in idx} | {k: v for k, v in idx.items() if k not in order} with open(idx_path, "w", encoding="utf-8") as fh: fh.write(dump(idx)) print("index.json: лицензионные поля проставлены") # ru.json — наш словарь русских названий. Ключи в нём — имена держав из обоих # источников, поэтому распространяем его на условиях GPL-3.0 (строгий выбор: # он же совместим с CC0-частью). Служебные ключи начинаются с «_» — движок # читает словарь только по имени державы и на них не наткнётся. ru_path = os.path.join(args.out, "ru.json") if os.path.exists(ru_path): with open(ru_path, encoding="utf-8") as fh: rud = json.load(fh) rud["_license"] = "GPL-3.0-only" rud["_license_url"] = LICENSE_FIELDS["license_url"] rud["_license_page"] = LICENSE_FIELDS["license_page"] rud["_source"] = ("русские названия — работа РеалСтат (real-stats.ru); ключи словаря — " "названия держав из " + REPO + " (GPL-3.0-only) и " "https://www.openhistoricalmap.org (CC0)") rud["_modified"] = ("2026-07-10, РеалСтат (real-stats.ru): составление словаря " "русских названий; поля лицензии проставлены 2026-09-06") rud["_warranty"] = "none" with open(ru_path, "w", encoding="utf-8") as fh: fh.write(dump(rud)) print("ru.json: лицензионные поля проставлены") print("лицензионные поля: GPL-3.0 в %d срезах, CC0 в %d, пропущено %d" % (n_gpl, n_cc0, n_skip)) def cmd_mirror(args): """Своя копия ИСХОДНИКОВ на нашем домене. Зачем: §6(d) GPL заканчивается словами «Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed». Сослаться на чужой репозиторий мало — если автор удалит его или перепишет историю, из соответствия выпадем МЫ, а не он. Поэтому пиннутое дерево источника лежит копией у нас. Архив собирается воспроизводимо: имена отсортированы, время файлов и владелец обнулены, gzip без отметки времени — пересборка даёт тот же sha256. """ import hashlib, tarfile, io names = ["world_%s.geojson" % e for e in EPOCHS] missing = [n for n in names if not os.path.exists(os.path.join(args.cache, n))] if missing: sys.exit("нет исходников в кэше (%d шт.) — сначала `hb_pack.py fetch`" % len(missing)) out = os.path.join(args.mirror_dir, MIRROR_NAME) os.makedirs(args.mirror_dir, exist_ok=True) raw = io.BytesIO() with tarfile.open(fileobj=raw, mode="w") as tar: for n in names: # порядок фиксирован списком EPOCHS src = os.path.join(args.cache, n) info = tarfile.TarInfo("historical-basemaps-%s/geojson/%s" % (REF[:8], n)) info.size = os.path.getsize(src) info.mtime = 0 info.mode = 0o644 info.uid = info.gid = 0 info.uname = info.gname = "" with open(src, "rb") as fh: tar.addfile(info, fh) import gzip as _gzip with open(out, "wb") as fh: gz = _gzip.GzipFile(fileobj=fh, mode="wb", compresslevel=9, mtime=0) gz.write(raw.getvalue()) gz.close() digest = hashlib.sha256(open(out, "rb").read()).hexdigest() with open(out + ".sha256", "w", encoding="utf-8") as fh: fh.write("%s %s\n" % (digest, MIRROR_NAME)) print("зеркало исходников: %s — %.1f МБ, sha256 %s" % (out, os.path.getsize(out) / 1e6, digest[:16] + "…")) def cmd_manifest(args): """Опись набора: что лежит, сколько весит, под какой лицензией и по какому адресу качается. Нужна не для красоты — GPL-3.0 §6(d) требует «clear directions next to the object code», где взять исходники, а §4 — отдавать копию лицензии ВМЕСТЕ с работой. Поэтому в описи не только данные: текст лицензии, скрипты сборки и копия исходников перечислены здесь же и скачиваются той же командой. Файл лежит РЯДОМ с данными — его увидит и тот, кто пришёл прямой ссылкой.""" site = "https://www.real-stats.ru" base = "/data/geo/history/" rows, total = [], 0 for name in sorted(os.listdir(args.out)): if not name.endswith(".json"): continue path = os.path.join(args.out, name) with open(path, encoding="utf-8") as fh: rec = json.load(fh) lic = rec.get("license") or rec.get("_license") or "?" size = os.path.getsize(path) total += size rows.append((name, size, lic, site + base + name)) # сопроводительные файлы: без них «скачал набор» не значит «получил лицензию» extra = [] for name, lic, note in ( ("gpl-3.0.txt", "текст лицензии", "GNU GPL v3, редакция gnu.org"), ("hb_pack.py", "GPL-3.0-or-later", "скрипт сборки срезов historical-basemaps"), ("ohm_pack.py", "GPL-3.0-or-later", "скрипт сборки срезов OpenHistoricalMap"), (MIRROR_NAME, "GPL-3.0-only", "копия исходников: пиннутое дерево geojson/"), (MIRROR_NAME + ".sha256", "—", "контрольная сумма копии исходников")): fp = os.path.join(args.mirror_dir, name) if os.path.exists(fp): extra.append((name, os.path.getsize(fp), lic, site + "/licenses/" + name, note)) w = max([len(r[0]) for r in rows] + [len(e[0]) for e in extra]) body = "\n".join("%-*s %9d Б %-18s %s" % (w, n, sz, lic, u) for n, sz, lic, u in rows) extra_body = "\n".join("%-*s %9d Б %-18s %s\n%s— %s" % (w, n, sz, lic, u, " " * (w + 32), note) for n, sz, lic, u, note in extra) text = """РеалСтат · «Машина времени» (%(site)s/istoriya/) Опись набора исторических границ. Составлена скриптом hb_pack.py — руками не правится. ЛИЦЕНЗИИ 53 среза — GNU GPL версии 3 (SPDX: GPL-3.0-only). Производные от historical-basemaps (A. Ourednik и участники), репозиторий <%(repo)s>, коммит %(ref)s от %(refdate)s. «Или любой более поздней версии» в источнике не заявлено — значит и мы этой опции не даём: только версия 3. Текст лицензии: %(site)s/licenses/gpl-3.0.txt 14 срезов — CC0-1.0. Производные от OpenHistoricalMap (%(ohm)s). ru.json — словарь русских названий, работа РеалСтат, GPL-3.0-only. Скрипты сборки — наша работа, GPL-3.0-or-later. Лицензия каждого файла продублирована ВНУТРИ него (поля license, source, modified, warranty) — уведомление едет вместе с копией, а не только на сайте. ЧТО ИЗМЕНЕНО ОТНОСИТЕЛЬНО ИСТОЧНИКА (10.07.2026, РеалСтат) упрощение геометрии (порог 0.005°) · округление координат до 0.001° · переупаковка схемы GeoJSON в компактную (n/s/p/b/g) · русские названия вынесены в отдельный файл. Геометрия не редактировалась, перепроекции нет (WGS84 → WGS84). КАК ВОСПРОИЗВЕСТИ НАБОР ИЗ ОРИГИНАЛА curl -sSLO %(site)s/licenses/hb_pack.py python3 hb_pack.py fetch # исходники (71 МБ): из репозитория-источника, # а если он недоступен — из нашей копии python3 hb_pack.py build # те же срезы python3 hb_pack.py verify # сверка с опубликованными: 53 из 53 побайтово Нужен shapely (сверялось на 2.1.2 / GEOS 3.13.1); без него результат будет другим. КАК СКАЧАТЬ ВСЁ — ДАННЫЕ, ЛИЦЕНЗИЮ И СКРИПТЫ ОДНОЙ КОМАНДОЙ curl -sSL %(site)s%(base)sMANIFEST.txt | awk '$NF ~ /^https:/ {print $NF}' | xargs -n1 -P4 curl -sSLO БЕЗ ГАРАНТИЙ Данные предоставляются «как есть», без каких-либо гарантий, в том числе гарантий пригодности для конкретной цели (GPL-3.0 §15–16). Границы историчны и приблизительны. СОПРОВОДИТЕЛЬНЫЕ ФАЙЛЫ (лицензия, скрипты, копия исходников) %(extra)s СРЕЗЫ И СЛОВАРЬ (%(n)d, всего %(mb)s МБ) %(body)s """ % {"site": site, "repo": REPO, "ref": REF, "refdate": REF_DATE, "ohm": "https://www.openhistoricalmap.org", "base": base, "n": len(rows), "mb": ("%.1f" % (total / 1e6)).replace(".", ","), "body": body, "extra": extra_body} dst = os.path.join(args.out, "MANIFEST.txt") with open(dst, "w", encoding="utf-8") as fh: fh.write(text) print("MANIFEST.txt: %d файлов данных + %d сопроводительных, %.1f МБ" % (len(rows), len(extra), total / 1e6)) def main(): ap = argparse.ArgumentParser(description="Сборка срезов /istoriya/ из historical-basemaps") ap.add_argument("cmd", choices=["fetch", "build", "verify", "meta", "manifest", "mirror"]) ap.add_argument("years", nargs="*", help="эпохи (1492, bc323) — по умолчанию все 53") ap.add_argument("--cache", default="./hb_cache") ap.add_argument("--out", default="./data/geo/history") ap.add_argument("--mirror-dir", default="./licenses", help="куда класть копию исходников") ap.add_argument("--ref", default=REF, help="коммит источника") ap.add_argument("--no-license", action="store_true", help="build без лицензионных полей") args = ap.parse_args() sys.exit({"fetch": cmd_fetch, "build": cmd_build, "verify": cmd_verify, "meta": cmd_meta, "manifest": cmd_manifest, "mirror": cmd_mirror}[args.cmd](args) or 0) if __name__ == "__main__": main()