Готовим статьи для Хабра: скрипт для подтягивания адресов картинок с habrastorage

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


Программист пишет интересную статью. Холст, масло, 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


Кликабельно


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


 pip install hsto-rename

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

Источник: https://habr.com/ru/post/674982/


Интересные статьи

Интересные статьи

Привет! Меня зовут Саша Шутай, я тимлид в AGIMA. В прошлой статье я рассказывал, что делать, если на проекте Bitrix сожительствует с Vue.js и поисковые боты не видят контента сайта. А в этой помогу ра...
Привет, Хабр! Меня зовут Антон. Примерно год назад я начал работать с Serverless — и был покорён этим подходом к разработке приложений. Несмотря на определённые недостатк...
SWAP (своп) — это механизм виртуальной памяти, при котором часть данных из оперативной памяти (ОЗУ) перемещается на хранение на HDD (жёсткий диск), SSD (твёрдотельный накоп...
Мне часто говорят – эй, где технические статьи? Чё ты всякую чушь пишешь, про менеджеров, директоров, отношения с персоналом, корпоративные дрязги, ноешь про бессмысленность нашей работы и вообще...
Реализация ORM в ядре D7 — очередная интересная, перспективная, но как обычно плохо документированная разработка от 1с-Битрикс :) Призвана она абстрагировать разработчика от механики работы с табл...