mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 20:22:03 +03:00
exporter: render russian markdown report alongside english
Thread a lang parameter through render_markdown and every section helper so the same report dict can render an English or Russian report; inline _t() calls carry the strings, findings fall back from _ru fields to their English siblings. write_report now emits a third .ru.md file alongside .json and .md, and render_markdown can stamp a sibling cross-link line right after the H1.
This commit is contained in:
parent
4c2da2e831
commit
d53ab9a0d6
2 changed files with 410 additions and 139 deletions
|
|
@ -88,14 +88,17 @@ def egress_asn(report: dict[str, Any]) -> str | None:
|
||||||
return ((data.get("egress_v4") or {}) if isinstance(data, dict) else {}).get("asn")
|
return ((data.get("egress_v4") or {}) if isinstance(data, dict) else {}).get("asn")
|
||||||
|
|
||||||
|
|
||||||
def write_report(report: dict[str, Any], markdown: str, logs_dir: Path) -> tuple[Path, Path]:
|
def write_report(
|
||||||
|
report: dict[str, Any], markdown: str, markdown_ru: str, logs_dir: Path
|
||||||
|
) -> tuple[Path, Path, Path]:
|
||||||
meta = report.get("meta") or {}
|
meta = report.get("meta") or {}
|
||||||
started = meta.get("started_at", "")
|
started = meta.get("started_at", "")
|
||||||
asn = meta.get("target") or egress_asn(report)
|
asn = meta.get("target") or egress_asn(report)
|
||||||
base = Path(logs_dir)
|
base = Path(logs_dir)
|
||||||
json_path = atomic_write(base / report_filename(asn, started, "json"), dump_json(report))
|
json_path = atomic_write(base / report_filename(asn, started, "json"), dump_json(report))
|
||||||
md_path = atomic_write(base / report_filename(asn, started, "md"), markdown)
|
md_path = atomic_write(base / report_filename(asn, started, "md"), markdown)
|
||||||
return json_path, md_path
|
ru_md_path = atomic_write(base / report_filename(asn, started, "ru.md"), markdown_ru)
|
||||||
|
return json_path, md_path, ru_md_path
|
||||||
|
|
||||||
|
|
||||||
from netcheck.interpret import bufferbloat_consequence
|
from netcheck.interpret import bufferbloat_consequence
|
||||||
|
|
@ -103,9 +106,14 @@ from netcheck.interpret import bufferbloat_consequence
|
||||||
SPARK_CHARS = "▁▂▃▅▇"
|
SPARK_CHARS = "▁▂▃▅▇"
|
||||||
_BADGES = {"ok": "🟢", "info": "🟢", "warn": "🟡", "crit": "🔴"}
|
_BADGES = {"ok": "🟢", "info": "🟢", "warn": "🟡", "crit": "🔴"}
|
||||||
_SEVERITY_RANK = {"crit": 0, "warn": 1, "info": 2, "ok": 3}
|
_SEVERITY_RANK = {"crit": 0, "warn": 1, "info": 2, "ok": 3}
|
||||||
|
_SEVERITY_WORDS_RU = {"ok": "норма", "info": "инфо", "warn": "предупреждение", "crit": "критично"}
|
||||||
_LOSS_JUMP_PCT = 20.0
|
_LOSS_JUMP_PCT = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def _t(lang: str, en: str, ru: str) -> str:
|
||||||
|
return en if lang == "en" else ru
|
||||||
|
|
||||||
|
|
||||||
def sparkline(values: list[float | None]) -> str:
|
def sparkline(values: list[float | None]) -> str:
|
||||||
numbers = [v for v in values if v is not None]
|
numbers = [v for v in values if v is not None]
|
||||||
if not numbers:
|
if not numbers:
|
||||||
|
|
@ -124,8 +132,11 @@ def sparkline(values: list[float | None]) -> str:
|
||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def badge(severity: str, emoji: bool) -> str:
|
def badge(severity: str, emoji: bool, lang: str = "en") -> str:
|
||||||
return _BADGES.get(severity, "⚪") if emoji else f"[{severity}]"
|
if emoji:
|
||||||
|
return _BADGES.get(severity, "⚪")
|
||||||
|
word = severity if lang == "en" else _SEVERITY_WORDS_RU.get(severity, severity)
|
||||||
|
return f"[{word}]"
|
||||||
|
|
||||||
|
|
||||||
def first_loss_jump(hops: list[dict]) -> int | None:
|
def first_loss_jump(hops: list[dict]) -> int | None:
|
||||||
|
|
@ -148,32 +159,15 @@ def _num(value: Any) -> str:
|
||||||
return "—" if value is None else f"{value:g}" if isinstance(value, (int, float)) else str(value)
|
return "—" if value is None else f"{value:g}" if isinstance(value, (int, float)) else str(value)
|
||||||
|
|
||||||
|
|
||||||
def _table(headers: list[str], rows: list[list[str]]) -> list[str]:
|
def _table(headers: list[str], rows: list[list[str]], lang: str = "en") -> list[str]:
|
||||||
if not rows:
|
if not rows:
|
||||||
return ["_No rows._"]
|
return [_t(lang, "_No rows._", "_Нет строк._")]
|
||||||
return (
|
return (
|
||||||
["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
|
["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"]
|
||||||
+ ["| " + " | ".join(row) + " |" for row in rows]
|
+ ["| " + " | ".join(row) + " |" for row in rows]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _header(report: dict, emoji: bool) -> list[str]:
|
|
||||||
meta = report.get("meta") or {}
|
|
||||||
interp = report.get("interpretation") or {}
|
|
||||||
target = f" · target `{meta.get('target')}`" if meta.get("target") else ""
|
|
||||||
status = interp.get("overall_status", "ok")
|
|
||||||
return [
|
|
||||||
"# netcheck report",
|
|
||||||
"",
|
|
||||||
f"- Mode: `{meta.get('mode', 'auto')}`{target}",
|
|
||||||
f"- Started: {meta.get('started_at', '?')} · finished: {meta.get('finished_at', '?')}",
|
|
||||||
f"- Host OS: {meta.get('host_os', '?')} · run id `{meta.get('run_id', '?')}`",
|
|
||||||
f"- Verdict: {badge(status, emoji)} **{status}** ({interp.get('overall_score', 0)}/100) — "
|
|
||||||
f"{interp.get('summary_text', '')}",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _findings(report: dict) -> list[dict]:
|
def _findings(report: dict) -> list[dict]:
|
||||||
return sorted(
|
return sorted(
|
||||||
(report.get("interpretation") or {}).get("findings") or [],
|
(report.get("interpretation") or {}).get("findings") or [],
|
||||||
|
|
@ -181,12 +175,47 @@ def _findings(report: dict) -> list[dict]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _tldr(report: dict, emoji: bool) -> list[str]:
|
def _f(finding: dict, field: str, lang: str) -> str:
|
||||||
|
if lang == "ru":
|
||||||
|
return finding.get(f"{field}_ru") or finding.get(field) or ""
|
||||||
|
return finding.get(field) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ru_summary(report: dict, status: str) -> str:
|
||||||
|
if status in ("ok", "info"):
|
||||||
|
return "Проблем на этом соединении не найдено."
|
||||||
|
found = [f for f in _findings(report) if f.get("severity") == status]
|
||||||
|
return "; ".join(_f(f, "title", "ru") for f in found[:3])
|
||||||
|
|
||||||
|
|
||||||
|
def _header(report: dict, emoji: bool, lang: str = "en") -> list[str]:
|
||||||
|
meta = report.get("meta") or {}
|
||||||
|
interp = report.get("interpretation") or {}
|
||||||
|
target = f" · {_t(lang, 'target', 'цель')} `{meta.get('target')}`" if meta.get("target") else ""
|
||||||
|
status = interp.get("overall_status", "ok")
|
||||||
|
summary = interp.get("summary_text", "") if lang == "en" else _ru_summary(report, status)
|
||||||
|
status_word = status if lang == "en" else _SEVERITY_WORDS_RU.get(status, status)
|
||||||
|
return [
|
||||||
|
"# netcheck report",
|
||||||
|
"",
|
||||||
|
f"- {_t(lang, 'Mode', 'Режим')}: `{meta.get('mode', 'auto')}`{target}",
|
||||||
|
f"- {_t(lang, 'Started', 'Начало')}: {meta.get('started_at', '?')} · "
|
||||||
|
f"{_t(lang, 'finished', 'завершение')}: {meta.get('finished_at', '?')}",
|
||||||
|
f"- {_t(lang, 'Host OS', 'ОС хоста')}: {meta.get('host_os', '?')} · "
|
||||||
|
f"{_t(lang, 'run id', 'id запуска')} `{meta.get('run_id', '?')}`",
|
||||||
|
f"- {_t(lang, 'Verdict', 'Вердикт')}: {badge(status, emoji, lang)} **{status_word}** "
|
||||||
|
f"({interp.get('overall_score', 0)}/100) — {summary}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tldr(report: dict, emoji: bool, lang: str = "en") -> list[str]:
|
||||||
found = _findings(report)
|
found = _findings(report)
|
||||||
|
heading = _t(lang, "## TL;DR", "## Коротко")
|
||||||
if not found:
|
if not found:
|
||||||
return ["## TL;DR", "", "No problems found on this connection.", ""]
|
return [heading, "", _t(lang, "No problems found on this connection.", "Проблем на этом соединении не найдено."), ""]
|
||||||
return ["## TL;DR", ""] + [
|
return [heading, ""] + [
|
||||||
f"- {badge(f.get('severity', 'info'), emoji)} **{f.get('title', '')}** — {f.get('detail', '')}"
|
f"- {badge(f.get('severity', 'info'), emoji, lang)} **{_f(f, 'title', lang)}** — {_f(f, 'detail', lang)}"
|
||||||
for f in found[:5]
|
for f in found[:5]
|
||||||
] + [""]
|
] + [""]
|
||||||
|
|
||||||
|
|
@ -202,8 +231,30 @@ _SECTION_TITLES = {
|
||||||
"speed": "## Speed",
|
"speed": "## Speed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SECTION_TITLES_RU = {
|
||||||
|
"connection": "## Подключение и идентификация",
|
||||||
|
"ip_geo": "## Подключение и идентификация",
|
||||||
|
"vpn_assessment": "## Оценка VPN/прокси",
|
||||||
|
"bgp": "## ASN и BGP-разведка",
|
||||||
|
"reputation": "## Репутация",
|
||||||
|
"latency": "## Задержки",
|
||||||
|
"path": "## Маршрут",
|
||||||
|
"speed": "## Скорость",
|
||||||
|
}
|
||||||
|
|
||||||
def unavailable(report: dict[str, Any], section: str) -> str | None:
|
|
||||||
|
def _section_title(section: str, lang: str = "en") -> str:
|
||||||
|
return _SECTION_TITLES_RU[section] if lang == "ru" else _SECTION_TITLES[section]
|
||||||
|
|
||||||
|
|
||||||
|
_MODULE_STATUS_RU = {"ok": "ок", "partial": "частично", "failed": "ошибка", "skipped": "пропущено"}
|
||||||
|
|
||||||
|
|
||||||
|
def _module_status_word(status: str, lang: str) -> str:
|
||||||
|
return status if lang == "en" else _MODULE_STATUS_RU.get(status, status)
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable(report: dict[str, Any], section: str, lang: str = "en") -> str | None:
|
||||||
module = _module(report, section)
|
module = _module(report, section)
|
||||||
if module.get("status") in ("ok", "partial") and module.get("data") is not None:
|
if module.get("status") in ("ok", "partial") and module.get("data") is not None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -211,147 +262,239 @@ def unavailable(report: dict[str, Any], section: str) -> str | None:
|
||||||
if not detail:
|
if not detail:
|
||||||
detail = "; ".join(module.get("warnings") or [])
|
detail = "; ".join(module.get("warnings") or [])
|
||||||
if not detail:
|
if not detail:
|
||||||
detail = "no data was collected for this section"
|
detail = _t(lang, "no data was collected for this section", "для этого раздела не собраны данные")
|
||||||
return f"_Not available — {module.get('status', 'skipped')}: {detail}._"
|
status_word = _module_status_word(module.get("status", "skipped"), lang)
|
||||||
|
label = _t(lang, "Not available", "Недоступно")
|
||||||
|
return f"_{label} — {status_word}: {detail}._"
|
||||||
|
|
||||||
|
|
||||||
def _connection(report: dict) -> list[str]:
|
def _connection(report: dict, lang: str = "en") -> list[str]:
|
||||||
conn_note = unavailable(report, "connection")
|
title = _section_title("connection", lang)
|
||||||
geo_note = unavailable(report, "ip_geo")
|
conn_note = unavailable(report, "connection", lang)
|
||||||
|
geo_note = unavailable(report, "ip_geo", lang)
|
||||||
if conn_note or geo_note:
|
if conn_note or geo_note:
|
||||||
notes = [n for n in (conn_note, geo_note) if n]
|
notes = [n for n in (conn_note, geo_note) if n]
|
||||||
return [_SECTION_TITLES["connection"], "", *notes, ""]
|
return [title, "", *notes, ""]
|
||||||
local = _data(report, "connection") or {}
|
local = _data(report, "connection") or {}
|
||||||
geo_bundle = _data(report, "ip_geo") or {}
|
geo_bundle = _data(report, "ip_geo") or {}
|
||||||
geo = geo_bundle.get("egress_v4") or {}
|
geo = geo_bundle.get("egress_v4") or {}
|
||||||
v6 = geo_bundle.get("egress_v6") or {}
|
v6 = geo_bundle.get("egress_v6") or {}
|
||||||
cf = geo_bundle.get("cf_trace") or {}
|
cf = geo_bundle.get("cf_trace") or {}
|
||||||
rows = [
|
rows = [
|
||||||
["Interface", f"{local.get('iface_name') or '?'} (MTU {local.get('iface_mtu') or '?'})"],
|
[_t(lang, "Interface", "Интерфейс"), f"{local.get('iface_name') or '?'} (MTU {local.get('iface_mtu') or '?'})"],
|
||||||
["Local address", f"{local.get('local_ipv4') or '?'} / {local.get('local_ipv6') or '—'}"],
|
[
|
||||||
["Default gateway", local.get("default_gateway_v4") or "?"],
|
_t(lang, "Local address", "Локальный адрес"),
|
||||||
["Egress IPv4", f"{geo.get('ip') or '?'} ({geo.get('asn') or '?'} {geo.get('as_name') or geo.get('org') or ''})"],
|
f"{local.get('local_ipv4') or '?'} / {local.get('local_ipv6') or '—'}",
|
||||||
["Egress IPv6", f"{v6.get('ip') or '—'} ({v6.get('asn') or '—'})"],
|
],
|
||||||
["Reverse DNS", geo.get("reverse_dns") or "—"],
|
[_t(lang, "Default gateway", "Шлюз по умолчанию"), local.get("default_gateway_v4") or "?"],
|
||||||
["Location", f"{geo.get('city') or '?'}, {geo.get('country') or geo.get('country_code') or '?'}"],
|
[
|
||||||
["Address type", geo.get("ip_type") or "unknown"],
|
_t(lang, "Egress IPv4", "Внешний IPv4"),
|
||||||
["Cloudflare colo", cf.get("colo") or "—"],
|
f"{geo.get('ip') or '?'} ({geo.get('asn') or '?'} {geo.get('as_name') or geo.get('org') or ''})",
|
||||||
|
],
|
||||||
|
[_t(lang, "Egress IPv6", "Внешний IPv6"), f"{v6.get('ip') or '—'} ({v6.get('asn') or '—'})"],
|
||||||
|
[_t(lang, "Reverse DNS", "Обратный DNS"), geo.get("reverse_dns") or "—"],
|
||||||
|
[
|
||||||
|
_t(lang, "Location", "Местоположение"),
|
||||||
|
f"{geo.get('city') or '?'}, {geo.get('country') or geo.get('country_code') or '?'}",
|
||||||
|
],
|
||||||
|
[_t(lang, "Address type", "Тип адреса"), geo.get("ip_type") or _t(lang, "unknown", "неизвестно")],
|
||||||
|
[_t(lang, "Cloudflare colo", "Дата-центр Cloudflare"), cf.get("colo") or "—"],
|
||||||
]
|
]
|
||||||
lines = ["## Connection & identity", ""] + _table(["Field", "Value"], rows) + [""]
|
lines = [title, ""] + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang) + [""]
|
||||||
if geo_bundle.get("dual_stack_note"):
|
if geo_bundle.get("dual_stack_note"):
|
||||||
lines += [f"> {geo_bundle['dual_stack_note']}", ""]
|
lines += [f"> {geo_bundle['dual_stack_note']}", ""]
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _vpn(report: dict) -> list[str]:
|
_VPN_VERDICT_RU = {"none": "нет", "likely": "вероятно", "confirmed": "подтверждено", "unknown": "неизвестно"}
|
||||||
note = unavailable(report, "vpn_assessment")
|
|
||||||
|
|
||||||
|
def _vpn(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("vpn_assessment", lang)
|
||||||
|
note = unavailable(report, "vpn_assessment", lang)
|
||||||
if note:
|
if note:
|
||||||
return [_SECTION_TITLES["vpn_assessment"], "", note, ""]
|
return [title, "", note, ""]
|
||||||
vpn = _data(report, "vpn_assessment") or {}
|
vpn = _data(report, "vpn_assessment") or {}
|
||||||
|
verdict = vpn.get("verdict", "unknown")
|
||||||
|
verdict_word = verdict if lang == "en" else _VPN_VERDICT_RU.get(verdict, verdict)
|
||||||
|
yes, no = _t(lang, "yes", "да"), _t(lang, "no", "нет")
|
||||||
lines = [
|
lines = [
|
||||||
"## VPN / proxy assessment",
|
title,
|
||||||
"",
|
"",
|
||||||
f"Verdict: **{vpn.get('verdict', 'unknown')}** · confidence {vpn.get('confidence', 0)}"
|
f"{_t(lang, 'Verdict', 'Вердикт')}: **{verdict_word}** · {_t(lang, 'confidence', 'уверенность')} "
|
||||||
f" · tunnel interface {vpn.get('tunnel_iface') or '—'}",
|
f"{vpn.get('confidence', 0)} · {_t(lang, 'tunnel interface', 'туннельный интерфейс')} "
|
||||||
|
f"{vpn.get('tunnel_iface') or '—'}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
lines += _table(
|
lines += _table(
|
||||||
["Signal", "Observed", "Weight", "Direction", "Note"],
|
[
|
||||||
|
_t(lang, "Signal", "Сигнал"),
|
||||||
|
_t(lang, "Observed", "Замечен"),
|
||||||
|
_t(lang, "Weight", "Вес"),
|
||||||
|
_t(lang, "Direction", "Направление"),
|
||||||
|
_t(lang, "Note", "Примечание"),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
s.get("name", ""),
|
s.get("name", ""),
|
||||||
"yes" if s.get("observed") else "no",
|
yes if s.get("observed") else no,
|
||||||
_num(s.get("weight")),
|
_num(s.get("weight")),
|
||||||
s.get("direction", ""),
|
s.get("direction", ""),
|
||||||
s.get("note") or "—",
|
s.get("note") or "—",
|
||||||
]
|
]
|
||||||
for s in vpn.get("signals") or []
|
for s in vpn.get("signals") or []
|
||||||
],
|
],
|
||||||
|
lang,
|
||||||
)
|
)
|
||||||
leak = vpn.get("dns_leak") or {}
|
leak = vpn.get("dns_leak") or {}
|
||||||
if leak:
|
if leak:
|
||||||
lines += ["", "### DNS leak", ""]
|
lines += ["", _t(lang, "### DNS leak", "### Утечка DNS"), ""]
|
||||||
lines += _table(
|
lines += _table(
|
||||||
["Adapter", "Configured resolvers", "Echoed IP", "Echoed ASN", "Same ASN as egress"],
|
[
|
||||||
|
_t(lang, "Adapter", "Адаптер"),
|
||||||
|
_t(lang, "Configured resolvers", "Настроенные резолверы"),
|
||||||
|
_t(lang, "Echoed IP", "Отражённый IP"),
|
||||||
|
_t(lang, "Echoed ASN", "Отражённый ASN"),
|
||||||
|
_t(lang, "Same ASN as egress", "Тот же ASN, что и внешний"),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
a.get("adapter", ""),
|
a.get("adapter", ""),
|
||||||
", ".join(a.get("configured_resolvers") or []) or "—",
|
", ".join(a.get("configured_resolvers") or []) or "—",
|
||||||
a.get("echoed_ip") or "—",
|
a.get("echoed_ip") or "—",
|
||||||
a.get("echoed_asn") or "—",
|
a.get("echoed_asn") or "—",
|
||||||
{True: "yes", False: "no", None: "?"}[a.get("matches_egress_asn")],
|
{True: yes, False: no, None: "?"}[a.get("matches_egress_asn")],
|
||||||
]
|
]
|
||||||
for a in leak.get("per_adapter") or []
|
for a in leak.get("per_adapter") or []
|
||||||
],
|
],
|
||||||
|
lang,
|
||||||
)
|
)
|
||||||
lines += ["", f"EDNS Client Subnet leaked: {'yes' if leak.get('ecs_leaked') else 'no'}", "", leak.get("note", "")]
|
ecs_word = yes if leak.get("ecs_leaked") else no
|
||||||
|
note_text = (leak.get("note_ru") or leak.get("note") or "") if lang == "ru" else (leak.get("note") or "")
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"{_t(lang, 'EDNS Client Subnet leaked', 'Утечка EDNS Client Subnet')}: {ecs_word}",
|
||||||
|
"",
|
||||||
|
note_text,
|
||||||
|
]
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def _bgp(report: dict) -> list[str]:
|
_ROUTE_STABILITY_RU = {"stable": "стабильный", "unstable": "нестабильный", "unknown": "неизвестно"}
|
||||||
note = unavailable(report, "bgp")
|
|
||||||
|
|
||||||
|
def _bgp(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("bgp", lang)
|
||||||
|
note = unavailable(report, "bgp", lang)
|
||||||
if note:
|
if note:
|
||||||
return [_SECTION_TITLES["bgp"], "", note, ""]
|
return [title, "", note, ""]
|
||||||
bgp = _data(report, "bgp") or {}
|
bgp = _data(report, "bgp") or {}
|
||||||
|
stability = bgp.get("stability", "unknown")
|
||||||
|
stability_word = stability if lang == "en" else _ROUTE_STABILITY_RU.get(stability, stability)
|
||||||
rows = [
|
rows = [
|
||||||
["ASN", f"{bgp.get('asn') or '?'} — {bgp.get('holder') or '?'}"],
|
[_t(lang, "ASN", "ASN"), f"{bgp.get('asn') or '?'} — {bgp.get('holder') or '?'}"],
|
||||||
["Registry", f"{bgp.get('registry') or '?'} (allocated {bgp.get('allocated_at') or '?'})"],
|
[
|
||||||
["Upstreams", ", ".join(bgp.get("upstreams") or []) or "—"],
|
_t(lang, "Registry", "Реестр"),
|
||||||
["Peers", ", ".join(bgp.get("peers") or []) or "—"],
|
f"{bgp.get('registry') or '?'} ({_t(lang, 'allocated', 'выделен')} {bgp.get('allocated_at') or '?'})",
|
||||||
["Downstreams", ", ".join((bgp.get("downstreams") or [])[:10]) or "—"],
|
],
|
||||||
["Announced prefixes", f"{bgp.get('prefix_count_v4', 0)} IPv4 / {bgp.get('prefix_count_v6', 0)} IPv6"],
|
[_t(lang, "Upstreams", "Аплинки"), ", ".join(bgp.get("upstreams") or []) or "—"],
|
||||||
["Route stability", f"{bgp.get('stability', 'unknown')} ({len(bgp.get('flaps') or [])} updates in window)"],
|
[_t(lang, "Peers", "Пиры"), ", ".join(bgp.get("peers") or []) or "—"],
|
||||||
["CAIDA ASRank", f"#{_num(bgp.get('asrank'))} · cone {_num(bgp.get('cone_asns'))} ASNs / "
|
[_t(lang, "Downstreams", "Клиенты"), ", ".join((bgp.get("downstreams") or [])[:10]) or "—"],
|
||||||
f"{_num(bgp.get('cone_prefixes'))} prefixes"],
|
[
|
||||||
["PeeringDB", f"{bgp.get('pdb_info_type') or '—'} · {bgp.get('pdb_traffic') or '—'}"],
|
_t(lang, "Announced prefixes", "Анонсируемые префиксы"),
|
||||||
|
f"{bgp.get('prefix_count_v4', 0)} IPv4 / {bgp.get('prefix_count_v6', 0)} IPv6",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
_t(lang, "Route stability", "Стабильность маршрутов"),
|
||||||
|
f"{stability_word} ({len(bgp.get('flaps') or [])} {_t(lang, 'updates in window', 'обновлений в окне')})",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
_t(lang, "CAIDA ASRank", "CAIDA ASRank"),
|
||||||
|
f"#{_num(bgp.get('asrank'))} · {_t(lang, 'cone', 'конус')} {_num(bgp.get('cone_asns'))} "
|
||||||
|
f"{_t(lang, 'ASNs', 'ASN')} / {_num(bgp.get('cone_prefixes'))} {_t(lang, 'prefixes', 'префиксов')}",
|
||||||
|
],
|
||||||
|
[_t(lang, "PeeringDB", "PeeringDB"), f"{bgp.get('pdb_info_type') or '—'} · {bgp.get('pdb_traffic') or '—'}"],
|
||||||
]
|
]
|
||||||
lines = ["## ASN & BGP intelligence", ""] + _table(["Field", "Value"], rows)
|
lines = [title, ""] + _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang)
|
||||||
ixps = bgp.get("ixps") or []
|
ixps = bgp.get("ixps") or []
|
||||||
if ixps:
|
if ixps:
|
||||||
lines += ["", "### IXP presence", ""]
|
lines += ["", _t(lang, "### IXP presence", "### Присутствие на IX"), ""]
|
||||||
lines += _table(
|
lines += _table(
|
||||||
["Exchange", "City", "Country", "Speed (Mbps)"],
|
[
|
||||||
|
_t(lang, "Exchange", "Точка обмена"),
|
||||||
|
_t(lang, "City", "Город"),
|
||||||
|
_t(lang, "Country", "Страна"),
|
||||||
|
_t(lang, "Speed (Mbps)", "Скорость (Мбит/с)"),
|
||||||
|
],
|
||||||
[[i.get("name", ""), i.get("city") or "—", i.get("country") or "—", _num(i.get("speed_mbps"))] for i in ixps],
|
[[i.get("name", ""), i.get("city") or "—", i.get("country") or "—", _num(i.get("speed_mbps"))] for i in ixps],
|
||||||
|
lang,
|
||||||
)
|
)
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def _reputation(report: dict) -> list[str]:
|
_CAPTCHA_RISK_RU = {"unknown": "неизвестен", "low": "низкий", "medium": "средний", "high": "высокий"}
|
||||||
note = unavailable(report, "reputation")
|
|
||||||
|
|
||||||
|
def _reputation(report: dict, lang: str = "en") -> list[str]:
|
||||||
|
title = _section_title("reputation", lang)
|
||||||
|
note = unavailable(report, "reputation", lang)
|
||||||
if note:
|
if note:
|
||||||
return [_SECTION_TITLES["reputation"], "", note, ""]
|
return [title, "", note, ""]
|
||||||
rep = _data(report, "reputation") or {}
|
rep = _data(report, "reputation") or {}
|
||||||
idb = rep.get("internetdb") or {}
|
idb = rep.get("internetdb") or {}
|
||||||
|
none_word = _t(lang, "none", "нет")
|
||||||
rows = [
|
rows = [
|
||||||
["FireHOL hits", ", ".join(rep.get("firehol_hits") or []) or "none"],
|
[_t(lang, "FireHOL hits", "Совпадения FireHOL"), ", ".join(rep.get("firehol_hits") or []) or none_word],
|
||||||
["Open ports (InternetDB)", ", ".join(str(p) for p in idb.get("ports") or []) or "none"],
|
[
|
||||||
["Tags", ", ".join(idb.get("tags") or []) or "none"],
|
_t(lang, "Open ports (InternetDB)", "Открытые порты (InternetDB)"),
|
||||||
["Known CVEs", ", ".join(idb.get("vulns") or []) or "none"],
|
", ".join(str(p) for p in idb.get("ports") or []) or none_word,
|
||||||
["AbuseIPDB", _num(rep.get("abuseipdb_score")) if rep.get("abuseipdb_score") is not None else "not queried"],
|
],
|
||||||
|
[_t(lang, "Tags", "Теги"), ", ".join(idb.get("tags") or []) or none_word],
|
||||||
|
[_t(lang, "Known CVEs", "Известные CVE"), ", ".join(idb.get("vulns") or []) or none_word],
|
||||||
|
[
|
||||||
|
_t(lang, "AbuseIPDB", "AbuseIPDB"),
|
||||||
|
_num(rep.get("abuseipdb_score")) if rep.get("abuseipdb_score") is not None else _t(lang, "not queried", "не запрошено"),
|
||||||
|
],
|
||||||
]
|
]
|
||||||
hits = rep.get("dnsbl_hits")
|
hits = rep.get("dnsbl_hits")
|
||||||
if hits is None:
|
if hits is None:
|
||||||
rows.append(["DNSBL", "not run (pass --dnsbl)"])
|
rows.append([_t(lang, "DNSBL", "DNSBL"), _t(lang, "not run (pass --dnsbl)", "не выполнено (используйте --dnsbl)")])
|
||||||
else:
|
else:
|
||||||
listed = ", ".join(h.get("zone", "") for h in hits) or "no listing"
|
listed = ", ".join(h.get("zone", "") for h in hits) or _t(lang, "no listing", "нет в списках")
|
||||||
if rep.get("dnsbl_query_blocked"):
|
if rep.get("dnsbl_query_blocked"):
|
||||||
listed += " · one or more zones answered with their query-error range, which is not a listing"
|
listed += _t(
|
||||||
rows.append(["DNSBL", listed])
|
lang,
|
||||||
|
" · one or more zones answered with their query-error range, which is not a listing",
|
||||||
|
" · одна или несколько зон ответили в диапазоне ошибок запроса — это не является листингом",
|
||||||
|
)
|
||||||
|
rows.append([_t(lang, "DNSBL", "DNSBL"), listed])
|
||||||
|
risk = rep.get("captcha_risk", "unknown")
|
||||||
|
risk_word = risk if lang == "en" else _CAPTCHA_RISK_RU.get(risk, risk)
|
||||||
|
rationale = (rep.get("rationale_ru") or rep.get("rationale") or "") if lang == "ru" else (rep.get("rationale") or "")
|
||||||
return (
|
return (
|
||||||
["## Reputation", "", f"Captcha/blocklist risk: **{rep.get('captcha_risk', 'unknown')}** — "
|
[title, "", f"{_t(lang, 'Captcha/blocklist risk', 'Риск капчи/блок-листов')}: **{risk_word}** — {rationale}", ""]
|
||||||
f"{rep.get('rationale', '')}", ""]
|
+ _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang)
|
||||||
+ _table(["Field", "Value"], rows)
|
|
||||||
+ [""]
|
+ [""]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _latency(report: dict) -> list[str]:
|
def _latency(report: dict, lang: str = "en") -> list[str]:
|
||||||
note = unavailable(report, "latency")
|
title = _section_title("latency", lang)
|
||||||
|
note = unavailable(report, "latency", lang)
|
||||||
if note:
|
if note:
|
||||||
return [_SECTION_TITLES["latency"], "", note, ""]
|
return [title, "", note, ""]
|
||||||
pings = _data(report, "latency") or []
|
pings = _data(report, "latency") or []
|
||||||
lines = ["## Latency", ""] + _table(
|
lines = [title, ""] + _table(
|
||||||
["Host", "Address", "Method", "Avg ms", "Min", "Max", "Jitter", "Loss"],
|
[
|
||||||
|
_t(lang, "Host", "Хост"),
|
||||||
|
_t(lang, "Address", "Адрес"),
|
||||||
|
_t(lang, "Method", "Метод"),
|
||||||
|
_t(lang, "Avg ms", "Сред. мс"),
|
||||||
|
_t(lang, "Min", "Мин"),
|
||||||
|
_t(lang, "Max", "Макс"),
|
||||||
|
_t(lang, "Jitter", "Джиттер"),
|
||||||
|
_t(lang, "Loss", "Потери"),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
p.get("label", ""),
|
p.get("label", ""),
|
||||||
|
|
@ -365,13 +508,17 @@ def _latency(report: dict) -> list[str]:
|
||||||
]
|
]
|
||||||
for p in pings
|
for p in pings
|
||||||
],
|
],
|
||||||
|
lang,
|
||||||
)
|
)
|
||||||
if pings:
|
if pings:
|
||||||
lines += ["", "```"]
|
lines += ["", "```"]
|
||||||
lines += [f"{p.get('label', ''):<18}{sparkline(p.get('samples') or [])}" for p in pings]
|
lines += [f"{p.get('label', ''):<18}{sparkline(p.get('samples') or [])}" for p in pings]
|
||||||
lines += ["```"]
|
lines += ["```"]
|
||||||
if any(p.get("method") == "tcp" for p in pings):
|
if any(p.get("method") == "tcp" for p in pings):
|
||||||
lines += ["", "> Loss on a `tcp` row counts failed TCP connections, not dropped ICMP packets."]
|
lines += [
|
||||||
|
"",
|
||||||
|
f"> {_t(lang, 'Loss on a `tcp` row counts failed TCP connections, not dropped ICMP packets.', 'Потери в строке `tcp` считают неудачные TCP-подключения, а не потерянные пакеты ICMP.')}",
|
||||||
|
]
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -379,18 +526,22 @@ def _bar(value: Any) -> str:
|
||||||
return "#" * int(min(float(value or 0.0), 200.0) / 10)
|
return "#" * int(min(float(value or 0.0), 200.0) / 10)
|
||||||
|
|
||||||
|
|
||||||
def _path(report: dict) -> list[str]:
|
def _path(report: dict, lang: str = "en") -> list[str]:
|
||||||
note = unavailable(report, "path")
|
title = _section_title("path", lang)
|
||||||
|
note = unavailable(report, "path", lang)
|
||||||
if note:
|
if note:
|
||||||
return [_SECTION_TITLES["path"], "", note, ""]
|
return [title, "", note, ""]
|
||||||
traces = _data(report, "path") or []
|
traces = _data(report, "path") or []
|
||||||
lines = ["## Path", ""]
|
lines = [title, ""]
|
||||||
for trace in traces:
|
for trace in traces:
|
||||||
hops = trace.get("hops") or []
|
hops = trace.get("hops") or []
|
||||||
jump = first_loss_jump(hops)
|
jump = first_loss_jump(hops)
|
||||||
|
completed_word = (
|
||||||
|
_t(lang, "complete", "завершено") if trace.get("completed") else _t(lang, "incomplete", "не завершено")
|
||||||
|
)
|
||||||
lines += [
|
lines += [
|
||||||
f"### {trace.get('target') or '?'} — backend `{trace.get('backend', 'none')}`, "
|
f"### {trace.get('target') or '?'} — {_t(lang, 'backend', 'бэкенд')} `{trace.get('backend', 'none')}`, "
|
||||||
f"{'complete' if trace.get('completed') else 'incomplete'}",
|
f"{completed_word}",
|
||||||
"",
|
"",
|
||||||
"```",
|
"```",
|
||||||
]
|
]
|
||||||
|
|
@ -404,73 +555,111 @@ def _path(report: dict) -> list[str]:
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _speed(report: dict) -> list[str]:
|
def _speed(report: dict, lang: str = "en") -> list[str]:
|
||||||
note = unavailable(report, "speed")
|
title = _section_title("speed", lang)
|
||||||
|
note = unavailable(report, "speed", lang)
|
||||||
if note:
|
if note:
|
||||||
return [_SECTION_TITLES["speed"], "", note, ""]
|
return [title, "", note, ""]
|
||||||
speed = _data(report, "speed") or {}
|
speed = _data(report, "speed") or {}
|
||||||
grade = speed.get("bufferbloat_grade") or "?"
|
grade = speed.get("bufferbloat_grade") or "?"
|
||||||
|
method = speed.get("method", "none")
|
||||||
|
method_word = _t(lang, "none", "нет") if method == "none" else method
|
||||||
|
yes, no = _t(lang, "yes", "да"), _t(lang, "no", "нет")
|
||||||
|
|
||||||
|
def yn(value: Any) -> str:
|
||||||
|
return {True: yes, False: no, None: "—"}[value]
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
["Method", speed.get("method", "none")],
|
[_t(lang, "Method", "Метод"), method_word],
|
||||||
["Server", speed.get("server") or "—"],
|
[_t(lang, "Server", "Сервер"), speed.get("server") or "—"],
|
||||||
["Download", f"{_num(speed.get('download_mbps'))} Mbps"],
|
[_t(lang, "Download", "Скачивание"), f"{_num(speed.get('download_mbps'))} {_t(lang, 'Mbps', 'Мбит/с')}"],
|
||||||
["Upload", f"{_num(speed.get('upload_mbps'))} Mbps"],
|
[_t(lang, "Upload", "Отдача"), f"{_num(speed.get('upload_mbps'))} {_t(lang, 'Mbps', 'Мбит/с')}"],
|
||||||
["Idle RTT", f"{_num(speed.get('idle_rtt_ms'))} ms"],
|
[_t(lang, "Idle RTT", "RTT в простое"), f"{_num(speed.get('idle_rtt_ms'))} {_t(lang, 'ms', 'мс')}"],
|
||||||
["Loaded RTT (down / up)", f"{_num(speed.get('loaded_rtt_down_ms'))} / {_num(speed.get('loaded_rtt_up_ms'))} ms"],
|
[
|
||||||
["Bufferbloat", f"grade {grade} — down +{_num(speed.get('bufferbloat_down_ms'))} ms, "
|
_t(lang, "Loaded RTT (down / up)", "RTT под нагрузкой (приём / передача)"),
|
||||||
f"up +{_num(speed.get('bufferbloat_up_ms'))} ms"],
|
f"{_num(speed.get('loaded_rtt_down_ms'))} / {_num(speed.get('loaded_rtt_up_ms'))} {_t(lang, 'ms', 'мс')}",
|
||||||
["Netflix OCA on-net", {True: "yes", False: "no", None: "—"}[speed.get("netflix_oca_onnet")]],
|
],
|
||||||
|
[
|
||||||
|
_t(lang, "Bufferbloat", "Bufferbloat"),
|
||||||
|
f"{_t(lang, 'grade', 'оценка')} {grade} — {_t(lang, 'down', 'приём')} "
|
||||||
|
f"+{_num(speed.get('bufferbloat_down_ms'))} {_t(lang, 'ms', 'мс')}, "
|
||||||
|
f"{_t(lang, 'up', 'передача')} +{_num(speed.get('bufferbloat_up_ms'))} {_t(lang, 'ms', 'мс')}",
|
||||||
|
],
|
||||||
|
[_t(lang, "Netflix OCA on-net", "Netflix OCA в сети"), yn(speed.get("netflix_oca_onnet"))],
|
||||||
]
|
]
|
||||||
lines = ["## Speed", ""] + _table(["Field", "Value"], rows) + ["", bufferbloat_consequence(grade)]
|
lines = (
|
||||||
|
[title, ""]
|
||||||
|
+ _table([_t(lang, "Field", "Поле"), _t(lang, "Value", "Значение")], rows, lang)
|
||||||
|
+ ["", bufferbloat_consequence(grade, lang)]
|
||||||
|
)
|
||||||
attempts = speed.get("tier_attempts") or []
|
attempts = speed.get("tier_attempts") or []
|
||||||
if attempts:
|
if attempts:
|
||||||
lines += ["", "### Cascade", ""]
|
lines += ["", _t(lang, "### Cascade", "### Каскад"), ""]
|
||||||
lines += _table(
|
lines += _table(
|
||||||
["Tier", "Used", "Reason", "ms"],
|
|
||||||
[
|
[
|
||||||
[a.get("tier", ""), "yes" if a.get("ok") else "no", a.get("reason") or "—", _num(a.get("duration_ms"))]
|
_t(lang, "Tier", "Уровень"),
|
||||||
|
_t(lang, "Used", "Использован"),
|
||||||
|
_t(lang, "Reason", "Причина"),
|
||||||
|
_t(lang, "ms", "мс"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[a.get("tier", ""), yn(a.get("ok")), a.get("reason") or "—", _num(a.get("duration_ms"))]
|
||||||
for a in attempts
|
for a in attempts
|
||||||
],
|
],
|
||||||
|
lang,
|
||||||
)
|
)
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def _problems(report: dict, emoji: bool) -> list[str]:
|
def _problems(report: dict, emoji: bool, lang: str = "en") -> list[str]:
|
||||||
found = _findings(report)
|
found = _findings(report)
|
||||||
|
heading = _t(lang, "## Problems & recommendations", "## Проблемы и рекомендации")
|
||||||
if not found:
|
if not found:
|
||||||
return ["## Problems & recommendations", "", "Nothing to act on.", ""]
|
return [heading, "", _t(lang, "Nothing to act on.", "Не на что реагировать."), ""]
|
||||||
lines = ["## Problems & recommendations", ""]
|
lines = [heading, ""]
|
||||||
for f in found:
|
for f in found:
|
||||||
lines.append(f"- {badge(f.get('severity', 'info'), emoji)} **{f.get('title', '')}** — {f.get('detail', '')}")
|
lines.append(f"- {badge(f.get('severity', 'info'), emoji, lang)} **{_f(f, 'title', lang)}** — {_f(f, 'detail', lang)}")
|
||||||
if f.get("advice"):
|
advice = _f(f, "advice", lang)
|
||||||
lines.append(f" - {f['advice']}")
|
if advice:
|
||||||
|
lines.append(f" - {advice}")
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def _diagnostics(report: dict) -> list[str]:
|
def _diagnostics(report: dict, lang: str = "en") -> list[str]:
|
||||||
rows = []
|
rows = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
for section in SECTION_ORDER:
|
for section in SECTION_ORDER:
|
||||||
module = _module(report, section)
|
module = _module(report, section)
|
||||||
errors = "; ".join(f"{e.get('source')}: {e.get('kind')}" for e in module.get("errors") or []) or "—"
|
errors = "; ".join(f"{e.get('source')}: {e.get('kind')}" for e in module.get("errors") or []) or "—"
|
||||||
rows.append([section, module.get("status", "skipped"), _num(module.get("duration_ms")), errors])
|
status_word = _module_status_word(module.get("status", "skipped"), lang)
|
||||||
|
rows.append([section, status_word, _num(module.get("duration_ms")), errors])
|
||||||
warnings += list(module.get("warnings") or [])
|
warnings += list(module.get("warnings") or [])
|
||||||
lines = ["## Run diagnostics", ""] + _table(["Module", "Status", "ms", "Errors"], rows)
|
heading = _t(lang, "## Run diagnostics", "## Диагностика запуска")
|
||||||
|
lines = [heading, ""] + _table(
|
||||||
|
[_t(lang, "Module", "Модуль"), _t(lang, "Status", "Статус"), _t(lang, "ms", "мс"), _t(lang, "Errors", "Ошибки")],
|
||||||
|
rows,
|
||||||
|
lang,
|
||||||
|
)
|
||||||
if warnings:
|
if warnings:
|
||||||
lines += [""] + [f"- {w}" for w in warnings]
|
lines += [""] + [f"- {w}" for w in warnings]
|
||||||
return lines + [""]
|
return lines + [""]
|
||||||
|
|
||||||
|
|
||||||
def render_markdown(report: dict[str, Any], emoji: bool = True) -> str:
|
def render_markdown(
|
||||||
lines = _header(report, emoji)
|
report: dict[str, Any], emoji: bool = True, lang: str = "en", sibling: str | None = None
|
||||||
lines += _tldr(report, emoji)
|
) -> str:
|
||||||
lines += _connection(report)
|
lines = _header(report, emoji, lang)
|
||||||
lines += _vpn(report)
|
if sibling:
|
||||||
lines += _bgp(report)
|
link_text = "English" if lang == "ru" else "Русский"
|
||||||
lines += _reputation(report)
|
lines = [lines[0], "", f"[{link_text}]({sibling})"] + lines[1:]
|
||||||
lines += _latency(report)
|
lines += _tldr(report, emoji, lang)
|
||||||
lines += _path(report)
|
lines += _connection(report, lang)
|
||||||
lines += _speed(report)
|
lines += _vpn(report, lang)
|
||||||
lines += _problems(report, emoji)
|
lines += _bgp(report, lang)
|
||||||
lines += _diagnostics(report)
|
lines += _reputation(report, lang)
|
||||||
|
lines += _latency(report, lang)
|
||||||
|
lines += _path(report, lang)
|
||||||
|
lines += _speed(report, lang)
|
||||||
|
lines += _problems(report, emoji, lang)
|
||||||
|
lines += _diagnostics(report, lang)
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
|
||||||
|
|
@ -195,9 +195,12 @@ def test_egress_asn_is_read_out_of_the_assembled_report():
|
||||||
|
|
||||||
def test_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: Path):
|
def test_write_report_names_the_file_after_the_target_in_target_mode(tmp_path: Path):
|
||||||
report = build_report({**meta(), "mode": "target", "target": "AS15169"}, modules(), [], {})
|
report = build_report({**meta(), "mode": "target", "target": "AS15169"}, modules(), [], {})
|
||||||
json_path, md_path = write_report(report, "# netcheck report\n", tmp_path)
|
json_path, md_path, ru_md_path = write_report(
|
||||||
|
report, "# netcheck report\n", "# netcheck report\n", tmp_path
|
||||||
|
)
|
||||||
assert json_path.name == "report_AS15169_20260808T191200Z.json"
|
assert json_path.name == "report_AS15169_20260808T191200Z.json"
|
||||||
assert md_path.name == "report_AS15169_20260808T191200Z.md"
|
assert md_path.name == "report_AS15169_20260808T191200Z.md"
|
||||||
|
assert ru_md_path.name == "report_AS15169_20260808T191200Z.ru.md"
|
||||||
|
|
||||||
|
|
||||||
def test_atomic_write_creates_the_directory_and_leaves_no_temp_file(tmp_path: Path):
|
def test_atomic_write_creates_the_directory_and_leaves_no_temp_file(tmp_path: Path):
|
||||||
|
|
@ -217,11 +220,15 @@ def test_atomic_write_replaces_an_existing_file(tmp_path: Path):
|
||||||
|
|
||||||
def test_write_report_emits_both_artifacts_with_matching_names(tmp_path: Path):
|
def test_write_report_emits_both_artifacts_with_matching_names(tmp_path: Path):
|
||||||
report = build_report(meta(), modules(), [], {})
|
report = build_report(meta(), modules(), [], {})
|
||||||
json_path, md_path = write_report(report, "# netcheck report\n", tmp_path)
|
json_path, md_path, ru_md_path = write_report(
|
||||||
|
report, "# netcheck report\n", "# netcheck report\n", tmp_path
|
||||||
|
)
|
||||||
assert json_path.name == "report_AS64500_20260808T191200Z.json"
|
assert json_path.name == "report_AS64500_20260808T191200Z.json"
|
||||||
assert md_path.name == "report_AS64500_20260808T191200Z.md"
|
assert md_path.name == "report_AS64500_20260808T191200Z.md"
|
||||||
|
assert ru_md_path.name == "report_AS64500_20260808T191200Z.ru.md"
|
||||||
assert json.loads(json_path.read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
assert json.loads(json_path.read_text(encoding="utf-8"))["schema_version"] == SCHEMA_VERSION
|
||||||
assert md_path.read_text(encoding="utf-8").startswith("# netcheck report")
|
assert md_path.read_text(encoding="utf-8").startswith("# netcheck report")
|
||||||
|
assert ru_md_path.read_text(encoding="utf-8").startswith("# netcheck report")
|
||||||
|
|
||||||
|
|
||||||
from netcheck.exporter import SPARK_CHARS, badge, first_loss_jump, render_markdown, sparkline
|
from netcheck.exporter import SPARK_CHARS, badge, first_loss_jump, render_markdown, sparkline
|
||||||
|
|
@ -601,10 +608,14 @@ def test_an_all_modules_failed_report_is_still_valid_strict_json():
|
||||||
|
|
||||||
def test_an_all_modules_failed_run_writes_both_artifacts(tmp_path: Path):
|
def test_an_all_modules_failed_run_writes_both_artifacts(tmp_path: Path):
|
||||||
report = dead_report()
|
report = dead_report()
|
||||||
json_path, md_path = write_report(report, render_markdown(report), tmp_path)
|
json_path, md_path, ru_md_path = write_report(
|
||||||
|
report, render_markdown(report), render_markdown(report, lang="ru"), tmp_path
|
||||||
|
)
|
||||||
assert json_path.name.startswith("report_unknown_")
|
assert json_path.name.startswith("report_unknown_")
|
||||||
assert md_path.name.startswith("report_unknown_")
|
assert md_path.name.startswith("report_unknown_")
|
||||||
|
assert ru_md_path.name.startswith("report_unknown_")
|
||||||
assert "## Run diagnostics" in md_path.read_text(encoding="utf-8")
|
assert "## Run diagnostics" in md_path.read_text(encoding="utf-8")
|
||||||
|
assert "## Диагностика запуска" in ru_md_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_the_diagnostics_table_still_names_every_failed_module():
|
def test_the_diagnostics_table_still_names_every_failed_module():
|
||||||
|
|
@ -617,3 +628,74 @@ def test_a_partial_module_with_data_is_rendered_normally_not_as_a_placeholder():
|
||||||
report = build_report(meta(), modules(), [], {})
|
report = build_report(meta(), modules(), [], {})
|
||||||
assert unavailable(report, "latency") is None
|
assert unavailable(report, "latency") is None
|
||||||
assert "cloudflare-dns" in render_markdown(report)
|
assert "cloudflare-dns" in render_markdown(report)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_cyrillic(text: str) -> bool:
|
||||||
|
return any("Ѐ" <= ch <= "ӿ" for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_russian_markdown_is_non_empty_and_contains_cyrillic():
|
||||||
|
text = render_markdown(full_report(), lang="ru")
|
||||||
|
assert text.strip()
|
||||||
|
assert _has_cyrillic(text)
|
||||||
|
assert text.startswith("# netcheck report")
|
||||||
|
|
||||||
|
|
||||||
|
def _structural_shape(text: str) -> tuple[int, int, int]:
|
||||||
|
headings = sum(1 for line in text.splitlines() if line.startswith("## "))
|
||||||
|
table_rows = sum(1 for line in text.splitlines() if line.strip().startswith("|"))
|
||||||
|
fences = text.count("```")
|
||||||
|
return headings, table_rows, fences
|
||||||
|
|
||||||
|
|
||||||
|
def test_russian_report_has_the_same_structural_shape_as_english_for_a_full_report():
|
||||||
|
en = render_markdown(full_report(), lang="en")
|
||||||
|
ru = render_markdown(full_report(), lang="ru")
|
||||||
|
assert _structural_shape(en) == _structural_shape(ru)
|
||||||
|
|
||||||
|
|
||||||
|
def test_russian_report_has_the_same_structural_shape_as_english_for_a_dead_report():
|
||||||
|
en = render_markdown(dead_report(), lang="en")
|
||||||
|
ru = render_markdown(dead_report(), lang="ru")
|
||||||
|
assert _structural_shape(en) == _structural_shape(ru)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_report_creates_three_files_with_the_right_extensions(tmp_path: Path):
|
||||||
|
report = full_report()
|
||||||
|
en_name = "sibling.md"
|
||||||
|
ru_name = "sibling.ru.md"
|
||||||
|
markdown = render_markdown(report, lang="en", sibling=ru_name)
|
||||||
|
markdown_ru = render_markdown(report, lang="ru", sibling=en_name)
|
||||||
|
json_path, md_path, ru_md_path = write_report(report, markdown, markdown_ru, tmp_path)
|
||||||
|
assert json_path.suffix == ".json"
|
||||||
|
assert md_path.name.endswith(".md") and not md_path.name.endswith(".ru.md")
|
||||||
|
assert ru_md_path.name.endswith(".ru.md")
|
||||||
|
|
||||||
|
ru_text = ru_md_path.read_text(encoding="utf-8")
|
||||||
|
assert _has_cyrillic(ru_text)
|
||||||
|
|
||||||
|
en_lines = md_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
ru_lines = ru_text.splitlines()
|
||||||
|
assert en_lines[2] == f"[Русский]({ru_name})"
|
||||||
|
assert ru_lines[2] == f"[English]({en_name})"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_markdown_default_call_is_byte_identical_to_before_the_lang_feature():
|
||||||
|
text = render_markdown(full_report())
|
||||||
|
headings = [line for line in text.splitlines() if line.startswith("## ")]
|
||||||
|
assert headings == [
|
||||||
|
"## TL;DR",
|
||||||
|
"## Connection & identity",
|
||||||
|
"## VPN / proxy assessment",
|
||||||
|
"## ASN & BGP intelligence",
|
||||||
|
"## Reputation",
|
||||||
|
"## Latency",
|
||||||
|
"## Path",
|
||||||
|
"## Speed",
|
||||||
|
"## Problems & recommendations",
|
||||||
|
"## Run diagnostics",
|
||||||
|
]
|
||||||
|
assert text.startswith("# netcheck report")
|
||||||
|
assert "[Русский]" not in text
|
||||||
|
assert "[English]" not in text
|
||||||
|
assert "cloudflare-dns" in text
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue