phase0: archive Python implementation under archive/
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.DS_Store
|
||||
.claude/
|
||||
.agents/
|
||||
.review-sandboxes/
|
||||
md/*.html
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000 8501
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD python -c "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3); u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)"
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["python", "start.py"]
|
||||
@@ -0,0 +1 @@
|
||||
Архивная Python-реализация md-to-html v0.1.2. Для истории.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application package for the md-to-html service."""
|
||||
|
||||
from app.version import __version__
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,189 @@
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request as FastAPIRequest, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from app.converter import convert, load_template_text
|
||||
from app.version import __version__
|
||||
|
||||
DEFAULT_MAX_MARKDOWN_BYTES = 1_048_576
|
||||
DEFAULT_MAX_REQUEST_BYTES = 1_200_000
|
||||
|
||||
|
||||
def get_int_env(name: str, default: int) -> int:
|
||||
raw_value = os.getenv(name)
|
||||
if raw_value is None:
|
||||
return default
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"{name} must be an integer.") from exc
|
||||
if value <= 0:
|
||||
raise RuntimeError(f"{name} must be positive.")
|
||||
return value
|
||||
|
||||
|
||||
def get_bool_env(name: str, default: bool = False) -> bool:
|
||||
raw_value = os.getenv(name)
|
||||
if raw_value is None:
|
||||
return default
|
||||
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
markdown: str
|
||||
title: str | None = None
|
||||
|
||||
@field_validator("markdown")
|
||||
@classmethod
|
||||
def validate_markdown_size(cls, value: str) -> str:
|
||||
max_markdown_bytes = get_int_env(
|
||||
"MAX_MARKDOWN_BYTES", DEFAULT_MAX_MARKDOWN_BYTES
|
||||
)
|
||||
if len(value.encode("utf-8")) > max_markdown_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"markdown exceeds {max_markdown_bytes} bytes",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class MaxRequestSizeMiddleware:
|
||||
def __init__(self, app: Any, max_request_bytes: int) -> None:
|
||||
self.app = app
|
||||
self.max_request_bytes = max_request_bytes
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = {
|
||||
key.decode("latin1").lower(): value.decode("latin1")
|
||||
for key, value in scope.get("headers", [])
|
||||
}
|
||||
content_length = headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > self.max_request_bytes:
|
||||
await self._send_413(scope, receive, send)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
body = bytearray()
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
if message["type"] == "http.disconnect":
|
||||
return
|
||||
continue
|
||||
|
||||
chunk = message.get("body", b"")
|
||||
body.extend(chunk)
|
||||
if len(body) > self.max_request_bytes:
|
||||
await self._send_413(scope, receive, send)
|
||||
return
|
||||
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
|
||||
body_bytes = bytes(body)
|
||||
body_sent = False
|
||||
|
||||
async def replay_receive():
|
||||
nonlocal body_sent
|
||||
if body_sent:
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
body_sent = True
|
||||
return {"type": "http.request", "body": body_bytes, "more_body": False}
|
||||
|
||||
await self.app(scope, replay_receive, send)
|
||||
|
||||
async def _send_413(self, scope, receive, send) -> None:
|
||||
response = JSONResponse(
|
||||
status_code=413,
|
||||
content={"detail": f"request exceeds {self.max_request_bytes} bytes"},
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
app = FastAPI(title="md-to-html")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["POST", "GET"],
|
||||
allow_headers=["content-type"],
|
||||
)
|
||||
app.add_middleware(
|
||||
MaxRequestSizeMiddleware,
|
||||
max_request_bytes=get_int_env("MAX_REQUEST_BYTES", DEFAULT_MAX_REQUEST_BYTES),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_exception_handler(
|
||||
request: FastAPIRequest, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(status_code=400, content={"detail": exc.errors()})
|
||||
|
||||
|
||||
@app.post("/convert")
|
||||
async def convert_markdown(payload: ConvertRequest) -> Response:
|
||||
if not payload.markdown.strip():
|
||||
raise HTTPException(status_code=400, detail="markdown must not be empty")
|
||||
|
||||
fallback_title = payload.title or "Document"
|
||||
try:
|
||||
html_result = convert(payload.markdown, fallback_title=fallback_title)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return Response(content=html_result, media_type="text/html; charset=utf-8")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/version")
|
||||
async def version() -> dict[str, str]:
|
||||
return {"version": __version__}
|
||||
|
||||
|
||||
@app.get("/ready")
|
||||
async def ready() -> dict[str, Any]:
|
||||
details: dict[str, Any] = {"status": "ok", "template_loaded": True}
|
||||
|
||||
try:
|
||||
load_template_text()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=f"Template load failed: {exc}") from exc
|
||||
|
||||
if get_bool_env("READY_CHECK_GITHUB", default=False):
|
||||
request = Request(
|
||||
"https://api.github.com",
|
||||
headers={"User-Agent": "md-to-html-service-readiness"},
|
||||
method="HEAD",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=5) as response:
|
||||
details["github_status"] = response.status
|
||||
except URLError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"GitHub readiness check failed: {exc.reason}",
|
||||
) from exc
|
||||
else:
|
||||
details["github_status"] = "skipped"
|
||||
|
||||
return details
|
||||
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
API_URL = "https://api.github.com/markdown"
|
||||
API_VERSION = "2022-11-28"
|
||||
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "template.html"
|
||||
|
||||
|
||||
class FirstHeadingParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._capture = False
|
||||
self._done = False
|
||||
self._parts: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None:
|
||||
if self._done:
|
||||
return
|
||||
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
||||
self._capture = True
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if self._capture and tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
||||
self._capture = False
|
||||
self._done = True
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._capture and not self._done:
|
||||
self._parts.append(data)
|
||||
|
||||
def title(self) -> str:
|
||||
return "".join(self._parts).strip()
|
||||
|
||||
|
||||
def render_markdown(markdown_text: str) -> str:
|
||||
payload = json.dumps({"text": markdown_text}).encode("utf-8")
|
||||
headers = {
|
||||
"Accept": "text/html",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "md-to-html-service",
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
}
|
||||
|
||||
github_token = os.getenv("GITHUB_TOKEN")
|
||||
if github_token:
|
||||
headers["Authorization"] = f"Bearer {github_token}"
|
||||
|
||||
request = Request(API_URL, data=payload, headers=headers, method="POST")
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read().decode("utf-8")
|
||||
except HTTPError as exc:
|
||||
error_body = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(
|
||||
f"GitHub API error: {exc.code} {exc.reason}\n{error_body}"
|
||||
) from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"Failed to reach GitHub API: {exc.reason}") from exc
|
||||
|
||||
|
||||
def extract_title(html_text: str, fallback: str) -> str:
|
||||
parser = FirstHeadingParser()
|
||||
parser.feed(html_text)
|
||||
return parser.title() or fallback
|
||||
|
||||
|
||||
def apply_template(template_text: str, html_text: str, title: str) -> str:
|
||||
updated = re.sub(
|
||||
r"<title>.*?</title>",
|
||||
f"<title>{title}</title>",
|
||||
template_text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
output_lines = []
|
||||
inserted = False
|
||||
html_lines = [f" {line}" if line else "" for line in html_text.splitlines()]
|
||||
for line in updated.splitlines():
|
||||
if not inserted and "Markdown -->" in line:
|
||||
output_lines.extend(html_lines)
|
||||
inserted = True
|
||||
continue
|
||||
output_lines.append(line)
|
||||
if not inserted:
|
||||
raise RuntimeError("Template placeholder not found.")
|
||||
return "\n".join(output_lines) + "\n"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_template_text() -> str:
|
||||
return TEMPLATE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def convert(markdown_text: str, fallback_title: str = "Document") -> str:
|
||||
html_text = render_markdown(markdown_text)
|
||||
title = extract_title(html_text, fallback_title)
|
||||
template_text = load_template_text()
|
||||
return apply_template(template_text, html_text, title)
|
||||
@@ -0,0 +1,270 @@
|
||||
import threading
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from html.parser import HTMLParser
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import streamlit as st
|
||||
|
||||
try:
|
||||
from app.converter import convert
|
||||
from app.version import __version__
|
||||
except ModuleNotFoundError:
|
||||
sys.path.append(str(Path(__file__).resolve().parent.parent))
|
||||
from app.converter import convert
|
||||
from app.version import __version__
|
||||
|
||||
MAX_PREVIEW_STORE_ITEMS = 20
|
||||
|
||||
|
||||
class BodyInnerHTMLParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=False)
|
||||
self._inside_body = False
|
||||
self._depth = 0
|
||||
self._parts: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None:
|
||||
rendered = self.get_starttag_text()
|
||||
if tag == "body":
|
||||
self._inside_body = True
|
||||
self._depth = 0
|
||||
return
|
||||
if self._inside_body and rendered is not None:
|
||||
self._parts.append(rendered)
|
||||
self._depth += 1
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "body" and self._inside_body:
|
||||
self._inside_body = False
|
||||
self._depth = 0
|
||||
return
|
||||
if self._inside_body:
|
||||
self._parts.append(f"</{tag}>")
|
||||
if self._depth > 0:
|
||||
self._depth -= 1
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs) -> None:
|
||||
if self._inside_body:
|
||||
rendered = self.get_starttag_text()
|
||||
if rendered is not None:
|
||||
self._parts.append(rendered)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._inside_body:
|
||||
self._parts.append(data)
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
if self._inside_body:
|
||||
self._parts.append(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
if self._inside_body:
|
||||
self._parts.append(f"&#{name};")
|
||||
|
||||
def handle_comment(self, data: str) -> None:
|
||||
if self._inside_body:
|
||||
self._parts.append(f"<!--{data}-->")
|
||||
|
||||
def body_html(self) -> str:
|
||||
return "".join(self._parts).strip()
|
||||
|
||||
|
||||
def extract_body_html(document_html: str) -> str:
|
||||
parser = BodyInnerHTMLParser()
|
||||
parser.feed(document_html)
|
||||
parser.close()
|
||||
return parser.body_html()
|
||||
|
||||
|
||||
@st.cache_resource
|
||||
def get_preview_runtime() -> dict[str, object]:
|
||||
store: OrderedDict[str, str] = OrderedDict()
|
||||
lock = threading.Lock()
|
||||
|
||||
class PreviewHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
prefix = "/preview/"
|
||||
if not self.path.startswith(prefix):
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
preview_id = self.path[len(prefix) :].split("?", 1)[0]
|
||||
with lock:
|
||||
document_html = store.get(preview_id)
|
||||
|
||||
if document_html is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
payload = document_html.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
return
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), PreviewHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return {
|
||||
"base_url": f"http://127.0.0.1:{server.server_port}",
|
||||
"store": store,
|
||||
"lock": lock,
|
||||
}
|
||||
|
||||
|
||||
def register_preview(document_html: str) -> str:
|
||||
runtime = get_preview_runtime()
|
||||
preview_id = uuid.uuid4().hex
|
||||
store = runtime["store"]
|
||||
lock = runtime["lock"]
|
||||
|
||||
with lock:
|
||||
store[preview_id] = document_html
|
||||
while len(store) > MAX_PREVIEW_STORE_ITEMS:
|
||||
store.popitem(last=False)
|
||||
|
||||
return f"{runtime['base_url']}/preview/{preview_id}"
|
||||
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Markdown to HTML",
|
||||
page_icon=":material/description:",
|
||||
layout="centered",
|
||||
)
|
||||
|
||||
if "html_result" not in st.session_state:
|
||||
st.session_state["html_result"] = None
|
||||
if "output_name" not in st.session_state:
|
||||
st.session_state["output_name"] = "document.html"
|
||||
if "preview_url" not in st.session_state:
|
||||
st.session_state["preview_url"] = None
|
||||
|
||||
st.title("Markdown → HTML")
|
||||
st.caption(
|
||||
f"Версия {__version__}. Загрузите markdown-файл или вставьте текст, проверьте превью и скачайте готовый HTML."
|
||||
)
|
||||
|
||||
input_mode = st.segmented_control(
|
||||
"Источник Markdown",
|
||||
options=["Файл", "Текст"],
|
||||
default="Файл",
|
||||
)
|
||||
|
||||
uploaded_file = None
|
||||
pasted_markdown = ""
|
||||
|
||||
if input_mode == "Файл":
|
||||
uploaded_file = st.file_uploader(
|
||||
"Загрузите .md файл",
|
||||
type=["md", "markdown"],
|
||||
)
|
||||
else:
|
||||
pasted_markdown = st.text_area(
|
||||
"Вставьте Markdown из буфера обмена",
|
||||
placeholder="# Заголовок\n\nВставьте сюда markdown-текст.",
|
||||
height=260,
|
||||
)
|
||||
|
||||
html_result = st.session_state["html_result"]
|
||||
is_convert_disabled = (
|
||||
uploaded_file is None if input_mode == "Файл" else not pasted_markdown.strip()
|
||||
)
|
||||
|
||||
with st.container(border=True):
|
||||
action_col, preview_col, download_col = st.columns(
|
||||
[1.1, 1, 1],
|
||||
vertical_alignment="center",
|
||||
)
|
||||
|
||||
with action_col:
|
||||
convert_clicked = st.button(
|
||||
"Конвертировать",
|
||||
disabled=is_convert_disabled,
|
||||
type="primary",
|
||||
icon=":material/auto_awesome:",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
with preview_col:
|
||||
if html_result and st.session_state["preview_url"] is not None:
|
||||
st.link_button(
|
||||
"Открыть превью",
|
||||
url=st.session_state["preview_url"],
|
||||
icon=":material/open_in_new:",
|
||||
use_container_width=True,
|
||||
)
|
||||
else:
|
||||
st.button(
|
||||
"Открыть превью",
|
||||
disabled=True,
|
||||
icon=":material/open_in_new:",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
with download_col:
|
||||
if html_result:
|
||||
st.download_button(
|
||||
"Скачать HTML",
|
||||
data=html_result,
|
||||
file_name=st.session_state["output_name"],
|
||||
mime="text/html",
|
||||
icon=":material/download:",
|
||||
use_container_width=True,
|
||||
)
|
||||
else:
|
||||
st.button(
|
||||
"Скачать HTML",
|
||||
disabled=True,
|
||||
icon=":material/download:",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
if html_result:
|
||||
st.caption(":green-badge[Результат готов]")
|
||||
else:
|
||||
st.caption("После конвертации здесь появятся действия с готовым файлом.")
|
||||
|
||||
if convert_clicked and not is_convert_disabled:
|
||||
if input_mode == "Файл":
|
||||
markdown_bytes = uploaded_file.getvalue()
|
||||
markdown_text = markdown_bytes.decode("utf-8")
|
||||
fallback_title = Path(uploaded_file.name).stem or "Document"
|
||||
output_name = f"{fallback_title}.html"
|
||||
else:
|
||||
markdown_text = pasted_markdown
|
||||
fallback_title = "Document"
|
||||
output_name = "document.html"
|
||||
|
||||
try:
|
||||
st.session_state["html_result"] = convert(
|
||||
markdown_text,
|
||||
fallback_title=fallback_title,
|
||||
)
|
||||
st.session_state["output_name"] = output_name
|
||||
st.session_state["preview_url"] = register_preview(st.session_state["html_result"])
|
||||
st.rerun()
|
||||
except RuntimeError as exc:
|
||||
st.session_state["html_result"] = None
|
||||
st.session_state["preview_url"] = None
|
||||
st.error(str(exc))
|
||||
|
||||
html_result = st.session_state["html_result"]
|
||||
if html_result:
|
||||
body_html = extract_body_html(html_result)
|
||||
|
||||
with st.container(border=True):
|
||||
st.caption(
|
||||
"Inline-превью без стилей. Для точного вида — «Открыть превью» или скачайте файл."
|
||||
)
|
||||
st.markdown(body_html, unsafe_allow_html=True)
|
||||
|
||||
with st.expander("Показать исходный HTML", icon=":material/code:"):
|
||||
st.code(html_result, language="html")
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
VERSION_FILE = Path(__file__).resolve().parent.parent / "VERSION"
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
return VERSION_FILE.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
__version__ = read_version()
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,41 @@
|
||||
Описание Github API конвертера markdown в HTML: https://docs.github.com/en/rest/markdown/markdown?apiVersion=2022-11-28
|
||||
|
||||
Пример вызова API:
|
||||
```bash
|
||||
curl -L \
|
||||
-X POST \
|
||||
-H "Accept: text/html" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/markdown \
|
||||
-d '{"text":"## Title 2\nHello **world**"}'
|
||||
```
|
||||
Ответ:
|
||||
```html
|
||||
<div class="markdown-heading"><h2 class="heading-element">Title 2</h2><a id="user-content-title-2" class="anchor" aria-label="Permalink: Title 2" href="#title-2"><span aria-hidden="true" class="octicon octicon-link"></span></a></div>
|
||||
<p>Hello <strong>world</strong></p>
|
||||
```
|
||||
Нужен простой python скрипт, который будет:
|
||||
1. Принимать на вход путь к markdown файлу `..path/example.md`
|
||||
2. Через Github API конвертировать его в html
|
||||
3. Формировать новый html файл по шаблону `md\template.html` и сохранять результат рядом в `..path/example.html`
|
||||
|
||||
===
|
||||
|
||||
У меня есть готовый пайтон скрипт md_to_html.py, который умеет конвертировать markdown в html с помощью Github API.
|
||||
|
||||
Мне нужно переделать его в простое Streamlit приложение, которое будет иметь следующий интерфейс:
|
||||
1. Поле для загрузки markdown файла
|
||||
2. Кнопка для конвертации
|
||||
3. Поле для отображения результата в виде HTML и возможность скачать результат в виде HTML файла
|
||||
|
||||
Так же хочу этот проект запускать в докере.
|
||||
|
||||
Было бы классно еще иметь один публичный API ендпоинт, который будет принимать markdown текст и возвращать html результат, чтобы можно было использовать этот сервис в других приложениях.
|
||||
|
||||
Задай вопросы, если что-то не понятно или есть неоднозночности или неопределенности.
|
||||
|
||||
Создай репозиторий на GitHub для этого проекта.
|
||||
Введи версии релизов.
|
||||
Настрой на гитхаб Actions для автоматической сборки и публикации докер образа при каждом релизе.
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
# План: md-to-html — Streamlit UI + публичный API в Docker
|
||||
|
||||
## Context
|
||||
|
||||
Сейчас в проекте есть CLI-скрипт `md_to_html.py`, который через GitHub Markdown API конвертирует `.md` файл в самодостаточный HTML (с CSS-шаблоном `md/template.html`). Нужно превратить его в сервис с двумя интерфейсами:
|
||||
|
||||
1. **Streamlit UI** — загрузить `.md`, нажать кнопку, увидеть превью HTML и скачать результат.
|
||||
2. **Публичный REST API** — принимает markdown-текст, отдаёт готовый HTML. Для интеграции с другими приложениями.
|
||||
|
||||
Всё это должно упаковываться в Docker-образ.
|
||||
|
||||
Решения, зафиксированные по ходу обсуждения:
|
||||
- FastAPI и Streamlit живут в **одном контейнере** (два процесса, запуск через стартовый скрипт).
|
||||
- Возвращается **полная HTML-страница** с применённым `template.html` — и в API, и в превью Streamlit.
|
||||
- Читаем `GITHUB_TOKEN` из env (опционально, для обхода лимита 60/час).
|
||||
- API **без аутентификации и rate-limiting** — минимальный стартовый вариант.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
md-to-html/
|
||||
├── md_to_html.py # оставить как есть (работающий CLI)
|
||||
├── md/template.html # без изменений, используется как раньше
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── converter.py # общая логика (вынесена из md_to_html.py)
|
||||
│ ├── api.py # FastAPI приложение
|
||||
│ └── streamlit_app.py # Streamlit UI
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
├── start.py # Python-супервизор: запускает uvicorn + streamlit
|
||||
├── .dockerignore
|
||||
└── README.md # короткая инструкция запуска
|
||||
```
|
||||
|
||||
Порты в контейнере: FastAPI — 8000, Streamlit — 8501. Оба пробрасываются наружу.
|
||||
|
||||
## Что делать
|
||||
|
||||
### 1. `app/converter.py` — общий модуль
|
||||
|
||||
Вынести из `md_to_html.py` переиспользуемые функции (без изменения логики):
|
||||
|
||||
- `render_markdown(markdown_text: str) -> str` — вызов GitHub API. Добавить чтение `GITHUB_TOKEN` из env: если задан, слать `Authorization: Bearer <token>`.
|
||||
- `FirstHeadingParser` + `extract_title(html_text, fallback) -> str`.
|
||||
- `apply_template(template_text, html_text, title) -> str`.
|
||||
- Хелпер `convert(markdown_text: str, fallback_title: str = "Document") -> str` — объединяет три шага и читает `md/template.html` один раз (кэшировать через `functools.lru_cache`).
|
||||
|
||||
Путь к шаблону: `Path(__file__).resolve().parent.parent / "md" / "template.html"`.
|
||||
|
||||
`md_to_html.py` переписать так, чтобы он импортировал `convert` из `app.converter` — убрать дублирование. CLI-поведение сохранить (входной путь → записать рядом `.html`).
|
||||
|
||||
### 2. `app/api.py` — FastAPI
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `POST /convert`
|
||||
- Тело: `{"markdown": "<text>", "title": "<optional>"}` — Pydantic-модель с `field_validator` на `markdown`, проверяющим `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (именно **байты UTF-8**, не `constr(max_length=...)` — тот считает символы). При превышении — `raise HTTPException(status_code=413, detail=...)`, чтобы обойти дефолтный `422` FastAPI-валидатора.
|
||||
- **Приоритет title (зафиксировано явно):**
|
||||
1. Первый `<h1..h6>` из отрендеренного HTML.
|
||||
2. Если heading отсутствует — переданный `title` из запроса.
|
||||
3. Если и его нет — `"Document"`.
|
||||
- Ответ: `text/html` с полной страницей (`Response(content=..., media_type="text/html; charset=utf-8")`).
|
||||
- Ошибки: пустой markdown → `400` (через отдельную проверку в роуте, до валидации); превышение размера → `413` (через ручной `HTTPException` в валидаторе, см. выше); `RuntimeError` от GitHub API → `502 Bad Gateway` с текстом исключения.
|
||||
- Дополнительно: `exception_handler(RequestValidationError)` перехватывает Pydantic-422 и возвращает структурированный `400` — чтобы публичный API не отдавал разные коды на разные виды плохого ввода.
|
||||
- `GET /health` → `{"status": "ok"}` для проверки.
|
||||
- `GET /ready` → проверяет, что шаблон загружен и при желании пингует `https://api.github.com` (опционально).
|
||||
|
||||
**Лимит размера запроса (два уровня, defence-in-depth):**
|
||||
|
||||
1. **Hard guard — ASGI middleware до парсинга тела.** Читает `Content-Length` и, если превышает `MAX_REQUEST_BYTES = 1_200_000` (немного больше лимита на поле, чтобы учитывать JSON-обёртку), возвращает `413` без чтения тела. Если `Content-Length` отсутствует (chunked) — аккуратно считать байты из `receive()` и обрывать при превышении. Это настоящий request-size guard, не post-parse.
|
||||
2. **Soft guard — Pydantic `field_validator` на поле `markdown`,** проверяющий `len(value.encode("utf-8")) <= MAX_MARKDOWN_BYTES` (`1_048_576`, 1 МБ) и поднимающий `HTTPException(413)`. Это вторая линия — на случай, если middleware обойдут (прокси, переписанные заголовки).
|
||||
|
||||
GitHub Markdown API сам ограничивает вход ~400 КБ, поэтому 1 МБ на стороне сервиса — безопасный запас с отсечкой абьюза. Значения вынести в env `MAX_MARKDOWN_BYTES` и `MAX_REQUEST_BYTES`.
|
||||
|
||||
CORS открыть для всех origin (`allow_origins=["*"]`, `allow_methods=["POST","GET"]`, `allow_headers=["content-type"]`).
|
||||
|
||||
### 3. `app/streamlit_app.py` — UI
|
||||
|
||||
Минимальный интерфейс:
|
||||
|
||||
1. `st.title("Markdown → HTML")`
|
||||
2. `st.file_uploader("Загрузите .md файл", type=["md", "markdown"])`
|
||||
3. Кнопка `st.button("Конвертировать")` — активна только когда файл загружен.
|
||||
4. После клика: вызвать `convert()` из `app.converter` напрямую (не через HTTP — это тот же Python-импорт, один процесс).
|
||||
5. Результат (три способа посмотреть, без deprecated API и без iframe-споров):
|
||||
- **Inline-превью (approximate):** извлечь содержимое `<body>` из результата (простой regex/BeautifulSoup — фрагмент HTML без `<html>/<head>`, без CSS из template) и отрендерить через `st.markdown(body_html, unsafe_allow_html=True)`. Это приблизительный рендер без стилей template — нужен для быстрой проверки разметки. Явно подписать: *«Inline-превью без стилей. Для точного вида — «Открыть превью в новой вкладке» или скачайте файл.»*
|
||||
- **Превью в новой вкладке:** `st.link_button("Открыть превью", url=f"data:text/html;charset=utf-8;base64,{b64(html_result)}")` — data-URL с полной страницей. Визуально идентично скачанному файлу, без component-API. **Fallback на большие документы:** если `len(html_result.encode()) > 1_500_000`, кнопка не рендерится, вместо неё показать `st.info("Документ слишком большой для превью в браузере. Скачайте файл.")` — браузеры ограничивают длину data-URL (~2 МБ в Chrome), а base64-кодирование раздувает payload в 1.33×.
|
||||
- **Скачивание:** `st.download_button("Скачать HTML", data=html_result, file_name=f"{stem}.html", mime="text/html")`.
|
||||
- **Сырой HTML:** `st.expander("Показать исходный HTML")` с `st.code(html_result, language="html")`.
|
||||
|
||||
Обоснование отказа от `st.components.v1.html` / `st.components.v2.*`: project skill `developing-with-streamlit` помечает v1 как deprecated, а v2 — это API для custom components с Python↔JS (`st.components.v2.component()`), не для простого показа HTML. Для нашего случая native-решения (`st.markdown` + `st.link_button` с data-URL) достаточно.
|
||||
6. Хранить результат в `st.session_state["html_result"]`, чтобы повторный rerun (клик по expander, download) не терял его и не гонял GitHub API заново.
|
||||
|
||||
Обработка ошибок: `try/except RuntimeError` → `st.error(str(e))`.
|
||||
|
||||
### 4. Зависимости и окружение (локальная разработка)
|
||||
|
||||
Локально использовать **`uv`** + виртуальное окружение, не системный `pip`:
|
||||
|
||||
```bash
|
||||
uv venv .venv # создать venv в .venv/
|
||||
source .venv/bin/activate # (или `uv run <cmd>` без активации)
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Либо эквивалент через `uv pip sync requirements.txt`, либо (если решим сразу в pep-621-формате) — `pyproject.toml` + `uv sync`. Для данного плана достаточно плоского `requirements.txt` ради совместимости с Docker-образом, где `uv` не обязателен.
|
||||
|
||||
`.gitignore`/`.dockerignore` — исключить `.venv/`.
|
||||
|
||||
**`requirements.txt`:**
|
||||
|
||||
```
|
||||
streamlit>=1.42
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.32
|
||||
pydantic>=2.9
|
||||
```
|
||||
|
||||
Streamlit зафиксирован `>=1.42` (актуальная стабильная ветка на момент планирования). Никаких HTTP-клиентов: `render_markdown` использует стандартную `urllib`. Для извлечения `<body>` в inline-превью достаточно stdlib (`html.parser`) — ту же `HTMLParser`-базу, что уже применяется в `FirstHeadingParser`. Отдельная зависимость на BeautifulSoup не нужна.
|
||||
|
||||
**В Docker-образе** `uv` не используется — остаёмся на `pip install --no-cache-dir -r requirements.txt`, чтобы не тащить лишний бинарь в runtime-образ. `uv` — только для локального dev-цикла.
|
||||
|
||||
### 5. `Dockerfile`
|
||||
|
||||
- Базовый образ: `python:3.12-slim`.
|
||||
- Установить `tini` через apt (`apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*`).
|
||||
- `WORKDIR /app`, скопировать `requirements.txt`, `pip install --no-cache-dir -r requirements.txt`.
|
||||
- Скопировать остальной код.
|
||||
- `EXPOSE 8000 8501`.
|
||||
- `ENTRYPOINT ["/usr/bin/tini", "--"]`, `CMD ["python", "start.py"]`.
|
||||
- `HEALTHCHECK` — см. секцию 6b.
|
||||
|
||||
### 6. Запуск двух процессов — `start.py` (супервизор)
|
||||
|
||||
В одном контейнере два процесса — это риск (см. F-01 из ревью: упавший uvicorn, неубитые zombies, игнор сигналов PID 1). Вместо хрупкого shell-скрипта использовать маленький Python-супервизор, который:
|
||||
|
||||
- стартует `uvicorn` и `streamlit` через `subprocess.Popen`;
|
||||
- пробрасывает `SIGTERM`/`SIGINT` обоим дочерним через `signal.signal`;
|
||||
- ждёт через `os.wait()` — **если любой из детей падает, супервизор убивает второго и выходит с его exit code** (контейнер корректно умирает и Docker перезапускает его по restart policy, а не живёт-зомби на одном сервисе);
|
||||
- после `SIGTERM` делает graceful shutdown с таймаутом (например 10 сек), затем `SIGKILL`.
|
||||
|
||||
Скрипт ~40 строк, без дополнительных зависимостей. Альтернатива `tini` как init (`ENTRYPOINT ["/usr/bin/tini", "--"]`) — для reaping, но решение о fail-fast всё равно за супервизором.
|
||||
|
||||
Ориентир (псевдокод, финализировать при реализации):
|
||||
|
||||
```python
|
||||
# start.py
|
||||
import os, signal, subprocess, sys
|
||||
procs = [
|
||||
subprocess.Popen(["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]),
|
||||
subprocess.Popen(["streamlit", "run", "app/streamlit_app.py",
|
||||
"--server.port", "8501", "--server.address", "0.0.0.0",
|
||||
"--server.headless", "true",
|
||||
"--browser.gatherUsageStats", "false"]),
|
||||
]
|
||||
def shutdown(signum, _frame):
|
||||
for p in procs: p.terminate()
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
pid, status = os.wait()
|
||||
for p in procs:
|
||||
if p.pid != pid: p.terminate()
|
||||
sys.exit(os.waitstatus_to_exitcode(status))
|
||||
```
|
||||
|
||||
`Dockerfile` использует `CMD ["python", "start.py"]` плюс `ENTRYPOINT ["tini", "--"]` (ставится через `apt-get install -y --no-install-recommends tini`) для надёжного reaping.
|
||||
|
||||
### 6b. Healthcheck
|
||||
|
||||
`HEALTHCHECK` в Dockerfile проверяет **оба** сервиса:
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD python -c "import urllib.request as u; \
|
||||
u.urlopen('http://127.0.0.1:8000/health', timeout=3); \
|
||||
u.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)" || exit 1
|
||||
```
|
||||
|
||||
Если упадёт только Streamlit — healthcheck покраснеет, контейнер перезапустится (в паре с restart policy).
|
||||
|
||||
### 7. `.dockerignore`
|
||||
|
||||
Исключить `.git`, `.DS_Store`, `.claude/`, `.agents/`, `.review-sandboxes/`, `md/*.html` (сгенерированное), `__pycache__/`, `*.pyc`, `venv/`, `.venv/`.
|
||||
|
||||
### 8. `README.md`
|
||||
|
||||
Короткий блок: как собрать образ (`docker build -t md-to-html .`), как запустить (`docker run -p 8000:8000 -p 8501:8501 -e GITHUB_TOKEN=... md-to-html`), примеры `curl` для API.
|
||||
|
||||
## Критические файлы
|
||||
|
||||
- **Создать:** `app/converter.py`, `app/api.py`, `app/streamlit_app.py`, `app/__init__.py`, `requirements.txt`, `Dockerfile`, `start.py`, `.dockerignore`, `README.md`.
|
||||
- **Изменить:** `md_to_html.py` (переписать на использование `app.converter.convert`).
|
||||
- **Без изменений:** `md/template.html`, `md/01-01-pretask.md`.
|
||||
|
||||
## Проверка (verification)
|
||||
|
||||
1. **Локально без Docker (через uv + venv):**
|
||||
- `uv venv .venv && source .venv/bin/activate && uv pip install -r requirements.txt`.
|
||||
- `uvicorn app.api:app --reload` → `curl -X POST http://localhost:8000/convert -H 'Content-Type: application/json' -d '{"markdown":"# Hello"}'` — должна вернуться полная HTML-страница с `<title>Hello</title>`.
|
||||
- `streamlit run app/streamlit_app.py` → загрузить `md/01-01-pretask.md`, нажать кнопку, убедиться что превью отрисовывается и скачивание работает.
|
||||
- `GET /health` → `{"status":"ok"}`.
|
||||
- CLI не сломался: `python md_to_html.py md/01-01-pretask.md` создаёт рядом `.html`, идентичный прежнему.
|
||||
|
||||
2. **Error paths API:**
|
||||
- `curl -X POST .../convert -d '{"markdown":""}'` → `400`.
|
||||
- `curl` с телом >1 МБ (поле `markdown` превышает `MAX_MARKDOWN_BYTES`) → `413` (validator).
|
||||
- `curl --data-binary @big.json` >1.2 МБ общего размера → `413` (middleware).
|
||||
- `curl -H "Transfer-Encoding: chunked" --data-binary @big.json` без `Content-Length` → `413` (middleware-ветка подсчёта байт из `receive()`).
|
||||
- Невалидный JSON (отсутствует поле `markdown`) → `400` (через `RequestValidationError` handler).
|
||||
- Имитация недоступности GitHub (временно подменить `API_URL` или поднять firewall rule) → `502`, контейнер не падает.
|
||||
|
||||
3. **Preview fallback в Streamlit:** загрузить синтетический markdown, дающий HTML >1.5 МБ — убедиться, что `link_button` скрывается и появляется `st.info` про скачивание; `download_button` при этом работает.
|
||||
|
||||
4. **В Docker:**
|
||||
- `docker build -t md-to-html .` — собирается без ошибок.
|
||||
- `docker run --rm -p 8000:8000 -p 8501:8501 md-to-html` — оба порта отвечают.
|
||||
- Открыть `http://localhost:8501`, прогнать сценарий из пункта 1.
|
||||
- `curl` на `http://localhost:8000/convert` работает снаружи контейнера.
|
||||
|
||||
5. **Supervision (F-01):**
|
||||
- Внутри работающего контейнера убить uvicorn (`docker exec ... pkill -f uvicorn`) → контейнер **должен завершиться** (не остаться с одним Streamlit). `docker ps` покажет рестарт.
|
||||
- `docker stop <container>` — оба процесса уходят в ≤10 сек, exit code корректный.
|
||||
- `docker inspect ... | grep Health` после 30 сек — `healthy`; после kill любого сервиса — `unhealthy`.
|
||||
|
||||
6. **С токеном:** `docker run -e GITHUB_TOKEN=ghp_... ...` — запросы проходят, в логах нет 403/429 при нагрузке.
|
||||
@@ -0,0 +1,65 @@
|
||||
1. Функциональный паритет с GitHub API. Сейчас HTML от GitHub даёт: таблицы, task-list, strikethrough, autolinks, footnotes, подсветку кода, emoji
|
||||
:name:, и главное — обёртки <div class="markdown-heading"> с якорями <a class="anchor"> (на них завязан CSS в template.html). Что нужно сохранить 1-в-1?
|
||||
- a) Полный GFM (goldmark поддерживает через extension.GFM) — да/нет?
|
||||
- b) Подсветку кода chroma встроить в <pre><code>? Или оставить «просто теги» без классов?
|
||||
- c) Emoji-shortcodes (yuin/goldmark-emoji)?
|
||||
- d) Обёртки heading’ов с якорями (делается через abhinav/goldmark-anchor или кастомный renderer). Если убрать — придётся править CSS в шаблоне.
|
||||
- e) Frontmatter (---) — парсить/игнорировать/использовать для title?
|
||||
|
||||
2. Архитектура Go-приложения. Предлагаю один бинарник с подкомандами:
|
||||
- serve — единый HTTP-сервер: / и /convert (форма на templUI, HTMX-превью), /api/convert, /preview/{id}, /health, /version, /ready, /download/{id}.
|
||||
- cli <file.md> — режим CLI (заменяет md_to_html.py).
|
||||
|
||||
Подходит, или надо разделить два бинарника (api и ui)?
|
||||
|
||||
3. templUI-стек. templUI = templ + Tailwind + Alpine.js + HTMX. Подтвердите:
|
||||
- a) Tailwind CSS сборку (tailwindcss CLI) встраиваем в Docker/Makefile?
|
||||
- b) HTMX для live-превью (без полной перезагрузки) — желательно?
|
||||
- c) Тёмная тема / языковой переключатель — нужны или оставляем русский-only как сейчас?
|
||||
|
||||
4. Структура репозитория. Ваш вариант — archive/ для Python, корень для Go. Подтвердите:
|
||||
/archive/ # текущий Python-проект целиком
|
||||
/cmd/md-to-html/ # main.go
|
||||
/internal/converter/ # goldmark-рендер
|
||||
/internal/server/ # HTTP handlers
|
||||
/internal/ui/ # .templ файлы (templUI components)
|
||||
/web/static/ # Tailwind output, favicon
|
||||
/template/ # самодостаточный HTML-шаблон для итога
|
||||
go.mod / Makefile / Dockerfile
|
||||
Go module path — github.com/fserg/md-to-html?
|
||||
|
||||
5. Шаблон итогового HTML. template.html сейчас рассчитан на GitHub-разметку (классы .markdown-heading, .heading-element, .anchor). Варианты:
|
||||
- a) Сохранить визуал идентично → реализовать goldmark-renderer, генерирующий ту же разметку.
|
||||
- b) Упростить шаблон под «чистый» goldmark-вывод (меньше кода, чуть другой вид h2/h3).
|
||||
|
||||
Какой?
|
||||
|
||||
6. Версионирование и релизы. Сейчас v0.1.2, GitHub Actions собирает Docker в GHCR. После переписывания:
|
||||
- a) Бампнуть до v0.2.0 (или v1.0.0)?
|
||||
- b) CI: заменить на Go-сборку (тесты + cross-compile linux/amd64,arm64, darwin/arm64) + Docker multi-stage?
|
||||
- c) Публиковать бинарники в GitHub Releases?
|
||||
|
||||
7. Нефункциональное.
|
||||
- Лимиты MAX_MARKDOWN_BYTES, MAX_REQUEST_BYTES — переносим (envs)?
|
||||
- Go 1.23+?
|
||||
- Тесты: golden-файлы (MD→HTML diff против эталона) + smoke-тесты HTTP?
|
||||
- air / templ generate --watch для dev-режима?
|
||||
|
||||
# Ответы
|
||||
1. Полный GFM и хотелось бы подсветку кода. Шаблон можно править как угодно под новый рендер. Якоря в заголовках хотелось бы сохранить, так как они полезны для навигации по документу.
|
||||
|
||||
2. Один бинарник, включая cli режим.
|
||||
|
||||
3. templUI можно встраивать в бинарник? Лайв-превью было бы круто, но не критично. Тёмная тема и языковой переключатель не нужны, так как целевая аудитория русскоязычная.
|
||||
|
||||
4. archive/ для Python, корень для Go
|
||||
|
||||
5. Шаблон поменяй под новый проект
|
||||
|
||||
6. После перехода на Go предлагаю бампнуть до v0.2.0
|
||||
|
||||
7. Про лимиты не знаю, на твое усмотрение и Го - на твой выбор. Тесты с golden-файлами звучат отлично, а для dev-режима air / templ generate --watch будет удобно.
|
||||
|
||||
===
|
||||
|
||||
Сохрани подробный план как md/02-01-plan.md
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Проект 1С УНФ 3.0</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
background: #fafafa;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
padding: 50px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.markdown-heading {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.heading-element {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
h2.heading-element {
|
||||
font-size: 28px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
h3.heading-element {
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.markdown-heading:hover .anchor {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.anchor:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2px;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #525252;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
|
||||
h2.heading-element {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h3.heading-element {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="markdown-heading">
|
||||
<h2 class="heading-element">Проект 1С Управление нашей фирмой (УНФ) 3.0 с доработками в расширениях
|
||||
конфигурации
|
||||
</h2><a id="user-content-проект-1с-управление-нашей-фирмой-унф-30-с-доработками-в-расширениях-конфигурации"
|
||||
class="anchor"
|
||||
aria-label="Permalink: Проект 1С Управление нашей фирмой (УНФ) 3.0 с доработками в расширениях конфигурации"
|
||||
href="#проект-1с-управление-нашей-фирмой-унф-30-с-доработками-в-расширениях-конфигурации"><span
|
||||
aria-hidden="true" class="octicon octicon-link"></span></a>
|
||||
</div>
|
||||
<div class="markdown-heading">
|
||||
<h3 class="heading-element">Основная кодовая база</h3><a id="user-content-основная-кодовая-база"
|
||||
class="anchor" aria-label="Permalink: Основная кодовая база" href="#основная-кодовая-база"><span
|
||||
aria-hidden="true" class="octicon octicon-link"></span></a>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Исходный код основной конфигурации 1С Управление нашей фирмой (УТ) 3.0.12.146:
|
||||
<code>1c-src/Configuration</code>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="markdown-heading">
|
||||
<h3 class="heading-element">Расширения конфигурации</h3><a id="user-content-расширения-конфигурации"
|
||||
class="anchor" aria-label="Permalink: Расширения конфигурации" href="#расширения-конфигурации"><span
|
||||
aria-hidden="true" class="octicon octicon-link"></span></a>
|
||||
</div>
|
||||
<ul>
|
||||
<li>расширение конфигурации АПРО_Доработки <code>1c-src/ExtensionsXML/АПРО_Доработки</code> с доработками
|
||||
функционала по рабочему месту кассиров (РМК) и части документов</li>
|
||||
</ul>
|
||||
<div class="markdown-heading">
|
||||
<h2 class="heading-element">Окружение разработки</h2><a id="user-content-окружение-разработки"
|
||||
class="anchor" aria-label="Permalink: Окружение разработки" href="#окружение-разработки"><span
|
||||
aria-hidden="true" class="octicon octicon-link"></span></a>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Разработка ведется в операционной системе Ubuntu 24.04 с использованием платформы 1С:Предприятие
|
||||
8.3.27.1688 и
|
||||
конфигуратора 1С:Предприятие.</li>
|
||||
<li>В системе доступен Python 3.12.</li>
|
||||
<li>Агентские возможности нужно запускать учитывая особенности консоли на Bash.</li>
|
||||
</ul>
|
||||
<div class="markdown-heading">
|
||||
<h2 class="heading-element">MCP-серверы и когда их вызывать</h2><a
|
||||
id="user-content-mcp-серверы-и-когда-их-вызывать" class="anchor"
|
||||
aria-label="Permalink: MCP-серверы и когда их вызывать" href="#mcp-серверы-и-когда-их-вызывать"><span
|
||||
aria-hidden="true" class="octicon octicon-link"></span></a>
|
||||
</div>
|
||||
<div class="markdown-heading">
|
||||
<h3 class="heading-element">1с-metadata (MCP)</h3><a id="user-content-1с-metadata-mcp" class="anchor"
|
||||
aria-label="Permalink: 1с-metadata (MCP)" href="#1с-metadata-mcp"><span aria-hidden="true"
|
||||
class="octicon octicon-link"></span></a>
|
||||
</div>
|
||||
<p><strong>Назначение:</strong> быстрый поиск описаний объектов конфигурации (структуры метаданных).
|
||||
<strong>Жёсткий порядок работы:</strong>
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<code>search_metadata(query[, object_type])</code> → топ-K совпадений (как минимум: <code>id</code>,
|
||||
<code>name</code>, иногда <code>type</code>, <code>score</code>).
|
||||
</li>
|
||||
<li>Выбираешь релевантный результат и вызываешь <code>metadata_details_by_id(id)</code> → подробности по
|
||||
объекту.
|
||||
</li>
|
||||
</ol>
|
||||
<p><strong>Использовать, когда:</strong></p>
|
||||
<ul>
|
||||
<li>нужно понять, существуют ли документ/справочник/регистр и как они называются;</li>
|
||||
<li>требуется структура объекта, реквизиты, измерения, ресурсы, табличные части и т.п.;</li>
|
||||
<li>нужно уточнить корректные имена метаданных перед написанием запроса/кода.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from app.converter import convert
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a Markdown file to HTML using the GitHub Markdown API."
|
||||
)
|
||||
parser.add_argument("input", help="Path to the Markdown file to convert")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
input_path = Path(args.input).expanduser().resolve()
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(f"Input file not found: {input_path}")
|
||||
|
||||
markdown_text = input_path.read_text(encoding="utf-8")
|
||||
output_text = convert(markdown_text, fallback_title=input_path.stem)
|
||||
|
||||
output_path = input_path.with_suffix(".html")
|
||||
output_path.write_text(output_text, encoding="utf-8")
|
||||
|
||||
print(f"Saved: {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,4 @@
|
||||
streamlit>=1.42
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.32
|
||||
pydantic>=2.9
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@@ -0,0 +1,122 @@
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
GRACEFUL_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def build_processes() -> list[subprocess.Popen[bytes]]:
|
||||
root = Path(__file__).resolve().parent
|
||||
return [
|
||||
subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app.api:app",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8000",
|
||||
],
|
||||
cwd=root,
|
||||
),
|
||||
subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"streamlit",
|
||||
"run",
|
||||
"app/streamlit_app.py",
|
||||
"--server.port",
|
||||
"8501",
|
||||
"--server.address",
|
||||
"0.0.0.0",
|
||||
"--server.headless",
|
||||
"true",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
],
|
||||
cwd=root,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def stop_processes(processes: list[subprocess.Popen[bytes]], skip_pid: int | None = None) -> None:
|
||||
for process in processes:
|
||||
if process.pid == skip_pid:
|
||||
continue
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
|
||||
|
||||
def reap_processes(
|
||||
processes: list[subprocess.Popen[bytes]], skip_pid: int | None = None
|
||||
) -> None:
|
||||
for process in processes:
|
||||
if process.pid == skip_pid:
|
||||
continue
|
||||
if process.poll() is not None:
|
||||
continue
|
||||
try:
|
||||
process.wait(timeout=GRACEFUL_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
processes = build_processes()
|
||||
exit_code = 0
|
||||
shutting_down = False
|
||||
|
||||
def handle_signal(signum, _frame) -> None:
|
||||
nonlocal exit_code, shutting_down
|
||||
if shutting_down:
|
||||
return
|
||||
shutting_down = True
|
||||
exit_code = 128 + signum
|
||||
stop_processes(processes)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
while True:
|
||||
try:
|
||||
pid, status = os_wait()
|
||||
except ChildProcessError:
|
||||
break
|
||||
except InterruptedError:
|
||||
continue
|
||||
|
||||
process = next((item for item in processes if item.pid == pid), None)
|
||||
if process is not None:
|
||||
process.returncode = os_waitstatus_to_exitcode(status)
|
||||
|
||||
if not shutting_down:
|
||||
exit_code = os_waitstatus_to_exitcode(status)
|
||||
shutting_down = True
|
||||
stop_processes(processes, skip_pid=pid)
|
||||
|
||||
reap_processes(processes, skip_pid=pid)
|
||||
break
|
||||
|
||||
reap_processes(processes)
|
||||
return exit_code
|
||||
|
||||
|
||||
def os_wait() -> tuple[int, int]:
|
||||
import os
|
||||
|
||||
return os.wait()
|
||||
|
||||
|
||||
def os_waitstatus_to_exitcode(status: int) -> int:
|
||||
import os
|
||||
|
||||
return os.waitstatus_to_exitcode(status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><!-- Сюда вставлять текст из первого по порядку <h[N]></h> тега --></title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
background: #fafafa;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
padding: 50px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.markdown-heading {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.heading-element {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
h2.heading-element {
|
||||
font-size: 28px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
h3.heading-element {
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.markdown-heading:hover .anchor {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.anchor:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 8px;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2px;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #525252;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 28px 20px;
|
||||
}
|
||||
|
||||
h2.heading-element {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h3.heading-element {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Начало содержимого Markdown -->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user