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

Собираем автоматический конспект из материалов курса на платформе Эквио

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

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

Все началось с того, что в компании, где я сейчас работаю, была запущена программа обучения для руководителей на платформе Эквио.

Википедия говорит нам, что «Эквио — мобильная платформа, единое цифровое пространство для обучения, управления и мотивации персонала. Позволяет создавать обучающие курсы, проводить онлайн-презентации, организовывать обучение сотрудников.»

Программа представляет из себя набор модулей, состоящих из видео, статей, тестов и практических заданий. В общем, добротный курс с полезными материалами. И случается, что порой хочется вернуться к какому-то материалу и перечитать его или найти нужный отрывок, который «кажется, где-то там был...». И все бы ничего, но находить нужный контент на платформе совсем непросто.

Сначала мне как пользователю доступен верхний уровень, который говорит, что у меня есть модуль, который включает в себя N программ обучения, внутри которых есть секции (которые изначально все свернуты), и только развернув каждую, я могу посмотреть, какие там материалы были и уже далее открыть контент конкретного материала. Поиска по содержимому тоже не завезли.

My momma always said, «Section was like a box of chocolates. You never know what you're gonna get».
My momma always said, «Section was like a box of chocolates. You never know what you're gonna get».

К тому же материалы курса доступны только на самой платформе и чем-то полезным непросто поделиться с коллегой, который курс не проходил.

И для сравнения мне в голову пришел другой пример. Однажды довелось мне проходить курс на Coursera, так вот там у каждого модуля, кроме видео-обучалок и прочего давали в дополнительных материалах скачать информативную PDF-ку, где уже текстом было описано содержимое модуля. Эдакие методички, как в университете были, может этим меня формат и подкупает. Так к этим файлам я неоднократно возвращался, и зачастую это было удобно и полезно.

Пример первого разворота одного из тех самых файлов
Пример первого разворота одного из тех самых файлов

Так, обуздав свои порочные желания, попробуем понять, чего же мы хотим:

  • Быстро находить нужное место в материалах по содержанию или по ключевым словам, если мы их запомнили

  • Иметь у себя материалы-методички, чтобы не лазить каждый раз на сайт и не зависеть от доступности курса на платформе или самой платформы

  • Делиться нужным разделом с коллегой, которого курс обошел стороной

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

Идем на разведку

Подумано - сделано. Сначала нужно было понять, как мы сами эти материалы сможем получить. Скрестив пальцы и понадеясь, что мне не придется все же заниматься веб-скраппингом, я вооружился старым-добрым Charles и пошел смотреть, а как у Эквио вообще ходят данные.

Сперва самое интересное: как отдается на фронт текст материалов?

Обратим внимание, что материалы у нас бывают разные. И для наших целей нас интересуют сейчас только "лонгриды".

Изначально ожидал, что в лучшем случае текстовый контент я найду в JSON виде строки с куском HTML внутри или просто в виде строк с сырым текстом, однако результат порадовал еще больше: с бэкэнда отдается статья в markdown-разметке.

{
	"success": {
		"page": {
			"uuid": "fc3f7d5a-254f-4bbb-a999-5b8da9ca9a17",
			"longread_id": 12345,
			"order": 1,
			"updated_at": 1635758515,
			"title": "Введение в блок",
			"body": "%текст статьи, написанный на markdown%",
			"images": []
		}
	}
}

Такой поворот событий воодушевляет: значит, получить контент без приключений мы все же сможем, к тому же уже с готовым оформлением (приятный бонус). Markdown мы легко сможем сконвертировать в HTML, который затем уже переведем в PDF.

Однако, чтобы получить такой ответ, нам необходимо в запросе отправить параметры "longread" и "page", о которых мы пока не имеем представления. Поэтому разберемся с остальными запросами и раскрутим цепочку до конца.

Пройдемся по порядку и изобразим схему, как клиент (браузер) взаимодействует с серверной частью.

