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