Как стать автором
Обновить

«Отчет Creator» или как стать героем в глазах одногруппников

Время на прочтение6 мин
Количество просмотров1.6K

История создания проекта

Вроде смотришь на название дисциплины «Алгоритмы и структуры данных», думаешь, что всё будет супер, а потом тебе говорят, что нужно будет формировать отчёт по каждому разделу курса на платформе. И ты такой: «Ну #₽@&*».

Дело в том, что задач в каждом разделе ну не сказать, что мало, а в отчёте должен быть вставлен и текст, и скриншот кода, и подпись к скриншоту, и всё это должно быть сделано по ГОСТ'у.

«Окей», — думаю я, — «как будто бы и не так сложно». Но в один день, убив около двух часов на один отчёт, пришло понимание, что нужно это исправлять. Так и появился он — Тайлер Дерден мой проект по автоматизации отчётов по АСД, или же «Отчет Creator».

Подходим к проблеме

Я решил писать решение на Python'е, потому что мне нравится лаконичность этого языка и читаемость кода на нем.

Как первый подход к проблеме, я решил задать себе следующие вопросы:

  1. Как я буду получать текст условия задачи?

  2. Как мне придется рендерить код решения, чтоб не сбилась верстка?

  3. Как мне сформировать сам отчет в форме документа Word?

Недолго размышляя, я вспомнил, что для формирования документа можно использовать пакет python-docx, который я подглядел в одном из ответов гпт.

При обдумывании ответа на 1-ый вопрос в голову пришли два варианта: (нежелательный) парсинг, (желательный) апи. Испытав удачу я вбил в поиск: "<название платформы> api", и о чудо, он был в открытом доступе. Хоть только и в сырых json описаниях, но в целом и этого было предостаточно.

2-ой вопрос оказался самым сложным. Казалось бы, придется рендерить страничку с кодом решения, делать скриншот и как-то обрезать это. В этом могла помочь либа для работы с Selenium на Python, но звучало это страшно, сложно и нудно.

Получив четкие ответы на 2/3 вопроса, мне показалось, что идея вполне осуществима. Пошел процесс творчества и претворения замысла в реальность.

Решение

Сперва пришлось хорошенько изучить и выцепить из апишки нужную информацию. Определив, где находятся нужные мне данные, я начал простраивать путь через описание нужных мне объектов:

from pydantic import BaseModel


class Lesson(BaseModel):
    id: int
    steps: list[int]
    title: str


class LessonResponse(BaseModel):
    meta: dict
    lessons: list[Lesson]


class Block(BaseModel):
    name: str
    text: str


class Step(BaseModel):
    id: int
    block: Block


class StepResponse(BaseModel):
    meta: dict
    steps: list[Step]

# ... и т.д.

Описав модели, я принялся писать клиента:

class Client:
    session = get_session()

    def get_section_id(self, course_id: int, section_no: int) -> int:
        resp = self.session.get(f"{API_URL}/courses/{course_id}")
        course_resp = CoursesResponse.model_validate(resp.json())
        sections_ids = course_resp.courses[0].sections
        if not (0 < section_no <= len(sections_ids)):
            raise IndexError("Секции с таким номером не существует")
        return sections_ids[section_no - 1]

    def get_section(self, id: int):
        resp = self.session.get(f"{API_URL}/sections/{id}")
        section_resp = SectionsResponse.model_validate(resp.json())
        return section_resp.sections[0]

    def get_lesson(self, id: int) -> Lesson:
        resp = self.session.get(f"{API_URL}/lessons/{id}")
        lesson_resp = LessonResponse.model_validate(resp.json())
        return Lesson.model_validate(lesson_resp.lessons[0])

    # И другие методы

Вроде бы полдела сделано, осталось написать вордовский клиент и наконец разобраться с рендерингом кода решения. Но тут не все так просто.

Еще раз заглянув в апи, узнал, что можно получить токен входа, благодаря которому можно получить объекты, хранящие в себе строку с решением. Тут то меня и осенило вбить в поисковую строку "str to png python", так я познакомился с пакетом для обработки растровых изображений Pillow. Рендеринг и остальную логику я написал в модуле logic.py:

PADDING = 20
FONT_SIZE = 24
FONT_PATH = "assets/JetBrainsMono-Regular.ttf"
IMGS_PATH = "images"

logger = logging.getLogger(__name__)


def save_code_picture(img_path: str, code_str: str) -> None:
    """Saves picture"""

    img = Image.new("RGB", (1, 1), (255, 255, 255))
    d = ImageDraw.Draw(img)
    font = ImageFont.truetype(FONT_PATH, FONT_SIZE)

    code_str = code_str.strip()
    box_size = d.textbbox(
        (PADDING, PADDING),
        code_str,
        font=font,
    )
    line_length = box_size[2]
    line_height = box_size[3]

    img = img.resize((line_length + PADDING, line_height + PADDING))
    d = ImageDraw.Draw(img)
    d.text(
        (PADDING, PADDING),
        code_str,
        fill=(0, 0, 0),
        font=font,
    )
    img.save(img_path, "PNG")

Так же на эту тему конечно же были написаны тесты, благодаря которым я точно убедился, что все работает вполне на достойном уровне.

Как оказалось, условие задачи требовало мини распарсировки:

