История создания проекта
Вроде смотришь на название дисциплины «Алгоритмы и структуры данных», думаешь, что всё будет супер, а потом тебе говорят, что нужно будет формировать отчёт по каждому разделу курса на платформе. И ты такой: «Ну #₽@&*».
Дело в том, что задач в каждом разделе ну не сказать, что мало, а в отчёте должен быть вставлен и текст, и скриншот кода, и подпись к скриншоту, и всё это должно быть сделано по ГОСТ'у.
«Окей», — думаю я, — «как будто бы и не так сложно». Но в один день, убив около двух часов на один отчёт, пришло понимание, что нужно это исправлять. Так и появился он — Тайлер Дерден мой проект по автоматизации отчётов по АСД, или же «Отчет Creator».
Подходим к проблеме
Я решил писать решение на Python'е, потому что мне нравится лаконичность этого языка и читаемость кода на нем.
Как первый подход к проблеме, я решил задать себе следующие вопросы:
Как я буду получать текст условия задачи?
Как мне придется рендерить код решения, чтоб не сбилась верстка?
Как мне сформировать сам отчет в форме документа 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