Далее для интересующихся описан более подробно разбор взаимодействия.

Сначала мы открываем список всем программ обучения и уходит запрос GET /v40/learning-programs?page=1&module_id={module_id}

Сервер нам отвечает массивом с "learning_programs". У каждой программы есть название, массив "sections", а у каждой секции есть массив "materials".

// ответ сокращен для компактности, часть параметров исключена
{
    "success": {
        "learning_programs": [
            {
                "id": 12345,
                "name": "1.1 Вводное занятие",
                "order": 38,
                "sections": [
                    {
                        "id": 12345,
                        "name": "Введение в блок",
                        "order": 50861,
                        "materials": [
                            {
                                "id": 12345,
                                "name": "1.1 Введение в блок",
                                "is_required": 1,
                                "order": 1
                            }
                        ]
                    }
                ]
            }
        ],
        "meta": {
            "pagination": {
                "current_page": 1,
                "pages_count": 1,
                "per_page": 20,
                "total_count": 14
            }
        }
    }
}

Далее отправляется запрос POST /v40/materials-cr?page=1, в теле мы передаем массив "materials", содержащий id материалов, массив сформирован из материалов одной секции.

{
	"materials": [12345, 12345, 12345, 12345]
}

В ответе получаем детальную информацию по каждому из переданных materials, из полезного здесь мы можем получить тип каждого материала, который нам пригодится далее.

{
    "success": {
        "materials": [
            {
                "id": 12345,
                "name": "1.1 Введение в блок",
                "image": {
                    // параметры исключены для компактности тела ответа
                },
                "type": "longread", // тот самый тип, который нам интересен
                "is_rating": 0,
                "updated_at": 1637272596,
                "allow_skip_pages": 0
            }
        ],
        "meta": {
            "pagination": {
                "current_page": 1,
                "pages_count": 1,
                "per_page": 20,
                "total_count": 15
            }
        }
    }
}

Затем для лонгрида отправляется POST /v40/materials/longreads/pages/titles?page=1. В теле содержится массив из одного элемента с id материала-лонгрида.

{
	"longreads": [12345]
}

Мы с вами внимательные и здесь замечаем, что, поскольку мы открываем конкретный материал, то в запросе передается всего один элемент массива, однако в предыдущем запросе мы видели такой же принцип и можем предположить, что запрашивать лонгриды можно не по одному, а сразу пачкой. К проверке этого предположения вернемся далее.

Ответ здесь сообщает UUID страницы лонгрида, который также нужен, чтобы получить искомый текст.

{
    "success": {
        "page_titles": [
            {
                "uuid": "ccbcd472-9c45-45df-862b-0358c04901f0",
                "longread_id": 12345,
                "order": 1,
                "updated_at": 1635758515,
                "title": "Введение в блок"
            }
        ],
        "meta": {
            "pagination": {
                "current_page": 1,
                "pages_count": 1,
                "per_page": 300,
                "total_count": 1
            }
        }
    }
}

И после этого уже идет запрос POST /v40/materials/longreads/page. В теле передается id лонгрида и UUID его страницы.

{
    "longread": 12345,
    "page": "ccbcd472-9c45-45df-862b-0358c04901f0"
}

В ответе и получаем нужный нам текст. Дело раскрыто, пора переходить к следующему шагу.

Реализация

Выделим основные шаги, которые намечаются к разработке:

План-капкан
План-капкан

В качестве ЯП выбрал Python, просто потому что интересно наработать практику на нем, объем данных в моем случае небольшой, записывать полученные сырые данные никуда не будем, чтобы не усложнять реализацию, а будем держать их в памяти.

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

Для улучшения процесса проверили догадку, которая появилась ранее: передали несколько id в массиве "longreads" и получили UUID сразу по всем. Это позволило не отправлять запрос для получения данных по каждому лонгриду, как это делает клиентская часть, а получать данные сразу для нужного количества.

