Я изучаю PowerShell по мере возникновения задач. Если надо что-то автоматизировать, я смотрю, как соотносится реализация в PowerShell с моими знаниями. И держу в уме, что мне есть к кому обратиться, если знаний не хватит :)
[+] Сегодня в программе
История вопроса и задача
Есть замечательный ресурс smartfiction.ru, публикующий по будням короткие рассказы. Для меня главная ценность в их автоматической доставке на Kindle. Работает это очень просто: на сайте даете почтовый адрес Kindle, а в настройках Amazon добавляете в разрешенные адрес рассылки, после чего книга скачивает отправленные рассказы автоматически.
Точнее – работало, потому что в какой-то момент рассказы перестали приходить. Сервис подписки на другом домене, а он недоступен, как выяснилось. Я написал письмо на адрес обратной связи, но оно осталось без ответа. Однако рассказы на сайте публикуются, и под каждым есть ссылка для загрузки в mobi.
Поэтому задача свелась к тому, чтобы автоматизировать закачку этих файлов. Первая мысль была расчехлить консольный wget, но тут же возникла ассоциация с PowerShell. Ведь wget – это псевдоним командлета Invoke-WebRequest.
Получение содержимого страницы и выбор нужных ссылок
Invoke-WebRequest умеет отправлять запросы HTTP/HTTPS/FTP, парсить ответ и возвращать наборы элементов HTML – ссылки, формы, изображения и т.д. Попробуйте любой сайт так:
Invoke-WebRequest -Uri "http://smartfiction.ru"
Для каждой ссылки легко выводится набор атрибутов.
$Site = "http://smartfiction.ru/" $HttpContent = Invoke-WebRequest -Uri $Site $HttpContent.Links innerHTML : smartfiction innerText : smartfiction outerHTML : <A title=smartfiction href="http://smartfiction.ru/" rel=home>smartfiction</A> outerText : smartfiction tagName : A title : smartfiction href : http://smartfiction.ru/ rel : home
Выше показана только первая ссылка страницы, но нужна конкретная. В Chrome щелкните правой кнопкой мыши по ссылке – «Посмотреть код элемента» и сопоставьте с выводом PowerShell. Интересующие атрибуты – это innerText (текст ссылки) и href (URL).
Передав запрос по конвейеру командлету Where-Object, можно получить список всех ссылок на книги в формате mobi (ниже показана только первая).
$HttpContent.Links | Where-Object {$_.innertext -eq "mobi"} | fl innerText, href innerText : mobi href : http://convert.smartfiction.ru/?uri=http%3A%2F%2Fsmartfiction.ru%2Fprose%2Fhot_and_cold_blood%2F&format =mobi
Загрузка файлов по ссылкам
Информации выше уже достаточно для первой версии скрипта.
$Site = "http://smartfiction.ru/" $HttpContent = Invoke-WebRequest -Uri $Site $HttpContent.Links | Where-Object {$_.innertext -eq "mobi"} | %{Invoke-WebRequest -Uri $_.href -OutFile "$(Get-Random 10001)$(".mobi")"}
Первые три строки вы уже видели, поэтому разберу четвертую. Список ссылок по конвейеру передается командлету ForEach-Object (псевдоним %). Он выполняет запрос для каждой ссылки ($_.href) и сохраняет ответ сервера в файл со случайным именем и расширением mobi. Таким образом, со страницы скачиваются все книги в формате mobi.
Случайное имя с числовым значением от 0 до 10001 генерирует командлет Get-Random. Это костыль, потому что имя файла в атрибутах ссылки не содержится. Но до него можно добраться!
Парсинг заголовков
В ответ на запрос о ссылке на книгу сервер выдает такую картину.
Invoke-WebRequest -Uri "http://convert.smartfiction.ru/?uri=http%3A%2F%2Fsmartfiction.ru%2Fprose%2Fhot_and_cold_blood%2F&format=mobi" StatusCode : 200 StatusDescription : OK Content : {76, 105, 111, 100...} RawContent : HTTP/1.1 200 OK Transfer-Encoding: chunked Connection: keep-alive Status: 200 OK content-disposition: attachment; filename="hot_and_cold_blood.mobi" content-transfer-encoding: binary x-ua-compat... Headers : {[Transfer-Encoding, chunked], [Connection, keep-alive], [Status, 200 OK], [content-disposition, at tachment; filename="hot_and_cold_blood.mobi"]...} RawContentLength : 46350
Имя файла тут есть: filename="hot_and_cold_blood.mobi". Но я сразу приуныл, потому что извлечь его можно только регулярным выражением, которые я исторически не осилил. Однако меня быстро утешил в Телеграме Вадимс Поданс :)
$r = Invoke-WebRequest -Uri "http://convert.smartfiction.ru/?uri=http%3A%2F%2Fsmartfiction.ru%2Fprose%2Fhot_and_cold_blood%2F&format=mobi" $r.Headers["content-disposition"] -match 'filename=\"(.+)\"' | Out-Null $matches[1] hot_and_cold_blood.mobi
Регулярное выражение берет из ответа заголовки (Headers) и вытаскивает имя файла из секции content-deposition.
В результате получается такой скрипт.
$Site = "http://smartfiction.ru/" $HttpContent = Invoke-WebRequest -Uri $Site $HttpContent.Links | Where-Object {$_.innertext -eq "mobi"} | %{ (Invoke-WebRequest -Uri $_.href).Headers["content-disposition"] -match 'filename=\"(.+)\"' | Out-Null Invoke-WebRequest -Uri $_.href -OutFile $matches[1] }
Экономия на запросах
Код выше вполне рабочий, но перфекциониста не устроит. Каждая ссылка на книгу запрашивается с сервера дважды: сначала для получения имени файла, затем для загрузки. Убрать лишний запрос из цикла несложно – достаточно присвоить ему переменную. Но я не мог сообразить, как это дело вывести в файл.
Вадимс подсказал командлет Set-Content. В данном случае он сохраняет содержимое ответа в файл с именем, полученным с помощью регулярного выражения.
$Site = "http://smartfiction.ru/" $HttpContent = Invoke-WebRequest -Uri $Site $HttpContent.Links | Where-Object {$_.innertext -eq "mobi"} | %{ $mobi = Invoke-WebRequest -Uri $_.href $mobi.Headers["content-disposition"] -match 'filename=\"(.+)\"' | Out-Null Set-Content -path $matches[1] -value $mobi.content -encoding byte #sleep -s 3 }
Иногда серверы блокируют слишком частые запросы с одного хоста. Поэтому в качестве последнего штриха я добавил в цикл трехсекундную паузу командлетом Start-Sleep.
Если хотите потренироваться, вот тут масса бесплатных книг Microsoft. А у вас возникают подобные задачи? Как решаете?
nett00n
Сначала хотел написать, что при помощи Bash и curl/wget получилось бы проще, потом смоделировал скрипт в голове и понял, что нет, не проще.
Vadim Sterkin
Выбирать инструмент, которым лучше владеешь — нормально, даже если в итоге не проще :) Но PowerShell очень компактно решает эту задачу, благодаря конвейеру. И, как я написал в чате, в первой версии скрипта используются только базовые возможности PS, которые используешь практически всегда: ? | % $_. $()
Lecron
Эту — несомненно. За исключением неявного формирования переменной $matches, все классно. Только выигрывая на простых задачах, слишком легко эту простоту разрушить. Очень быстро догнав и перегнав по сложности кода традиционные скриптовые языки.
Допустим надо искать не ссылки, а блоки книги. Фильтровать их по некоторым полям, например пользовательскому рейтингу. И только потом, уже в самом блоке, искать нужную ссылку. Четкого якоря, типа «mobi», для которой может не быть.
Alexey Prikhodko
Думаю, что это можно было сделать с помощью MS Flow (аналог IFTTT, но более мощный) без программирования. Но у них ограничение на количество бесплатных запусков — 75. Хотя в данном случае — этого хватит. :)
Vadim Sterkin
MS Flow умеет парсить веб-страницы?
Сергей Казнадей
Выдрать ссылки — проще некуда.
lynx -dump http://smartfiction.ru |grep 'epub$' |tail -10 |awk '{print $2}' > /home/user/urllist.txt
использовал для удобства — скрипт пробный и все ссылки мне не нужны, оставил только первые 10.|tail -10
Но при попытке скачать wget файл по ссылке получаю Internal Error 500. curl выдает ту же ошибку.
Vadim Sterkin
Лучше на другом сайте тренироваться, а то сломаем хороший сайт окончательно.
Вот тут куча бесплатных книг Microsoft.
Павел Нагаев
Вадим, я делал похожий пример, когда парсил русскоязычную версию сайта Вадимса, для истории :-) , а так же mp3 с djpromo сайта скачивал на флешку в машину.
Lecron
Публикуется не более одного рассказа в день. Если скрипт запускать ежедневно, и качать только последний, то Start-Sleep не нужен. Иначе нужно вести учет скачанного или проверять существование файла, для исключения повторной закачки. Для перфекциониста это как серпом по :).
И зачем запрашивать заголовки, если имя файла можно получить сразу из url-а? А так как читалка берет инфу из тегов, и ей имя по-барабану, то можно и суррогатным обойтись. Только не случайное число а дату.
Vadim Sterkin
Я, конечно, думал об этом, но решил не уводить скрипт в сторону, потому что этот момент все-таки специфичен для мой конкретной задачи.
Цель поста — показать приемы на конкретной задаче. В данном случае имя файла не имеет значения, конечно. Но в каком-то другом — скорее да, чем нет.
Можно парсить URL, но парсинг заголовков полезнее. Например, для задачи, где в URL — linkID=45105045.
:) Дата была в исходном варианте скрипта, но я уже разбирал Get-Date в блоге, а Get-Random — нет.
Lecron
ОК. Тогда строго по теме. Доступ не только к ссылке, но и извлечение информации из произвольного селектора (div). Итерация по блокам, например книгам «div.post» и обращение к селекторам только внутри него. Пара примеров доступа по классу, id, произвольному свойству, css query.
Это не выйдет за рамки простого примера, но и не окажется настолько примитивным. Понимаю, что есть книги, но эта информация скорее нужна не для обучения, а для оценки перспектив. Прикинуть, насколько стоит использовать штатный инструмент и когда станет пора расчехлять нечто по-мощнее.
Vadim Sterkin
Я написал на примере практической задачи, причем вполне распространенной (см. ссылку в конце статьи). Примитивно? Нет, но просто.
А для ваших хотелок у меня практических задач нет. И даже если будут, вряд ли стану писать для 2.5 человек.
Хотите оценить перспективы — попробуйте сами сделать то, что вы попросили.
Herz Mein
Такой же подход, изучаю по мере выполнения задач. Накатал скриптов, начиная от задач, связанных с музыкой — рип, конвертация, прослушивание, в основном, как скриптовый фронт-енд к lame, vorbis, упорядочивания встроенных тэгов и т.д. Для загрузки rss-лент, анекдотов, цитат и прочей погоды. Ну и конечно для выполнения повседневных задач, связанных с администрированием личного ноутбука.