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 версии
|
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||||
`0.2.1`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР.
|
`0.2.2`: возобновляемая выгрузка и нормализация документов ЦБД Минюста КР.
|
||||||
|
|
||||||
| Компонент | Версия | Состояние |
|
| Компонент | Версия | Состояние |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||||
| Backend | `0.2.1` | реализованы выгрузка и нормализация документов ЦБД Минюста КР |
|
| Backend | `0.2.2` | реализованы выгрузка и нормализация документов ЦБД Минюста КР |
|
||||||
| Frontend | — | ещё не создан |
|
| Frontend | — | ещё не создан |
|
||||||
| Сбор и обработка правовых данных | `0.2.1` | реализованы архиватор и нормализатор ЦБД Минюста КР |
|
| Сбор и обработка правовых данных | `0.2.2` | реализованы архиватор и нормализатор ЦБД Минюста КР |
|
||||||
| RAG и база знаний | — | ещё не созданы |
|
| 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 Акылдаш
|
# Backend Акылдаш
|
||||||
|
|
||||||
Версия: `0.2.1`
|
Версия: `0.2.2`
|
||||||
|
|
||||||
Первая backend-область проекта — загрузка правовых документов из официального
|
Первая backend-область проекта — загрузка правовых документов из официального
|
||||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||||
@@ -68,6 +68,8 @@ python3 backend/normalization/minjust_cbd.py
|
|||||||
в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и
|
в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и
|
||||||
`--output`; `--refresh` принудительно обрабатывает неизменившиеся документы,
|
`--output`; `--refresh` принудительно обрабатывает неизменившиеся документы,
|
||||||
`--log-level` задаёт уровень журнала.
|
`--log-level` задаёт уровень журнала.
|
||||||
|
Каталоги `--input` и `--output` не должны совпадать, содержать друг друга или
|
||||||
|
пересекаться через разрешённые абсолютные пути.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/minjust-normalized/
|
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 pathlib import Path
|
||||||
from typing import Callable, Iterable
|
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/"
|
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
APP_VERSION = "0.2.1"
|
APP_VERSION = "0.2.2"
|
||||||
SCHEMA_VERSION = "1"
|
SCHEMA_VERSION = "1"
|
||||||
NORMALIZER_VERSION = "1.0.0"
|
NORMALIZER_VERSION = "1.0.0"
|
||||||
LANGUAGES = ("ru", "ky")
|
LANGUAGES = ("ru", "ky")
|
||||||
@@ -234,7 +234,10 @@ class SafeHtmlParser(HTMLParser):
|
|||||||
return safe
|
return safe
|
||||||
|
|
||||||
def safe_image(self, value: str) -> bool:
|
def safe_image(self, value: str) -> bool:
|
||||||
|
try:
|
||||||
parsed = urlsplit(value)
|
parsed = urlsplit(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
|
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
|
||||||
return False
|
return False
|
||||||
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
|
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
|
||||||
@@ -249,7 +252,10 @@ def safe_link(value: str) -> bool:
|
|||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value or value.startswith(("//", "\\\\")):
|
if not value or value.startswith(("//", "\\\\")):
|
||||||
return False
|
return False
|
||||||
|
try:
|
||||||
parsed = urlsplit(value)
|
parsed = urlsplit(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
|
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
|
||||||
not parsed.scheme and parsed.netloc
|
not parsed.scheme and parsed.netloc
|
||||||
)
|
)
|
||||||
@@ -531,6 +537,23 @@ def publish_directory(staged: Path, target: Path) -> None:
|
|||||||
shutil.rmtree(backup)
|
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(
|
def normalize_archive(
|
||||||
input_root: Path = Path("data/minjust-cbd"),
|
input_root: Path = Path("data/minjust-cbd"),
|
||||||
output: Path = Path("data/minjust-normalized"),
|
output: Path = Path("data/minjust-normalized"),
|
||||||
@@ -538,6 +561,7 @@ def normalize_archive(
|
|||||||
refresh: bool = False,
|
refresh: bool = False,
|
||||||
progress: Callable[[NormalizeResult, int], None] | None = None,
|
progress: Callable[[NormalizeResult, int], None] | None = None,
|
||||||
) -> NormalizeResult:
|
) -> NormalizeResult:
|
||||||
|
validate_roots(input_root, output)
|
||||||
document_root = input_root / "documents"
|
document_root = input_root / "documents"
|
||||||
if not document_root.is_dir():
|
if not document_root.is_dir():
|
||||||
raise FileNotFoundError(f"Document directory not found: {document_root}")
|
raise FileNotFoundError(f"Document directory not found: {document_root}")
|
||||||
@@ -563,10 +587,12 @@ def normalize_archive(
|
|||||||
for source_directory in directories:
|
for source_directory in directories:
|
||||||
discovered += 1
|
discovered += 1
|
||||||
code = source_directory.name
|
code = source_directory.name
|
||||||
|
target = output / "documents" / code
|
||||||
checksum = None
|
checksum = None
|
||||||
try:
|
try:
|
||||||
|
recover_directory(target)
|
||||||
files, checksum = source_inventory(source_directory, input_root)
|
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"
|
checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success"
|
||||||
):
|
):
|
||||||
skipped += 1
|
skipped += 1
|
||||||
@@ -574,7 +600,7 @@ def normalize_archive(
|
|||||||
with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
|
with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
|
||||||
staged = Path(temporary) / code
|
staged = Path(temporary) / code
|
||||||
normalize_document(source_directory, input_root, staged, files, checksum)
|
normalize_document(source_directory, input_root, staged, files, checksum)
|
||||||
publish_directory(staged, output / "documents" / code)
|
publish_directory(staged, target)
|
||||||
with connection:
|
with connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -55,7 +56,7 @@ class MinjustNormalizationTest(unittest.TestCase):
|
|||||||
document,
|
document,
|
||||||
10,
|
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>"})
|
self.edition(document, 20, {"ky": "<p>1. Кыргызча жобо</p>"})
|
||||||
@@ -73,6 +74,7 @@ class MinjustNormalizationTest(unittest.TestCase):
|
|||||||
self.assertNotIn("script", safe_html)
|
self.assertNotIn("script", safe_html)
|
||||||
self.assertNotIn("style=", safe_html)
|
self.assertNotIn("style=", safe_html)
|
||||||
self.assertNotIn("javascript:", safe_html)
|
self.assertNotIn("javascript:", safe_html)
|
||||||
|
self.assertNotIn("http://[", safe_html)
|
||||||
self.assertIn("Статья 1 Закон", safe_html)
|
self.assertIn("Статья 1 Закон", safe_html)
|
||||||
parsed = json.loads(fragments)
|
parsed = json.loads(fragments)
|
||||||
self.assertEqual(parsed[0]["type"], "article")
|
self.assertEqual(parsed[0]["type"], "article")
|
||||||
@@ -116,6 +118,35 @@ class MinjustNormalizationTest(unittest.TestCase):
|
|||||||
("success", None, None),
|
("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__":
|
if __name__ == "__main__":
|
||||||
unittest.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-бот: `0.2.2`
|
||||||
- Telegram-бот на Synology: `0.2.1`
|
- Telegram-бот на Synology: `0.2.1`
|
||||||
- Backend: `0.2.1`
|
- Backend: `0.2.2`
|
||||||
- Frontend: не создан
|
- Frontend: не создан
|
||||||
|
|
||||||
## Краткий итог
|
## Краткий итог
|
||||||
@@ -68,10 +68,10 @@
|
|||||||
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||||
- Репозиторий организован как основа всего проекта, а не отдельного бота.
|
- Репозиторий организован как основа всего проекта, а не отдельного бота.
|
||||||
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
|
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
|
||||||
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.1` без внешних зависимостей.
|
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей.
|
||||||
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
|
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
|
||||||
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
|
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
|
||||||
- Реализован backend-нормализатор версии `0.2.1` без внешних зависимостей.
|
- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей.
|
||||||
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
|
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
|
||||||
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
|
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
|
||||||
|
|
||||||
@@ -134,6 +134,9 @@
|
|||||||
|
|
||||||
### 2026-08-12
|
### 2026-08-12
|
||||||
|
|
||||||
|
- Нормализатор запрещает пересекающиеся каталоги источника и результата,
|
||||||
|
восстанавливает прерванную публикацию и отбрасывает некорректные URL.
|
||||||
|
- Версия backend обновлена до `0.2.2`.
|
||||||
- Загрузчик пересоздаёт временный список документов на текущей странице не
|
- Загрузчик пересоздаёт временный список документов на текущей странице не
|
||||||
только после HTTP 404, но и после исчерпания повторов запроса списка.
|
только после HTTP 404, но и после исчерпания повторов запроса списка.
|
||||||
- Версия backend обновлена до `0.2.1`.
|
- Версия 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