Начнем с аутентификации, на ней было принято волевое решение сэкономить и не писать ее вовсе. Повлиял на это также нюанс, что платформа допускает только одну активную сессию, и при новой завершает прочие сеансы.

Опять смотрим запросы к серверу и видим, что используется Bearer-токен в заголовке Authorization, значит будем использовать его. Сформируем общие заголовки для всех запросов и, чтобы наши запросы были больше похожи на настоящие и не светили в логах каким-нибудь "python-requests/2.26.0", добавим еще случайный User agent.

Далее нам нужно получить данные программ обучения. Используя схему, описанную выше, получим структуру каждой программы и сложим данные в словарь вида

{
    1234: {  # ключ-id программы обучения
        "name": "2.1 Эмоциональное лидерство",
        "order": 39,
        "sections": [
            {
                "id": 12345,
                "materials": [
                    {"id": 12345, "name": "1.1 Введение в тему модуля", "order": 1},
                    {
                        "id": 12345,
                        "name": "1.2 Введение в тему занятия "
                        "“Эмоциональное лидерство”",
                        "order": 2,
                    },
                ],
                "name": "Лидерство — это ответственность",
                "order": 52013,
            },
        ],
    },
}

Кроме названий и id, полезным оказалось использование order из тела ответа, на основе этого ключа сделаем далее сортировку, чтобы материалы были в том же порядке, что и на платформе.

Затем для каждой программы определим типы материалов, для материалов с типом "лонгрид" получим UUID страниц и сложим это в массив, по которому далее будем получать тексты

[
    {"id": 12345, "uuid": "0542f71f-83c4-4572-b37d-74d948c8c03a"},
    {"id": 12346, "uuid": "1a7bc1bd-b7e9-487d-968c-87a4f2dfe86c"},
]

И полученные тексты соберем их в отдельном массиве со структурой

[
    {"id": 12345, "content": "%markdown-текст%"},
    {"id": 12346, "content": "%markdown-текст%"},
]

Далее отфильтруем материалы в общем словаре с программами обучения на основе массива с лонгридами, чтобы оставить только материалы-лонгриды.

Теперь сделаем общие markdown-файлы с контентом. Программы обучения с платформы являются верхним уровнем в структуре (не включая сам курс), поэтому будем делать файл для каждой программы обучения. Тексты отдельных лонгридов у нас уже есть, теперь их нужно собрать вместе. Для этого просто сконкатенируем их, добавляя перед каждым лонгридом его название в виде markdown-заголовка 1 уровня.

program_content_md += f'# {heading}\n\n\n{text}\n'

Однако, поскольку в статьях уже были свои заголовки, я столкнулся с тем, что на 1 уровне были и добавляемые заголовки лонгридов, и уже имеющиеся заголовки статей. Помимо неудобств в оформлении, это помешает нам дальше при формировании оглавления. Соответственно появилась необходимость "сдвинуть" все исходные заголовки на уровень вниз, чтобы на 1 уровне оставались только те, которые мы добавляем сами. Поискав существующие наработки на эту тему, я нашел только расширение для VS Code, в исходниках которого ничего элегантного тоже не было. Поэтому я реализовал свой небольшой велосипед для смещения заголовков:

def shift_headings(md, level=1):
    shift = '#'*level
    replaced = re.sub(r'^(#+)', r'\1'+shift, md, flags=re.MULTILINE)
    return replaced

Так получаем markdown-файл:

Дальше нужно сделать оглавление и сконвертировать markdown в HTML. К этому моменту у меня были в голове мысли, как я смогу сделать оглавление: я хотел использовать якорные ссылки markdown на заголовки, однако предвидел проблемы с тем, как это будет переводиться в HTML. Но дело обернулось еще лучше.

