Программист пишет интересную статью. Холст, масло, ruDALL-E.


Что самое сложное в написании статьи для Хабра? Конечно же сесть и начать писать! А потом вовремя остановиться. Ну а на третьем месте — во всяком случае для меня — стоит загрузка уже готовой статьи на Хабр. Про новый редактор я тактично промолчу, а старый в принципе весьма неплох: статью в markdown можно скопировать в него почти без изменений. Но вот с добавлением картинок есть пара нюансов.


Во-первых, форматирование: markdown не поддерживает ширину-высоту-выравнивание картинок, поэтому если вам захочется красоты, то все теги придется переписать в html. А во-вторых, когда вы зальете картинки на Habrastorage (или в любое другое облако), адреса локальных картинок по всему тексту придется вручную перебивать на ссылки в облаке. Как-то вечером я дописывал статью с ~50 картинками, ужаснулся количеству предстоящей работы, и решил написать простенький скрипт для автоматизации всего этого.


Итак, юзкейз: мы пишем статью в markdown в любимом оффлайновом редакторе и расставляем ссылки на картинки, лежащие где-то рядом на жестком диске.



После этого мы вручную загружаем пачку картинок на Habrastorage, он генерирует ссылки на них. Можно ли вытащить ссылки прямо со страницы Habrastorage? Наверное да, но так как с фронтендом я знаком на уровне



то придется пойти более простым путем. Благо Habrastorage позволяет одним махом скопировать URL всех картинок, которые можно положить в файл (назовем его cloud.txt). Они лежат там в каком-то произвольном порядке; чтобы понять, где скрывается какая картинка, нужно сравнить их с локальными копиями. Алгоритм простой:


  • вытаскиваем все теги картинок из текста статьи;
  • находим в каждом теге адрес, загружаем по нему картинку;
  • по очереди сравниваем ее с содержимым ссылок в cloud.txt;
  • совпадение? не думаю меняем локальный адрес картинки на ссылку в облаке.

В эту же логику хорошо ложится преобразование тегов из markdown в html и обратно. Если мы смогли распарсить тег и вытащить из него адрес картинки и описание, то


![изысканный жираф](images\img1.png)

легко превратить в


<img src="images\img1.png" alt="изысканный жираф">

и наоборот. Что ж, приступим.


Ищем теги


Теги в тексте статьи проше всего найти регулярками:


def find_tags(text):
        md_tags = re.findall('!\[.*\]\(.+\)',text)
        html_tags = re.findall('<img.*>',text)
        return md_tags + html_tags

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


class ImageTag():
    def __init__(self,tag):
        self.tag = tag
        self.type = ''
        self.link = None
        self.alt_text = ''
        self._parse_tag()

Адрес картинки, alt-text и остальные аргументы (в случае html) тоже распарсим регулярками.


    def _parse_tag(self):
        if re.match('!\[.*\]\(.+\)',self.tag):
            # this is a markdown tag
            self.type = 'md'
            ... # here goes the parsing

        elif re.match('<img.*>',self.tag):
            # HTML tag
            self.type = 'html'
            ... # here goes the parsing

        else:
            print(f'Tag "{self.tag}" is not recognized')

чуть подробнее
        if re.match('!\[.*\]\(.+\)',self.tag):
            # markdown tag
            self.type = 'md'
            # find the link
            prefix = re.match('!\[.*\]\(',self.tag)
            postfix = re.search('\s*"[^"]*"\s*\)',self.tag)
            if postfix:
                self.link = self.tag[prefix.end():postfix.start()].strip()
            else:
                self.link = self.tag[prefix.end():-1].strip()
            # find alt text
            self.alt_text = re.match('[^\]]*',self.tag[2:]).group(0)

        elif re.match('<img.*>',self.tag):
            # HTML tag
            self.type = 'html'
            # find the link
            s = re.search('src\s*=\s*"[^"]*"',self.tag)
            if not s:
                print(f'Cannot find "src" in "{self.tag}"')
                self.type = ''
            prefix = re.match('src\s*=\s*"',s.group(0))
            self.link = s.group(0)[prefix.end():-1].strip()
            # find alt text
            s = re.search('alt\s*=\s*"[^"]*"',self.tag)
            if s:
                prefix = re.match('alt\s*=\s*"',s.group(0))
                self.alt_text = s.group(0)[prefix.end():-1].strip()
            # find optional parameters
            s = re.search('width\s*=\s*"[^"]*"',self.tag)
            if s:
                self.width_tag = s.group(0)
            s = re.search('height\s*=\s*"[^"]*"',self.tag)
            if s:
                self.height_tag = s.group(0)
            s = re.search('align\s*=\s*"[^"]*"',self.tag)
            if s:
                self.align_tag = s.group(0)

Почему не BeautifulSoup? Во-первых, он не работает с Markdown. Во-вторых, он возвращает значение аргумента, которое нас не особо интересует: если мы захотим изменить, скажем, ширину картинки, мы можем найти весь тег width="600" и заменить его на width="400"; какая именно там была ширина, нам безразлично.


