Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Программист пишет интересную статью. Холст, масло, 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
Потренироваться можно на Алисе в стране чудес, которая лежит на том же гитхабе.