Для конвертации я решил использовать популярную библиотеку markdown и в процессе изучения документации обнаружил, что в ней уже реализована возможность сбора оглавления на основе заголовков из markdown-документа. Для этого в ней есть расширение TOC.

Для использования в самом документе необходимо разместить строку-маркер (по умолчанию "[TOC]") в том месте, куда необходимо вставить оглавление, и указать соответствующий аргумент при конвертации. Оглавление будем строить по заголовкам только первого уровня, здесь как раз и пригодилось смещение исходных заголовков на уровень вниз.

html = markdown.markdown(program_content_md, extensions=[
    TocExtension(title='Оглавление', toc_depth='1')])

Получаем наш конспект уже в формате HTML, а также имеем кликабельное оглавление:

Теперь нужно создать PDF-файл. Для конвертации HTML в PDF использовал библиотеку pdfkit, она работает с утилитой wkhtmltopdf.

Причешем внешний вид выходного файла, используя аргумент options:

options = {
    'encoding': 'UTF-8',
    'footer-right': '[page]/[topage]', # номера страниц в футере
    'footer-font-size': '10',
    'footer-center': programs[program_id]["name"], # название программы обучения в футере
    'footer-spacing': '5', # отступ от текста для футера
    'footer-font-name': 'Roboto',
    'margin-top': '16mm',
    'margin-bottom': '20mm',
    'margin-right': '20mm',
    'margin-left': '20mm',
    'user-style-sheet': 'pdf.css', # css-файл, который будет использован при конвертации
    'disable-smart-shrinking': None,  # необходимо, чтобы вручную задать постоянное отношение px/dpi через zoom
    'zoom': 0.6112  # вычислил опытым путем для документов, чтобы масштаб везде был одинаковый и подходящий
}
Path("output/pdf").mkdir(parents=True, exist_ok=True)
pdfkit.from_file(
    f'output/html/{file_name}.htm', f'output/pdf/{file_name}.pdf', options=options)

Здесь столкнулся с загвоздкой в виде того, что у wkhtmltopdf по умолчанию включена "автоподстройка" контента под страницу, что с одной стороны дает из коробки правильный масштаб, но с другой на некоторых файлах может привести к неконтролируемому его изменению. Поэтому пришлось поиграться с параметрами и сделать костыль в виде отключения этой опции и вычисления значения для zoom, чтобы результат по крайней мере был предсказуемым.

И поиграемся со шрифтами добавим немного оформления в CSS (размеры текста указаны с учетом выставленного ранее масштаба):

.toc {
     page-break-after: always !important;
}
 * {
     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
 body :not(h1):not(h2):not(h3):not(h4):not(h5):not(h6) {
     font-size: 24px;
}
 h1 {
     font-size: 48px;
     page-break-before: always !important;
}
 h2 {
     font-size: 36px;
}
 h3 {
     font-size: 30px;
}
 h4, h5, h6 {
     font-size: 24px;
}
 .toctitle {
     font-size: 48px;
     font-weight: bold;
}

Получаем на выходе вот такую красоту:

И так создаем конспекты для всех программ обучения

Время выполнения на 18 программах обучения - в среднем 150-170 секунд из-за генерации PDF. Небыстро, но и задачи оптимизации и ускорения мы перед собой не ставили.

Курс еще в процессе и программы обучения доступны на платформе не все, но при добавлении или изменении материалов теперь можно быстро получить актуальную версию конспекта.

Исходники на Github: https://github.com/anador/e-queo-pdf

upd. 31.08.2022 - вернул статью из черновиков по согласованию с представителями Эквио.

Теги:
Хабы:
0
Комментарии0

Публикации

Изменить настройки темы

Истории

Работа

Data Scientist
62 вакансии
Python разработчик
133 вакансии

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

PG Bootcamp 2024
Дата16 апреля
Время09:30 – 21:00
Место
МинскОнлайн
EvaConf 2024
Дата16 апреля
Время11:00 – 16:00
Место
МоскваОнлайн
Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн