Предисловие
Прежде всего хочу отметить, я не программист. Я админ, пока. Хотелось бы конечно зваться архитектором, но в обозримом пространстве подходящих вакансий, с адекватными требованиями, а главное, зарплатами за эти требования нет. А жаль.
Собственно говоря, в рамках этой заметки хочу рассказать о полезных плюшках новой версии Powershell. В частности, о возможности быстро и уверенно парсить веб-странички и делать это «параллельно».
Задача
Итак, задача, которая стояла передо мной была довольно простой. Есть некий сайт, если пройти через начальную форму, на которой нужно выбрать начальную и конечную дату попадаем вот на такую страничку:
Количество таких страниц может быть большим в рамках одного периода дат. Но не больше 999. То есть, если к примеру, нужно выбрать данные за 5 лет, то они все в 999 страниц не влезут. Эта страница – каталог, меня интересовали только данные на которые она ведет по ссылке в колонке Permit NO:
В общем, поскольку я не программист, моих знаний не хватало чтобы воспользоваться возможностями C# или чего-то такого. В общем, мой любимый инструмент – powershell помог и тут.
Решение
Я решил пойти в два этапа. Сначала выгрузить и разобрать каталог со ссылками, а затем пройтись по каталогу выбрать документы, на которые он ссылается. Примитивная задачка для программиста. У меня это заняло в районе 16 часов. Правда с учетом того что я делал это используя новые для меня команды не только с целью решить задачу, но и с целью изучить новые для меня команды и фишки powershell 3, который в тот момент только-что вышел.
Мне повезло, что сайт принимал параметры прямо в строке URL, вот так:
http://[skip]/[skip]?allcount=$allcount&allstartdate_month=$allstartdate_month [skip]
потому как работать с HTML формами я не умею. Потому я решил просто запрашивать нужные странички, меняя параметры запроса. Для этого я использовал командлет Invoke-WebRequest. Он позволяет в простейшем виде отправить запрос и получить результат без использования .NET классов напрямую или COM объектов IE. В результате получается разобранный HTML документ, который можно разбирать дальше.
Кроме этого, особенностью данной страницы явилось то, что она возвращалась не только с HTML кодом таблицы, но и с вот таким разобранным содержимым этой самой таблицы
Парсинг первой половины
В этой части я выбирал просто каталог. Основная проблема на этом этапе, перебрать все страницы, которые вернулись системой и определить последнюю. Для этого я решил проверять есть ли кнопка Next на странице, или ее нет.
Кроме того, на выходе этой части я хотел получить плоский csv файл, содержащий собственно каталог. И в конце передать этот файл на следующий этап. Для этого родился код ниже. Он просто выбирает все корневые таблички для диапазона дат, разбирает содержимое странички регулярными выражениями, используя указанную выше особенность и возвращает объект, который содержит всю указанную информацию.
function Get-AppList {
[CmdletBinding()]
param(
[datetime] $startDate = '01.01.2012',
[datetime] $endDate = '01.01.2012',
[string] $allpermittype = "SG",
[string] $allcount = "0000",
[string] $requestid= "1"
)
begin{
[string] $allstartdate_month = "{0:d2}" -f $startDate.Month
[string] $allstartdate_day= "{0:d2}" -f $startDate.Day
[string] $allstartdate_year= $startDate.Year
[string] $allenddate_month = "{0:d2}" -f $endDate.Month
[string] $allenddate_day = "{0:d2}" -f $endDate.Day
[string] $allenddate_year = $endDate.Year
$fields = @{Regex="[0:PtAppFirstName]{(?<PtAppFirstName>.+)}";Column="PtAppFirstName"},
@{Regex="[1:PtAppLastName]{(?<PtAppLastName>.+)}";Column="PtAppLastName"},
@{Regex="[2:PtAppMI]{(?<PtAppMI>.+)}";Column="PtAppMI"},
@{Regex="[3:PtJobNum]{(?<PtJobNum>.+)}";Column="PtJobNum"},
@{Regex="[4:PtJobDocNum]{(?<PtJobDocNum>.+)}";Column="PtJobDocNum"},
@{Regex="[5:PtJobType]{(?<PtJobType>.+)}";Column="PtJobType"},
@{Regex="[6:PtPermitType]{(?<PtPermitType>.+)}";Column="PtPermitType"},
@{Regex="[7:PtPermitSubtype]{(?<PtPermitSubtype>.+)}";Column="PtPermitSubtype"},
@{Regex="[8:PtPermitSeqNum]{(?<PtPermitSeqNum>.+)}";Column="PtPermitSeqNum"},
@{Regex="[9:PtIssuanceDate]{(?<PtIssuanceDate>.+)}";Column="PtIssuanceDate"},
@{Regex="[10:PtFilingDate]{(?<PtFilingDate>.+)}";Column="PtFilingDate"},
@{Regex="[11:PtExpirationDate]{(?<PtExpirationDate>.+)}";Column="PtExpirationDate"},
@{Regex="[12:PtBin]{(?<PtBin>.+)}";Column="PtBin"},
@{Regex="[13:JHouseNumber]{(?<JHouseNumber>.+)}";Column="JHouseNumber"},
@{Regex="[14:JStreetName]{(?<JStreetName>.+)}";Column="JStreetName"},
@{Regex="[15:PermitIsn]{(?<PermitIsn>.+)}";Column="PermitIsn"}
$uri = "http://[skip]/bisweb/[skip]?allcount=$allcount&allstartdate_month=$allstartdate_month&allstartdate_day=$allstartdate_day&allstartdate_year=$allstartdate_year&allenddate_month=$allenddate_month&allenddate_day=$allenddate_day&allenddate_year=$allenddate_year&allpermittype=$allpermittype&go13=+GO+&requestid=0&navflag=T&requestid=$requestid"
}
process{
do {
# выбираем очередную страницу. сохраняем сессию
$a = Invoke-WebRequest -Uri $uri -SessionVariable sv
$s = $a.ParsedHtml.childNodes| % data
$s2 = ($s[3] -split "[d+]")
$obj = @{}
$s2 | % {
$item = $_
if ($item) {
$fields | % {
$res = $item -match $_.regex
if ($res) {
$obj[$_.Column] = $matches[$_.Column]
}
else {
$obj[$_.Column]= $null
}
}
if (($obj.PtPermitType -ne $null) -and ($obj.PtPermitType -ne " ")) {
new-object psobject -Property $obj
}
}
}
# проверка, последняя ли это страница.специфично только для этого сайта
$form = $a.Forms | where id -EQ "frmnext"
if ($form) {
$allstartdate_month=$form.Fields["allstartdate_month"]
$allstartdate_day=$form.Fields["allstartdate_day"]
$allstartdate_year=$form.Fields["allstartdate_year"]
$allenddate_month = $form.Fields["allenddate_month"]
$allenddate_day = $form.Fields["allenddate_day"]
$allenddate_year = $form.Fields["allenddate_year"]
$allpermittype = $form.Fields["allpermittype"]
$allcount = $form.Fields["allcount"]
$requestid = $form.Fields["requestid"]
$uri = "http://[skip]/skip?allcount=$allcount&allstartdate_month=$allstartdate_month&allstartdate_day=$allstartdate_day&allstartdate_year=$allstartdate_year&allenddate_month=$allenddate_month&allenddate_day=$allenddate_day&allenddate_year=$allenddate_year&allpermittype=$allpermittype&go13=+GO+&requestid=0&navflag=T&requestid=$requestid"
}
} while ($form)
}
}
Парсинг второй половины
Во второй части возникла еще одна проблема. Количество страничек, которые нужно было запросить становилось малость больше. Раз эдак в 30. Потому, перебор результатов первого этапа и выбор страничек по одной занимал много времени. Потому я решил воспользоваться еще одной фишкой powershell v3 – powershell workflow. Ну верней сказать оператором foreach –parallel. На самом деле workflow предназначены для совсем другого, но в данном случае сошло и так. Сразу скажу, это не средство для распараллеливания задач с целью увеличения производительности, потому не стоит ожидать от него этого. Так вот, в данном случае идея была в том, чтобы воспользоваться этой возможностью, чтобы запускать запросы для каждой строчки каталога «параллельно». На самом деле эта команда запускает отдельный процесс, и их количество ограничено. Я не задавался вопросом можно ли изменить их максимальное количество. Этот механизм позволяет просто упростить код для получения «параллелизма». В кавычках не потому что они не параллельны. Они параллельны, просто запускаются не в легких потоках а в тяжелых процессах в рамках .NET Workflow и результаты передавать вынуждены через границы процессов. Поэтому это не слишком производительно, но зато, «как говорит наш любимы шеф, дешево удобно и практично», а самое главное для админа всего 2 строки кода. Потеря нескольких секунд в на отдельную задачу не играет роли относительно задачи в целом. В общем, годная штука.
Код вышел вот таким.
workflow Get-AppDetails2 ($list) {
$webList = @()
foreach -parallel ($i in $list){
$PermitIsn = $i.PermitIsn
$queryUri = "http://[skip]/bisweb/[skip]?allisn=$PermitIsn&allbin=&requestid=1"
Invoke-WebRequest -Uri $queryUri
}
}
Выводы
В общем и целом, все это доказывает, что powershell мощная и полезная штука, годная для всяких важных и полезных дел.
Автор: eosfor