Преобразуем теги


С преобразованием в markdown все просто: если тег уже был в markdown, ничего делать не надо; если он был в html, достаточно взять адрес картинки и alt-text и создать новый тег:


    def to_markdown(self):
        if self.type == 'md':
            return self.tag

        elif self.type == 'html':
            return '![' + self.alt_text + '](' + self.link + ')'

Преобразование из markdown в html аналогично, нужно только не забыть добавить дополнительный аргумент (ширину, высоту, выравнивание), если его задал пользователь:


    def to_html(self,width=None,height=None,align=None):
        if self.type == 'md':
            new_tag = '<img src="' + self.link + '"'
            if self.alt_text:
                new_tag += f' alt="{self.alt_text}"'
            if width:
                new_tag += f' width="{width}"'
            if height:
                new_tag += f' height="{height}"'
            if align:
                new_tag += f' align="{align}"'
            new_tag += ">"
            return new_tag
        ...

Если же мы преобразовываем html в html, то нужно проверить, не был ли аргумент уже установлен, и в противном случае заменить его. Генерировать заново весь тег не будем, в нем может быть много другой информации:


        ...
        elif self.type == 'html':
            new_tag = self.tag
            if width:
                if self.width_tag:
                    new_tag = new_tag.replace(self.width_tag,f'width="{width}"')
                else:
                    new_tag = new_tag[:-1] + f' width="{width}"' + ">"
            if height:
                if self.height_tag:
                    new_tag = new_tag.replace(self.height_tag,f'height="{height}"')
                else:
                    new_tag = new_tag[:-1] + f' height="{height}"' + ">"
            if align:
                if self.align_tag:
                    new_tag = new_tag.replace(self.align_tag,f'align="{align}"')
                else:
                    new_tag = new_tag[:-1] + f' align="{align}"' + ">"
            return new_tag

С преобразованием на этом все. Осталось завернуть все это в функцию main(), которую будет вызывать пользователь:


def main(file_in,file_out,format=None,
        width=None,height=None,align=None):

    with open(file_in,'r',encoding="UTF-8") as f:
        text = f.read()
    tags = find_tags(text)
    text_tags = [ImageTag(tag,path=dir_in) for tag in tags]

    if format:
        if format == 'md':
            for tag in text_tags:
                new_tag = tag.to_markdown()
                text = text.replace(tag.tag, new_tag)

        elif format == 'html':
            for tag in text_tags:
                new_tag = tag.to_html(width=width,height=height,align=align)
                text = text.replace(tag.tag, new_tag)

    with open(file_out,'w',encoding="UTF-8") as f:
        f.write(text)

Меняем адреса картинок


Habrastorage отдает нам список ссылок на картинки в виде списка тегов markdown, html, или просто URL-адресов. Добавим в класс ImageTag() поддержку bare URL и подумаем, как лучше сравнивать картинки. На самом деле оптимизировать тут почти нечего: наибольшее время тратится на загрузку картинок с сервера, места в памяти они занимают немного, а одна картинка может встречаться в тексте много раз. Поэтому выберем самый простой путь: будем по очереди идти по картинкам из текста и искать для каждой первое совпадение на сервере:


def main(file_in,file_out,format=None,rename=None,
        width=None,height=None,align=None):
    ...

    if rename:
        # достаем ссылки на облачные картинки из файла
        with open(rename,'r') as f:
            content = f.read()
        hsto_urls = find_tags(content,bare_urls=True)
        hsto_tags = [ImageTag(url) for url in hsto_urls]

        # цикл по локальным картинкам и ссылкам в облако
        for tag in text_tags:
            for hsto_tag in hsto_tags:
                if hsto_tag == tag:
                    text = text.replace(tag.link,hsto_tag.link)
                    matched = True
                    break

Как сравивать объекты класса ImageTag()? Разумеется через сравнение картинок! Их мы будем подгружать по ссылке из тега по первому запросу:


    def __eq__(self, other: 'ImageTag') -> bool:
        return self.img == other.img

    @property
    def img(self):
        if self._img:
            return self._img
        if self._img_failed:
            return None
        self._load_img()
        return self.img

Здесь _img содержит собственно картинку, а флаг _img_failed устанавливается, если ее не удалось загрузить по указанному адресу. Так как адрес может быть и локальным адресом файла, и URL, то мы будем проверять оба (пожалуй, это не самое красивое решение):


    def _load_img(self):
        try:
            if os.path.isfile(os.path.join(self.path,self.link)):
                # загружаем как файл
                self._img = Image.open(os.path.join(self.path,self.link))
            else:
                # загружаем по внешей ссылке
                response = requests.get(self.link)
                image_bytes = io.BytesIO(response.content)
                self._img = Image.open(image_bytes)
        except:
            self._img_failed = True
            print(f'Cannot access image "{self.link}"')

Вот в принципе и все. Осталось прикрутить argparse, чтобы вызывать скрипт как модуль, передавая аргументы через командную строку.


Все вместе


Ключ -f или --format запускает преобразование в markdown или html:


python -m hsto-rename input.md output.md -f md

При преобразовании в html можно добавить аргументы --width, --height и --align:


python -m hsto-rename input.md output.md -f html --align=center


Кликабельно


Замена локальных адресов на облачные запускается ключом -r, --rename, который в качестве аргумента принимает имя файла с ссылками на облако:


python -m hsto-rename input.md output.md -r cloud.txt


Кликабельно


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


 pip install hsto-rename

Потренироваться можно на Алисе в стране чудес, которая лежит на том же гитхабе.

Комментарии (10)


  1. polearnik
    04.07.2022 18:36
    +9

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


    1. qbertych Автор
      04.07.2022 22:45
      +3

      С одной стороны — да, копировать текст с картинками из условного ворда было бы удобно.


      С другой, чем плох markdown? Да и "новому" редактору уже лет пять, и судя по многочисленным отзывам его пора не реанимировать, а закапывать.


  1. KvanTTT
    05.07.2022 02:48
    +1

    Я сделал похожий конвертер пару лет назад: MarkConv и даже написал статью про него: Статьи — это тоже исходный код {. Советую его попробовать.


    1. qbertych Автор
      05.07.2022 11:12

      Черт, я помнил о вашей статье, но не смог ее найти! Сразу утащу в закладки, почему-то не сделал этого раньше.


      А классический редактор Хабра поддерживает linkmap?


      1. KvanTTT
        05.07.2022 12:11
        +1

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


  1. devzona
    05.07.2022 03:50
    +2

    Писать пост в VSCode? Программисты интересны тем, что они часто любят использовать инструменты не по назначению. А потом пишут посты, как они героически превозмогая себя забивали гвозди микроскопом. Например, до сих пор уродливые редакторы типа vim в Linux превозносят как нечто божественное. При том, что есть нормальный редактор с псевдографикой mcedit.

    Самое простое писать пост в WordPress, затем его конвертировать под теги Хабра. Посты правлю в старой версии редактора Хабра. Откровенно говоря, не знаю, какой жопой думали программисты Хабра, делая новый редактор. Просто необходимо переключаться на html-код, и иногда править сам код. Без этого никак, в WordPress одни теги, у Хабра другие, невозможно править html без доступа к коду.

    Вот если бы html был единым стандартом как word, тогда другое дело, а пока только визуальное редактирование html это фантастика.

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


    1. qbertych Автор
      05.07.2022 11:13
      +3

      На вкус и цвет фломастеры разные.
      Под запрос "оффлайновый редактор markdown, желательно с предпросмотром" попадает много чего, в том числе vscode.


      1. devzona
        05.07.2022 17:04

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

        Можно предложить голосовалку на редактор типа WYSIWYG с переключением на html-код или Markdown-код, кому как нравится. Технически это сделать несложно, в старом редакторе WordPress уже реализовано, только необходимо добавить Markdown-код.

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


        1. KvanTTT
          05.07.2022 17:36
          +2

          Писать пост в WordPress и потом его прогонять через программу на .net, которая перебивает теги на хабровские, еще тот костыль.

          Это ответ автору или мне? Просто у автора программа на Python. У меня на .net, но при желании можно настроить конвертацию в облаке, что я и сделал. А так-то да, костыль, но это лучше, чем пользоваться хабровским редактором.


          Можно предложить голосовалку на редактор типа WYSIWYG с переключением на html-код или Markdown-код, кому как нравится.

          Я, кстати, проводил подобные голосования. Большинство пишут Markdown, используют VSCode и хранят статьи на диске.


          Технически это сделать несложно, в старом редакторе WordPress уже реализовано, только необходимо добавить Markdown-код.

          Если любой Markdown в браузере конвертируется в HTML, то обратно уже просто не получится. Да и не думаю, что нужно — возможностей Markdown хватает для больинства статей, а недостающее покрывается HTML вставками. Статьи в Markdown проще редактировать и читать. Можно сделать по типу Typora — там есть и WYSIWIG и Markdown.


    1. KvanTTT
      05.07.2022 12:16
      +2

      Писать пост в VSCode? Программисты интересны тем, что они часто любят использовать инструменты не по назначению. А потом пишут посты, как они героически превозмогая себя забивали гвозди микроскопом.

      Героически превозмогать себя — это пользоваться хабровским редактором, особенно новым. А VSCode более чем подходит для создания и редактирования Markdown кода.


      Самое простое писать пост в WordPress, затем его конвертировать под теги Хабра. Посты правлю в старой версии редактора Хабра.

      Ну кому как, если это визуальное редактирование, то не самое простое. А если текст или html, то VSCode с Markdown попроще будет.