fix: harden document normalization
Reject overlapping source and destination roots before any write so normalization cannot replace the raw Ministry archive. Recover an interrupted directory publication before resume checks and require the normalized target to exist before skipping a manifest success. Treat malformed link and image URLs as unsafe attributes, add regressions for all review findings, and bump the backend version to 0.2.2.
This commit is contained in:
@@ -10,14 +10,14 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||
`0.2.1`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР.
|
||||
`0.2.2`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР.
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.2.1` | реализованы выгрузка и нормализация документов ЦБД Минюста КР |
|
||||
| Backend | `0.2.2` | реализованы выгрузка и нормализация документов ЦБД Минюста КР |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Сбор и обработка правовых данных | `0.2.1` | реализованы архиватор и нормализатор ЦБД Минюста КР |
|
||||
| Сбор и обработка правовых данных | `0.2.2` | реализованы архиватор и нормализатор ЦБД Минюста КР |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
## Структура репозитория
|
||||
@@ -58,4 +58,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.2.1`
|
||||
Версия: `0.2.2`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -68,6 +68,8 @@ python3 backend/normalization/minjust_cbd.py
|
||||
в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и
|
||||
`--output`; `--refresh` принудительно обрабатывает неизменившиеся документы,
|
||||
`--log-level` задаёт уровень журнала.
|
||||
Каталоги `--input` и `--output` не должны совпадать, содержать друг друга или
|
||||
пересекаться через разрешённые абсолютные пути.
|
||||
|
||||
```text
|
||||
data/minjust-normalized/
|
||||
@@ -98,4 +100,4 @@ PYTHONPATH=backend python3 -m unittest backend/test_minjust_normalization.py -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
APP_VERSION = "0.2.1"
|
||||
APP_VERSION = "0.2.2"
|
||||
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_VERSION = "0.2.1"
|
||||
APP_VERSION = "0.2.2"
|
||||
SCHEMA_VERSION = "1"
|
||||
NORMALIZER_VERSION = "1.0.0"
|
||||
LANGUAGES = ("ru", "ky")
|
||||
@@ -234,7 +234,10 @@ class SafeHtmlParser(HTMLParser):
|
||||
return safe
|
||||
|
||||
def safe_image(self, value: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
|
||||
return False
|
||||
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
|
||||
@@ -249,7 +252,10 @@ def safe_link(value: str) -> bool:
|
||||
value = value.strip()
|
||||
if not value or value.startswith(("//", "\\\\")):
|
||||
return False
|
||||
parsed = urlsplit(value)
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
|
||||
not parsed.scheme and parsed.netloc
|
||||
)
|
||||
@@ -531,6 +537,23 @@ def publish_directory(staged: Path, target: Path) -> None:
|
||||
shutil.rmtree(backup)
|
||||
|
||||
|
||||
def recover_directory(target: Path) -> None:
|
||||
backup = target.parent / f".{target.name}.previous"
|
||||
if not backup.exists():
|
||||
return
|
||||
if target.exists():
|
||||
shutil.rmtree(backup)
|
||||
else:
|
||||
os.replace(backup, target)
|
||||
|
||||
|
||||
def validate_roots(input_root: Path, output: Path) -> None:
|
||||
source = input_root.resolve()
|
||||
destination = output.resolve()
|
||||
if source == destination or source.is_relative_to(destination) or destination.is_relative_to(source):
|
||||
raise ValueError("--input and --output must not overlap")
|
||||
|
||||
|
||||
def normalize_archive(
|
||||
input_root: Path = Path("data/minjust-cbd"),
|
||||
output: Path = Path("data/minjust-normalized"),
|
||||
@@ -538,6 +561,7 @@ def normalize_archive(
|
||||
refresh: bool = False,
|
||||
progress: Callable[[NormalizeResult, int], None] | None = None,
|
||||
) -> NormalizeResult:
|
||||
validate_roots(input_root, output)
|
||||
document_root = input_root / "documents"
|
||||
if not document_root.is_dir():
|
||||
raise FileNotFoundError(f"Document directory not found: {document_root}")
|
||||
@@ -563,10 +587,12 @@ def normalize_archive(
|
||||
for source_directory in directories:
|
||||
discovered += 1
|
||||
code = source_directory.name
|
||||
target = output / "documents" / code
|
||||
checksum = None
|
||||
try:
|
||||
recover_directory(target)
|
||||
files, checksum = source_inventory(source_directory, input_root)
|
||||
if not refresh and known.get(code) == (
|
||||
if target.is_dir() and not refresh and known.get(code) == (
|
||||
checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success"
|
||||
):
|
||||
skipped += 1
|
||||
@@ -574,7 +600,7 @@ def normalize_archive(
|
||||
with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
|
||||
staged = Path(temporary) / code
|
||||
normalize_document(source_directory, input_root, staged, files, checksum)
|
||||
publish_directory(staged, output / "documents" / code)
|
||||
publish_directory(staged, target)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -55,7 +56,7 @@ class MinjustNormalizationTest(unittest.TestCase):
|
||||
document,
|
||||
10,
|
||||
{
|
||||
"ru": '<meta charset=unicode><style>x</style><p style="color:red">Статья 1 Закон<script>bad()</script></p><a href="javascript:bad">ссылка</a>',
|
||||
"ru": '<meta charset=unicode><style>x</style><p style="color:red">Статья 1 Закон<script>bad()</script></p><a href="javascript:bad">ссылка</a><a href="http://[">сломанная ссылка</a><img src="http://[">',
|
||||
},
|
||||
)
|
||||
self.edition(document, 20, {"ky": "<p>1. Кыргызча жобо</p>"})
|
||||
@@ -73,6 +74,7 @@ class MinjustNormalizationTest(unittest.TestCase):
|
||||
self.assertNotIn("script", safe_html)
|
||||
self.assertNotIn("style=", safe_html)
|
||||
self.assertNotIn("javascript:", safe_html)
|
||||
self.assertNotIn("http://[", safe_html)
|
||||
self.assertIn("Статья 1 Закон", safe_html)
|
||||
parsed = json.loads(fragments)
|
||||
self.assertEqual(parsed[0]["type"], "article")
|
||||
@@ -116,6 +118,35 @@ class MinjustNormalizationTest(unittest.TestCase):
|
||||
("success", None, None),
|
||||
)
|
||||
|
||||
def test_rejects_overlapping_input_and_output(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source = Path(temporary) / "source"
|
||||
metadata = self.document(source) / "metadata.json"
|
||||
original = metadata.read_bytes()
|
||||
|
||||
for output in (source, source / "normalized", source.parent):
|
||||
with self.subTest(output=output):
|
||||
with self.assertRaisesRegex(ValueError, "must not overlap"):
|
||||
normalize_archive(source, output)
|
||||
self.assertEqual(metadata.read_bytes(), original)
|
||||
|
||||
def test_recovers_interrupted_directory_publication_before_skip(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
source = base / "source"
|
||||
output = base / "normalized"
|
||||
self.document(source)
|
||||
first = normalize_archive(source, output)
|
||||
target = output / "documents/1"
|
||||
backup = output / "documents/.1.previous"
|
||||
os.replace(target, backup)
|
||||
|
||||
second = normalize_archive(source, output)
|
||||
|
||||
self.assertEqual((first.normalized, second.skipped), (1, 1))
|
||||
self.assertTrue((target / "document.json").is_file())
|
||||
self.assertFalse(backup.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -33,4 +33,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.2.1`
|
||||
- Backend: `0.2.2`
|
||||
- Frontend: не создан
|
||||
|
||||
## Краткий итог
|
||||
@@ -68,10 +68,10 @@
|
||||
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||
- Репозиторий организован как основа всего проекта, а не отдельного бота.
|
||||
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
|
||||
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.1` без внешних зависимостей.
|
||||
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей.
|
||||
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
|
||||
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
|
||||
- Реализован backend-нормализатор версии `0.2.1` без внешних зависимостей.
|
||||
- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей.
|
||||
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
|
||||
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
|
||||
### 2026-08-12
|
||||
|
||||
- Нормализатор запрещает пересекающиеся каталоги источника и результата,
|
||||
восстанавливает прерванную публикацию и отбрасывает некорректные URL.
|
||||
- Версия backend обновлена до `0.2.2`.
|
||||
- Загрузчик пересоздаёт временный список документов на текущей странице не
|
||||
только после HTTP 404, но и после исчерпания повторов запроса списка.
|
||||
- Версия backend обновлена до `0.2.1`.
|
||||
@@ -194,4 +197,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -180,4 +180,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
@@ -48,4 +48,4 @@ python3 -m unittest -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
|
||||
|
||||
Reference in New Issue
Block a user