def parse_block_text(html_text: str) -> CodeProblem | None:
  
    soup = BeautifulSoup(html_text, "html.parser")
    
    # В ходе тестов выяснилась проблема с отсутствием в описаниях 
    # некоторых задач их заголовка.
    # Было решено не включать их в отчет, по причине сложности таких задач.
    if not soup.h2:
        return None

    problem_title: str = soup.h2.text
    problem_descriptions = []
    for p in soup.find_all("p"):
        text: str = p.text
        if text.startswith("Формат входных данных"):
            break
        problem_descriptions.append(text.replace("\xa0", " "))

    problem_title = problem_title.replace("\xa0", " ")
    return CodeProblem(title=problem_title, description=problem_descriptions)

И конечно же все это нужно было подготовить для дальнейшей обработки вордовским клиентом:

def get_code_solutions(lesson: Lesson) -> list[CodeSolution]:
    logger.info(f"Getting code solutions «{lesson.title}»\n")
    stepik = StepikClient()

    code_solutions = []
    for step_id in lesson.steps:
        step = stepik.get_step(id=step_id)
        if step.block.name != "code":
            continue

        code_problem = parse_block_text(step.block.text)
        if not code_problem:
            continue

        code_str = stepik.get_solution_code(step_id=step_id)
        if not code_str:
            continue

        title = legalize_title(code_problem.title)
        img_path = f"{IMGS_PATH}/{title}.png"

        save_code_picture(img_path, code_str)
        code_solutions.append(
            CodeSolution(
                title=code_problem.title,
                description=code_problem.description,
                img_path=img_path,
            )
        )
    return code_solutions

Супер! Когда основная часть работы сделана, можно заняться и версткой. Всю верстку я поместил в вордовский клиент, чтоб потом все красиво и кратко написать в main.py; за основу для нового документа брал один из своих старых отчетов:

class WordClient:
    def __init__(self, doc_path: str):
        self.doc_name = doc_path
        self.doc = Document(docx=doc_path)

    def add_heading2(self, title: str, heading_no: int) -> None:
        self.doc.add_heading(
            f"2.{heading_no}. Решения задач на тему «{title}»",
            level=2,
        )
        self.doc.add_paragraph()

    def add_solution(self, no: int, title: str, descr: str, img_path: str) -> None:
        self.doc.add_paragraph(f"«{title}».")
        self.doc.add_paragraph(f"{descr}")
        self.doc.add_paragraph()

        pic_p = self.doc.add_paragraph()
        pic_p.add_run().add_picture(img_path)
        pic_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER

        label = self.doc.add_paragraph(f"Рисунок 2.{no} — решение задачи «{title}».")
        label.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER

        self.doc.add_paragraph()

    def add_page_break(self):
        self.doc.add_page_break()

    def save(self, doc_name: str) -> None:
        os.makedirs("my_docs", exist_ok=True)
        if not doc_name.endswith(".docx"):
            doc_name += ".docx"
        self.doc.save(f"my_docs/{doc_name}")

В main.py я слил всю работу воедино, чтоб получить цельную картину:

def main():
    logger.info("Start")
    os.makedirs(IMGS_PATH, exist_ok=True)

    doc = WordClient(doc_path=TEMPLATE_PATH)
    stepik = StepikClient()

    course_id = PYTHON_COURSE_ID
    section_no = int(input("Введите номер раздела (номер лабы): "))
    doc_name = ""
    if not doc_name:
        section = stepik.get_section(stepik.get_section_id(course_id, section_no))
        doc_name = f"{section_no}-{section.title.strip().replace(' ', '-')}"

    current_no = 1
    heading_no = 1

    lessons = stepik.get_lessons(course_id, section_no)
    for lesson in lessons:
        code_solutions = get_code_solutions(lesson)
        if not code_solutions:
            logger.warning(f"No any solutions for «{lesson.title}»\n")
            continue
        doc.add_heading2(lesson.title, heading_no=heading_no)
        for solution in code_solutions:
            doc.add_solution(
                no=current_no,
                title=solution.title,
                descr="\n".join(solution.description),
                img_path=solution.img_path,
            )
            current_no += 1
        doc.add_page_break()
        heading_no += 1

    doc.save(doc_name=doc_name)
    print(f"Документ {doc_name}.docx готов.")
    print("(он находится в папке my_docs)")

В итоге, запустив скрипт, у меня удается получить заветный автоматически-сформированный отчет, на который больше не придется тратить over много времени. Я был очень впечатлен своей работой и очень-очень рад.

Одна из страниц отчета.
Одна из страниц отчета.
Заголовки отчета.
Заголовки отчета.

Заключение

Создавая отчеты от руки и каждый раз копируя и вставляя куски текста в Word, я не понимал, в чем кроется суть такой работы, чему она учит. Меня жутко раздражала вся эта монотонность и однотипность действий, а главное — смысл был непонятен. Но потом, повторяя одни и те же действия на протяжении полутора часов, я понял, что смысл этой работы заключался совсем не в том, чтобы создать отчет, а в том, чтобы оптимизировать этот процесс, сделать его автономным.

Этот проект научил меня многому: искать информацию в интернете, разбираться в сырой API-документации, подходить к задаче с разных сторон с целью выявить самое простое решение и многому другому. Было очень интересно разобрать эту проблему вдоль и поперек и в конце концов решить ее, а следовательно, сохранить себе и одногруппникам кучу времени.

Раньше я почему-то не думал, что с помощью языков программирования можно делать такие вещи. Оказалось, что можно. С момента решения этой проблемы я открыл для себя программирование с другой стороны — со стороны бесконечной фантазии и творчества, которое оно может предоставить.

Ссылка на проект: very-interesting-task

Теги:
Хабы:
+5
Комментарии10

Публикации

Работа

Data Scientist
38 вакансий

Ближайшие события