Страницы

суббота, 3 мая 2014 г.

Загрузка таблиц Web-страниц

Сегодня я хочу продолжить рассказ о загрузке данных в таблицы SQL Server-а из разнообразных источников. На этот раз речь пойдет о данных, которые размещаются на Web-страницах в интернете. Предположим, есть ссылка, пройдя по которой можно просмотреть содержимое Web-страницы, включая набор таблиц с какими-либо данными. Мы напишем хранимую процедуру, которая получает в качестве параметра адрес сайта и возвращает несколько табличных наборов записей, каждый из которых соответствует таблице на сайте.

Для импорта таблиц со страницы сайта процедура будет получать html-код страницы, который затем будет интерпретироваться. Под таблицей будет пониматься html-таблица, то есть часть html-кода страницы, которая заключена в тэги <TABLE>.
 
Метод позволит, например, создавать информационные системы, которые смогут в режиме реального времени отслеживать различную информацию. Например, динамику курсов валют различных банков, информацию о праздниках в различных регионах, прогнозы погоды и многое другое.

Для начала напишем хранимую процедуру, которая получает в качестве параметра адрес сайта и возвращает html-код соответствующей Web-страницы. Сначала я хотел воспользоваться хранимыми процедурами sp_OACreate, sp_OAMethod,  и т. д. То есть с помощью sp_OACreate создавать объект InternetExplorer.Application. А затем применять метод InnerHtml для получения html-кода страницы. Однако я "нарвался" на один сюрприз. Когда html-код занимает не больше 8 тыс. символов, то можно вызвать процедуру sp_OAGetProperty с выходным параметром, который и даст html-код. Если код занимает больше 8 тыс. символов, то методика не работает как описано в моей предыдущей статье об импорте таблиц из Word-файлов. Там же рассказывается о том, что для получения длинных строк можно вызвать sp_OAMethod/sp_OAGetProperty, указав null вместо выходного параметра, и поймать возвращаемое значение с помощью конструкции insert into ... exec sp_OAMethod/spOAGetProperty. И в данном случае это срабатывало, пока на моем новом компьютере стояла старая версия Internet Explorer-а (кажется 6-ая). После установки последней версии браузера, метод с вызовом кода insert into ... exec sp_OAGetProperty ... перестал работать. Процедура sp_OAGetErrorInfo показала, что при вызове sp_OAGetProperty с null вместо выходного параметра происходит ошибка с описанием: Traversal string:  An object return value was required but not supplied. Источник ошибки: ODSOLE Extended Procedure. Поэтому вместо использования устаревшей технологии в данном случае необходимо воспользоваться clr-сборкой. Мы создадим скалярную функцию на базе clr-сборки, которая решает задачу загрузки html-кода Web-страницы. Код сборки такой:
using System;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;
using System.IO;
using System.Net;
public partial class HtmlCodeOperations
{
    [Microsoft.SqlServer.Server.SqlFunction]

        public static SqlString GetHtmlCodeOfWebPage(SqlString url)
    {
        HttpWebRequest myRequest = (HttpWebRequest)WebRequest.Create(url.ToString());

        myRequest.Method = "GET";
        WebResponse myResponse = myRequest.GetResponse();
        StreamReader srUTF8 = new StreamReader(myResponse.GetResponseStream(), System.Text.Encoding.UTF8);
        string resultUTF8 = srUTF8.ReadToEnd();
        srUTF8.Close();

        myResponse.Close();
        string result = "";
        int[] utf8 = new int[4];
        utf8[0] = resultUTF8.IndexOf(@"charset=utf-8");
        utf8[1] = resultUTF8.IndexOf(@"charset=UTF-8");
        utf8[2] = resultUTF8.IndexOf(@"charset=""utf-8""");
        utf8[3] = resultUTF8.IndexOf(@"charset=""UTF-8""");

        int[] win = new int[3];
        win[0] = resultUTF8.IndexOf(@"windows-1251");
        win[1] = resultUTF8.IndexOf(@"Windows-1251");
        win[2] = resultUTF8.IndexOf(@"WINDOWS-1251");

        int utfMin = -1, winMin = -1, i = 0;

        for (i = 0; i < 4; i++)
        {

            if (i == 0)
            {
                utfMin = utf8[i];
            }
            else
            {
                if (utfMin < 0 && utf8[i] >= 0)
                {
                    utfMin = utf8[i];
                }
                if (utfMin > utf8[i] && utf8[i] >= 0)
                {
                    utfMin = utf8[i];
                }
            }
        }
        for (i = 0; i < 3; i++)
        {
            if (i == 0)
            {
                winMin = win[i];
            }
            else
            {
                if (winMin < 0 && win[i] >= 0)
                {
                    winMin = win[i];
                }
                if (winMin > win[i] && win[i] >= 0)
                {
                    winMin = win[i];
                }
            }
        }
        if ((utfMin < winMin && utfMin >= 0) || winMin < 0)
        {
            result = resultUTF8;
            result = win[0].ToString() + " test " + utfMin.ToString() + " " + winMin.ToString() + " " + resultUTF8;
        }
        else
        {
            myRequest = (HttpWebRequest)WebRequest.Create(url.ToString());
            myRequest.Method = "GET";
            myResponse = myRequest.GetResponse();
            StreamReader srWin1251 = new StreamReader(myResponse.GetResponseStream(),

                    System.Text.Encoding.GetEncoding("Windows-1251"));

            string resultWin1251 = srWin1251.ReadToEnd();
            srWin1251.Close();
            result = utfMin.ToString() + " " + winMin.ToString() + " " + resultWin1251;
        }
        return new SqlString(result);
    }
}

В коде выше html-код считывается с указанием определенной кодировки. Коды html, которые я встречал, используют либо кодировку UTF8, либо Windows-1251. Только на них мы и заложимся. В коде сборки используется метод IndexOf для того, чтобы найти первое вхождение подстроки "charset=UTF-8". При этом учитываются регистры символов. Также находится первое вхождение подстроки "windows-1251". Если подстрока с "UTF8" встречается раньше чем с "windows-1251", или "windows-1251" отсутствует, то возвращается код страницы в кодировке UTF8. В противном случае код страницы считывается повторно с указанием кодировки windows-1251. Если прочесть код в неверной кодировке, то не удастся получить в нормальном виде значения элементов, которые написаны с использованием кирилицы. Если вы найдете html-коды, в которых используется какая-то иная кодировка, то можно соответствующим образом подправить код сборки.


Теперь необходимо развернуть сборку на сервере. Поскольку сборка обращается к данным Internet, она должна быть помечена как unsafe. В то же время я не сторонник включения свойства trustworthy базы данных ради работы небезопасных сборок. Поэтому предварительно создадим асимметричный ключ. Введем в командной строке инструкцию, которая сгенерирует асимметричный ключ с помощью программы sn.exe:
"C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\bin\sn.exe"-k"C:\Документы\Временные\tmp\HtmlWorkClr.snk"
Теперь подпишем сборку созданным ключом. Его нужно выбрать в свойствах проекта Visual Studio в разделе подписи:















Внедрим ключ на сервер:
use master
create asymmetric key HtmlWork
    from file = N'C:\Документы\Временные\tmp\HtmlWorkClr.snk'
    encryption by password = N'привет всем!!!'

Создаем логин на основе созданного ключа:
create login HtmlWorkLog from asymmetric key HtmlWork

Предоставляем логину доступ на запуск небезопасной сборки:
grant unsafe assembly to HtmlWorkLog

Теперь, наконец создадим сборку:
create assembly HtmlWrk
    from N'C:\Документы\Временные\tmp\Database1\Database1\bin\Debug\Database1.dll'
    with permission_set=unsafe

Такой способ развертывания сборки более безопасный. Можно было бы действовать с другой стороны. Создать в базе данных master сертификат и логин на его основе. Предоставить логину доступ на небезопасные сборки. Затем создать резервную копию сертификата, восстановить его в базе данных, создать на основе восстановленного сертификата пользователя. После чего подписать сборку созданным сертификатом, используя инструкцию add signature.
Код ниже создает функцию на основе созданной сборки, которая возвращает html-код созданной страницы:
create function dbo.HtmlCodeGet ( @url nvarchar ( 200 ) )
     returns nvarchar ( max )
     as external name HtmlWrk.HtmlCodeOperations.GetHtmlCodeOfWebPage

После того как решена задача получения кода html-страницы, необходимо научиться интерпретировать ее содержимое. Основная идея состоит в том, что табличные данные сайтов зачастую сосредоточены внутри html-таблиц, то есть, заключены внутри открывающего и закрывающего тэгов TABLE. Только такие данные и будут интерпретированы в нашей задаче. Такие html-таблицы напоминают xml-документы, в которых тэг TABLE является корневым.

Предположим, что код html-таблицы уже отредактирован, так чтобы соответствовать синтаксису xml. Тогда для извлечения данных мы воспользуемся функцией openxml и тем фактом, что тэги TR и TD используются для создания строк и столбцов таблицы. Это дает возможность построить выражение для поиска значений ячеек. Для изучения структуры xml-документа используется функция openxml. Функция openxml вызывается без параметров. Связано это с тем, что в одной ячейке таблицы может находиться не просто одно значение. Там может быть, например, целый список или внутри строки значения могут располагаться дополнительные тэги, которые обеспечивают нужное форматирование документа (размер шрифтов и прочее). Вывод функции openxml используется для нахождения числа строк и столбцов. Данные о числе столбцов используются для построения динамического sql-выражения, которое строит временную таблицу, которая будет содержать данные. Как обычно в таких случаях временная таблица является глобальной в связи с использованием динамического sql.

Когда таблица построена, начинается цикл по строкам html-таблицы. На каждой его итерации идет цикл по столбцам. На каждой итерации внутреннего цикла во временную таблицу кладется значение очередной ячейки. Оно получается применением метода value типа данных xml.

В данном случае мы не анализируем содержимое ячейки на предмет наличия в ней иных тэгов. Поэтому если, скажем, в ячейке есть список, то его содержимое будет конкатенироваться и в таком виде загружаться в таблицу. Можно было бы считывать значение ячейки с помощью метода query типа данных xml, получая содержимое ячейки в формате xml. К нему можно было бы снова применять функцию openxml без параметров для определения тэгов, находящихся внутри ячейки и определения значений элементов, которые образуют отдельную ячейку. Но мы оставляем это в качестве упражнения читателю.
Описанный алгоритм реализован в нижеследующей хранимой процедуре.

if sessionproperty ( N'ansi_nulls' ) = 0
    set ansi_nulls on
go

if sessionproperty ( N'quoted_identifier' ) = 0
    set quoted_identifier on
go

if object_id ( N'dbo.ConvertXmlToTable', N'P' ) is null
    exec ( N'create proc dbo.ConvertXmlToTable as return 1' )
go



alter proc dbo.ConvertXmlToTalbe
(
       @xml xml
)
as
begin
       declare @td int, @tr int, @h int, @sql nvarchar ( max ), @curtd int, @tab nvarchar ( 100 ) = quotename ( N'##' + cast ( newid () as nvarchar ( 1000 ) ) )
       declare @curtr int, @expr nvarchar ( max )
       exec sp_xml_preparedocument @h out, @xml
 


       if @xml.exist ( '/table/tbody' ) = 1 and @xml.exist ( '/table/tbody/tr' ) = 1
       begin
             set @expr = N'/table/tbody'


             select @tr = count (*)
             from openxml ( @h, N'/table/tbody', 1 )
             where localname = N'tr'
             if @tr =

             begin

                    select 'Данные не опознаны.' [Сообщение], @xml [Документ]

                    return 

             end
             select @td = count (*) / @tr
             from openxml ( @h, N'/table/tbody', 1 )
             where localname in ( N'td', N'th' )
       end
       else
       begin
             set @expr = N'/table'
             select @tr = count (*)
             from openxml ( @h, N'/table', 1 )
             where localname = N'tr'
             if @tr =

             begin

                    select 'Данные не опознаны.' [Сообщение], @xml [Документ]

                    return 

             end


             select @td = count (*) / @tr
             from openxml ( @h, N'/table', 1 )
             where localname in ( N'td', N'th' )
       end


       exec sp_xml_removedocument @h


       set @curtd = 1
       set @sql = N''
       while @curtd <= @td
       begin
             set @sql = @sql + case when @curtd = 1 then N'' else N', ' end + N'row' + cast ( @curtd as nvarchar ( 100 ) ) + N' varchar ( 8000 ) null'
             set @curtd += 1
       end
       set @sql = N'iRowId int identity ( 1, 1 ) not null primary key clustered, ' + @sql
       set @sql = N'
             if object_id ( N''tempdb..' + @tab + ''', N''U'' ) is not null
                    drop table ' + @tab + '
             create table ' + @tab + '
             (
                    ' + @sql + '
             ) on [PRIMARY]
       '
       exec sp_executesql @sql


       set @curtr = 1
       while @curtr <= @tr
       begin
             set @curtd = 1
             while @curtd <= @td
             begin
                    if @curtd = 1
                    begin
                           set @sql = N'
                                  insert into ' + @tab + ' ( row1 )
                                        select
                                               case when @xml.value ( N''(' + @expr + '/tr[' + cast ( @curtr as nvarchar ( 100 ) ) + ']/td[' + cast ( @curtd as nvarchar ( 100 ) ) + '])[1]'', N''nvarchar ( max )'' ) is not null
                                                      then @xml.value ( N''(' + @expr + '/tr[' + cast ( @curtr as nvarchar ( 100 ) ) + ']/td[' + cast ( @curtd as nvarchar ( 100 ) ) + '])[1]'', N''nvarchar ( max )'' )
                                                      else @xml.value ( N''(' + @expr + '/tr[' + cast ( @curtr as nvarchar ( 100 ) ) + ']/th[' + cast ( @curtd as nvarchar ( 100 ) ) + '])[1]'', N''nvarchar ( max )'' )


                                                      end
                           '
                    end
                    else
                    begin
                           set @sql = N'
                                 update ' + @tab + '
                                        set row' + cast ( @curtd as nvarchar ( 100 ) ) + ' =
                                               case when @xml.value ( N''(' + @expr + '/tr[' + cast ( @curtr as nvarchar ( 100 ) ) + ']/td[' + cast ( @curtd as nvarchar ( 100 ) ) + '])[1]'', N''nvarchar ( max )'' ) is not null
                                                      then @xml.value ( N''(' + @expr + '/tr[' + cast ( @curtr as nvarchar ( 100 ) ) + ']/td[' + cast ( @curtd as nvarchar ( 100 ) ) + '])[1]'', N''nvarchar ( max )'' )
                                                      else @xml.value ( N''(' + @expr + '/tr[' + cast ( @curtr as nvarchar ( 100 ) ) + ']/th[' + cast ( @curtd as nvarchar ( 100 ) ) + '])[1]'', N''nvarchar ( max )'' )
                                                      end
                                        where iRowId = ' + cast ( @curtr as nvarchar ( 100 ) ) + '
                           '
                    end
                    exec sp_executesql @sql, N'@xml xml', @xml = @xml
                    
                    set @curtd += 1
             end
             set @curtr += 1
       end


       set @sql = N'
             select *
             from ' + @tab + '
             if object_id ( N''tempdb..' + @tab + ''', N''U'' ) is not null
                    drop table ' + @tab + '
       '
       exec sp_executesql @sql
end
В процедуре методом exists проверяется наличие элемента TBODY, внутри которого могут быть элементы TR и TD.




Теперь поговорим о том, как добиться того, чтобы html-таблица стала правильно оформленным xml-документом. К этому есть несколько препятствий. Одно из них состоит в том, что в html-документе может присутствовать тэг, для которого нет закрывающего тэга.  Для решения этой проблемы мы напишем отдельную хранимую процедуру, которая будет искать все места, с которых начинаются открывающие и закрывающие тэги.
Если есть тэг для которого нет закрывающего тэга, то выбор места где требуется расположить закрывающий тэг необднозначен, и мы будет просто добавлять к такому тэгу, то есть жедать его тэгом-контейнером. Если же будет найден закрывающий тэг, для которого нет открывающего тэга, то такой тэг будет просто удаляться. При этом, если будут найдены открывающий и закрывающий тэги одного элемента, между которыми нет тэга с таким же элементом, то мы будет предполагать, что эти тэги относятся друг к другу и с ними ничего происходить не будет. Также не будут учитываться комментарии, которые будут находиться по подстроке "<!".
В процедуре будут использоваться две временные таблицы, в которых будут храниться номера символов, с которых начинаются закрывающие и открывающие тэги. Для построения таблицы, в которой будут столбцы с началом открывающего и закрывающего тэгов, будет использоваться запрос к двум таблицам с функцией lead, которая появилась в MS SQL 2012. Она позволяет писать более эффективные запросы и уменьшить объем кода. Ниже код хранимой процедуры CheckDocTags.


if sessionproperty ( N'ansi_nulls' ) = 0

    set ansi_nulls on
go

if sessionproperty ( N'quoted_identifier' ) = 0
    set quoted_identifier on
go

if object_id ( N'dbo.CheckDocTags', N'P' ) is null
    exec ( N'create proc dbo.CheckDocTags as return 1' )
go

alter proc dbo.CheckDocTags
(
       @txt nvarchar ( max ) out
)
as
begin
       set nocount, xact_abort on

       if object_id ( N'tempdb..#OpenTags', N'U' ) is not null
             drop table #OpenTags
       create table #OpenTags
       (
             iRowId       int identity ( 1, 1 )      not null,
             TagName      nvarchar ( 1000 )          not null,
             StartPos     int                        not null,
             IsClosed     bit                        not null,
             primary key clustered ( StartPos asc, TagName asc ) on [PRIMARY]
       ) on [PRIMARY]

       if object_id ( N'tempdb..#CloseTags', N'U' ) is not null
             drop table #CloseTags
       create table #CloseTags
       (
             iRowId       int identity ( 1, 1 )      not null,
             TagName      nvarchar ( 1000 )          not null,
             StartPos     int                        not null,
             primary key clustered ( StartPos asc, TagName asc ) on [PRIMARY]
       ) on [PRIMARY]

       declare @pos int, @iter int, @tag nvarchar ( max ), @IsClosed bit, @EndTag int

       set @iter = 0
       set @pos = 1
       while @pos <> 0
       begin
             set @pos = charindex ( N'<', @txt, @pos + case when @iter = 0 then 0 else 1 end )
  
             if @pos = 0
             begin
                    break
             end
             if substring ( @txt, @pos + 1, 1 ) = N'/'
             begin
                    continue
             end

             if substring ( @txt, @pos + 1, 1 ) = N'!'
             begin
                    continue
             end

             if charindex ( N' ', @txt, @pos + 1 ) = 0
             begin
                    set @tag = substring ( @txt, @pos + 1, charindex ( N'>', @txt, @pos + 1 ) - @pos - 1 )
             end
             else if charindex ( N'>', @txt, @pos + 1 ) = 0
             begin
                    set @tag = substring ( @txt, @pos + 1, charindex ( N' ', @txt, @pos + 1 ) - @pos - 1 )
             end
             else if charindex ( N' ', @txt, @pos + 1 ) < charindex ( N'>', @txt, @pos + 1 )
             begin
                    set @tag = substring ( @txt, @pos + 1, charindex ( N' ', @txt, @pos + 1 ) - @pos - 1 )
             end
             else
             begin
                    set @tag = substring ( @txt, @pos + 1, charindex ( N'>', @txt, @pos + 1 ) - @pos - 1 )
             end

             set @EndTag = charindex ( N'>', @txt, @pos + 1 )
             if substring ( @txt, @EndTag - 1, 1 ) = N'/'
             begin
                    set @IsClosed = 1
             end
             else
             begin
                    set @IsClosed = 0
             end

             insert into #OpenTags ( TagName, StartPos, IsClosed )
                    values ( @tag, @pos, @IsClosed )
 
            set @iter += 1
       end

       set @pos = 1
       set @iter = 0
       while @pos <> 0
       begin
             set @pos = charindex ( N'</', @txt, @pos + case when @iter = 0 then 0 else 1 end )
             if @pos = 0
             begin
                    break
             end

             set @tag = substring ( @txt, @pos + 2, charindex ( N'>', @txt, @pos ) - @pos - 2 )
             insert into #CloseTags ( TagName, StartPos )
                    values ( @tag, @pos )
 
             set @iter += 1
       end

       delete #OpenTags
             where IsClosed = 1
   
       if object_id ( N'tempdb..#Tags', N'U' ) is not null
             drop table #Tags
       create table #Tags
       (
             TagName             nvarchar ( max )    not null,
             StartPos     int                               not null,
             Direction    bit                               not null,
             iRowId       int                               not null
       ) on [PRIMARY]

       declare @kol int = 1
       while @kol <> 0
       begin
             delete #Tags

             insert into #Tags ( TagName, StartPos, Direction, iRowId )
                    select TagName, StartPos, 0 Direction, iRowId
                    from #OpenTags
                           union all
                    select TagName, StartPos, 1, iRowId
                    from #CloseTags

             ;
             with Data
             as
             (
                    select TagName, StartPos, Direction, iRowId,
                           lead ( Direction ) over ( partition by TagName order by TagName, StartPos ) Direction_next,
                           lead ( iRowId ) over ( partition by TagName order by TagName, StartPos ) iRowId_next
                    from #Tags
             )
             delete openTags
                    from
                           Data
                                  inner join
                           #OpenTags openTags on openTags.iRowId = Data.iRowId
                    where Data.Direction = 0 and Data.Direction_next = 1
  
             ;
             with Data
             as
             (
                    select TagName, StartPos, Direction, iRowId,
                           lead ( Direction ) over ( partition by TagName order by TagName, StartPos ) Direction_next,
                           lead ( iRowId ) over ( partition by TagName order by TagName, StartPos ) iRowId_next
                    from #Tags
             )
             delete closeTags
                    from
                           Data
                                  inner join
                           #CloseTags closeTags on closeTags.iRowId = Data.iRowId_next
                    where Data.Direction = 0 and Data.Direction_next = 1
      
             set @kol = @@rowcount
       end

       declare @step int
       set @kol = 0
       declare cur cursor local static forward_only for
             select TagName, StartPos
             from #CloseTags
             order by StartPos
       open cur
       fetch next from cur into @Tag, @pos
       while @@fetch_status = 0
       begin
             set @pos -= @kol
             set @EndTag = charindex ( N'>', @txt, @pos )
             set @txt = stuff ( @txt, @pos, @EndTag - @pos + 1, N'' )
 
             update #OpenTags
                    set StartPos = StartPos - ( @EndTag - @pos + 1 )
                    where StartPos >= @pos

             set @kol += @EndTag - @pos + 1
             fetch next from cur into @Tag, @pos
       end
       close cur
       deallocate cur

       set @step = 0
       declare cur cursor local static forward_only for
             select TagName, StartPos
             from #OpenTags
             order by StartPos
       open cur
       fetch next from cur into @Tag, @pos
       while @@fetch_status = 0
       begin
             set @pos += 2 * @step
             set @EndTag = charindex ( N'>', @txt, @pos )
             set @txt = stuff ( @txt, @EndTag, 0, ' /' )
             set @step += 1
             fetch next from cur into @Tag, @pos
       end
       close cur
       deallocate cur
end

Следующее препятствие состоит в том, что после имени элемента может идти имя атрибута, за которым не стоит знак равенства. Такие псевдо-атрибуты будут удаляться процедурой EliminateWrongAttr.


if sessionproperty ( N'ansi_nulls' ) = 0
    set ansi_nulls on
go


if sessionproperty ( N'quoted_identifier' ) = 0
    set quoted_identifier on
go


if object_id ( N'dbo.EliminateWrongAttr', N'P' ) is null
    exec ( N'create proc dbo. EliminateWrongAttr as return 1' )
go


alter proc dbo.EliminateWrongAttr
(
       @txt nvarchar ( max ) out
)
as
begin
       set nocount, xact_abort on


       declare @pos int, @cnt int = 1, @NewAttr int, @EndNewAttr int


       set @pos = 1
       while @pos <> 0
       begin
             set @pos = charindex ( '<', @txt, case when @cnt = 1 then @pos else @pos + 1 end )
             set @cnt += 1


             if @pos = 0
             begin
                    break
             end
             set @NewAttr = charindex ( N' ', @txt, @pos + 1 )


             if @NewAttr > charindex ( N'>', @txt, @pos + 1 ) or @NewAttr = 0
             begin
                    continue
             end


             if charindex ( N' ', @txt, @NewAttr + 1 ) = 0
             begin
                    set @EndNewAttr = charindex ( N'>', @txt, @NewAttr + 1 )
             end


             if charindex ( N'>', @txt, @NewAttr + 1 ) = 0
             begin
                    set @EndNewAttr = charindex ( N' ', @txt, @NewAttr + 1 )
             end


             if charindex ( N' ', @txt, @NewAttr + 1 ) <> 0 and
                    charindex ( N'>', @txt, @NewAttr + 1 ) <> 0
             begin
                    set @EndNewAttr = case when charindex ( N' ', @txt, @NewAttr + 1 ) < charindex ( N'>', @txt, @NewAttr + 1 ) then charindex ( N' ', @txt, @NewAttr + 1 )
                           else charindex ( N'>', @txt, @NewAttr + 1 ) end
             end


             if @EndNewAttr = 0
             begin
                    continue
             end


             if charindex ( N'>', @txt, @NewAttr ) <= @EndNewAttr
             begin
                    continue
             end


              if charindex ( N'=', @txt, @NewAttr ) = 0 or charindex ( N'=', @txt, @NewAttr ) > @EndNewAttr
             begin
                    set @txt = stuff ( @txt, @NewAttr, @EndNewAttr - @NewAttr, N'' )
             end
       end
end
Теперь необходимо учесть, чтобы значение каждого атрибута было заключено в двойные кавычки. Надо сказать, что некоторые атрибуты могут и так быть в кавычках, необходимо отслеживать значение атрибутов, не заключенные в кавычки и добавлять их.
Для поиска значений атрибутов будет использоваться то, что перед ними стоит знак равно. Здесь потребуется быть внимательным. Значение атрибута, которое уже заключено в кавычки, может содержать пробелы и другие символы равенства, поэтому в таких случаях всегда требуется искать место окончания значения атрибута по знаку закрывающей кавычки. И уже после этого продолжать поиск следующих атрибутов. Следует также учитывать, что атрибуты могут быть также заключены в одиночные кавычки. В этом случае на них также не требуется воздействовать. Дополнительно необходимо устранять специальные символы, начинающиеся на знак амперсанда (&). Также знаки двоеточия заменяются символом тире на случай, если будут испольщоваться необъявленные пространства имен. Процедура ниже решает задачу. В конце она вызывает процедуры dbo.CheckDocTags, dbo.ConvertXmlToTable и dbo.EliminateWrongAttr.

if sessionproperty ( N'ansi_nulls' ) = 0
    set ansi_nulls on
go

 
if sessionproperty ( N'quoted_identifier' ) = 0
    set quoted_identifier on
go


if object_id ( N'dbo.MakeXmlFromTab, N'P' ) is null
    exec ( N'create proc dbo.MakeXmlFromTab as return 1' )
go


alter proc [dbo].[MakeXmlFromTab]
(
       @txt nvarchar ( max )
)
as
begin
       declare @CurAttrStart int, @CurAttrEnd int, @txtPrev nvarchar ( max ) = @txt, @xml xml, @quote char ( 1 ), @isAttrEx bit = 1, @err nvarchar ( max )


       -- пока используется предположение, что символ за концом значения атрибута, это или пробел или ">"


       -- место начала и конца значения атрибута
       set @CurAttrStart = charindex ( N'=', @txt, 1 )
       if @CurAttrStart = 0
       begin
             set @isAttrEx = 0
       end


       if @isAttrEx = 1
       begin
             set @CurAttrEnd = case when charindex ( N'>', @txt, @CurAttrStart + 1 ) < charindex ( N' ', @txt, @CurAttrStart + 1 )
                    then charindex ( N'>', @txt, @CurAttrStart + 1 ) else charindex ( N' ', @txt, @CurAttrStart + 1 ) end


             if charindex ( N'>', @txt, @CurAttrStart + 1 ) = 0
             begin
                    set @CurAttrEnd = charindex ( N' ', @txt, @CurAttrStart + 1 )
             end
             if charindex ( N' ', @txt, @CurAttrStart + 1 ) = 0
             begin
                    set @CurAttrEnd = charindex ( N'>', @txt, @CurAttrStart + 1 )
             end


             -- добавляем кавычки к значению первого атрибута
             if substring ( @txt, @CurAttrStart + 1, 1 ) not in ( N'"', N'''' ) and substring ( @txt, @CurAttrEnd + 1, 1 ) not in ( N'"', N'''' )
             begin
                    set @txt = stuff ( @txt, @CurAttrStart + 1, 0, N'"' )
                    set @txt = stuff ( @txt, @CurAttrEnd + 1, 0, N'"' )
             end
       end


       if substring ( @txt, @CurAttrStart + 1, 1 ) in ( N'"', N'''' )
       begin
             -- это требуется, чтобы не нарваться на знак =, который находится внутри значения атрибута
             set @quote = substring ( @txt, @CurAttrStart + 1, 1 )
             set @CurAttrStart = charindex ( @quote, @txt, @CurAttrStart + 2 )
       end


       if @CurAttrStart = 0 or @CurAttrEnd = 0
       begin
             set @isAttrEx = 0
       end


       while @CurAttrStart <> 0 and @isAttrEx = 1
       begin
             set @CurAttrStart = charindex ( N'=', @txt, @CurAttrStart + 1 )
             if @CurAttrStart = 0
             begin
                    break
             end
             if substring ( @txt, @CurAttrStart + 1, 1 ) in ( N'"', N'''' )
             begin
                    -- это требуется, чтобы не нарваться на знак =, который находится внутри значения атрибута
                    set @quote = substring ( @txt, @CurAttrStart + 1, 1 )
                    set @CurAttrStart = charindex ( @quote, @txt, @CurAttrStart + 2 )
                    continue
             end


             set @CurAttrEnd = 0
             if charindex ( N'>', @txt, @CurAttrStart + 1 ) < charindex ( N' ', @txt, @CurAttrStart + 1 ) and charindex ( N'>', @txt, @CurAttrStart + 1 ) <> 0
             begin
                    set @CurAttrEnd = charindex ( N'>', @txt, @CurAttrStart + 1 )
             end
             if charindex ( N' ', @txt, @CurAttrStart + 1 ) < charindex ( N'>', @txt, @CurAttrStart + 1 ) and charindex ( N' ', @txt, @CurAttrStart + 1 ) <> 0


             begin
                    set @CurAttrEnd = charindex ( N' ', @txt, @CurAttrStart + 1 )
             end
             if charindex ( N' ', @txt, @CurAttrStart + 1 ) = 0
             begin
                    set @CurAttrEnd = charindex ( N'>', @txt, @CurAttrStart + 1 )
             end
             if charindex ( N'>', @txt, @CurAttrStart + 1 ) = 0
             begin
                    set @CurAttrEnd = charindex ( N' ', @txt, @CurAttrStart + 1 )
             end



             if @CurAttrStart = 0 or @CurAttrEnd = 0
             begin
                    break
             end


             if substring ( @txt, @CurAttrStart + 1, 1 ) not in ( N'"', N'''' ) and substring ( @txt, @CurAttrEnd + 1, 1 ) not in ( N'"', N'''' )
             begin
                    set @txt = stuff ( @txt, @CurAttrStart + 1, 0, N'"' )
                    set @txt = stuff ( @txt, @CurAttrEnd + 1, 0, N'"' )
             end
       end


       set @txt = replace ( @txt, N'&nbsp;', N' ' )
       set @txt = replace ( @txt, N'&nbsp', N' ' )
       set @txt = replace ( @txt, N'&lang', N'' )


       set @txt = replace ( @txt, N'&', N'' )
       set @txt = replace ( @txt, N':', N'-' )


       begin try
             exec dbo.CheckDocTags @txt out
       end try
       begin catch
             select @txt, error_message () [Описание ошибки], error_line () [Номер строки], error_procedure () [Процедура]
       end catch


       exec dbo.EliminateWrongAttr @txt out


       set @xml = try_convert ( xml, @txt )
       if @xml is null
       begin
             begin try
                    set @xml = cast ( @txt as xml )
             end try
             begin catch
                    set @err = error_message ()
             end catch
             select @txtPrev [Исходный текст], @txt [Преобразованный текст] , @err [Текст ошибки]
       end
       else
       begin
             begin try
                    exec dbo.ConvertXmlToTalbe @xml
             end try
             begin catch
                    select error_procedure () [Процедура], error_message () [Описание ошибки], error_line () [Номер строки], @xml [Документ]
             end catch
       end
end

До сих пор мы работали с html-таблицей. Но ее надо извлечь из html-документа. Для поиска таблицы мы, конечно, будем использовать имя тэга, то есть искать подстроки вида <TABLE. Но необходимо учесть, что внутри тэга TABLE может находиться другой такой тэг. Это вложенность может иметь нескольких уровней. Мы будем искать самый глубокий тэг TABLE и извлекать содержимое таблицы. Хранимая процедура dbo.FindTables решает задачу. Она в цикле извлекает все таблицы из документа и вызывает для таблицы хранимую процедуру dbo.MakeXmlFromTable.

if sessionproperty ( N'ansi_nulls' ) = 0
    set ansi_nulls on
go


if sessionproperty ( N'quoted_identifier' ) = 0
    set quoted_identifier on
go



if object_id ( N'dbo.FindTables, N'P' ) is null
    exec ( N'create proc dbo.FindTables as return 1' )

go

alter proc dbo.FindTables
(
      @txt nvarchar ( max )
)
as
begin
      declare @StartTab int, @EndTab int, @StartNextTab int, @TabTxt nvarchar ( max ), @cnt int

      set @StartTab = 1
      set @cnt = 1
      while @StartTab <> 0
      begin
            set @StartTab = charindex ( N'<table', @txt, case when @cnt = 1 then 0 else 1 end + @StartTab )

            if @StartTab = 0
            begin
                  break
            end

            set @EndTab = charindex ( N'/table>', @txt, @StartTab + 1 )
            set @StartNextTab = charindex ( N'<table', @txt, @StartTab + 1 )

            if @StartNextTab <> 0
            begin
                  if @StartNextTab < @EndTab
                  begin
                        while @StartNextTab <> 0 and @StartNextTab < @EndTab
                        begin
                             set @StartTab = charindex ( N'<table', @txt, @StartTab + 1 )
                             set @StartNextTab = charindex ( N'<table', @txt, @StartTab + 1 )
                        end
                  end
            end

            set @EndTab = charindex ( N'/table>', @txt, @StartTab + 1 )
            set @TabTxt = substring ( @txt, @StartTab, @EndTab + len ( N'/table>' ) - @StartTab )
       
            exec dbo.MakeXmlFromTab @TabTxt
            set @cnt += 1
      end
end

Вроде бы на этом все. Теперь можно протестировать работу группы процедур на примере сайта банка Москвы (http://www.bm.ru). Запустим такой код
declare @txt nvarchar ( max ) = dbo.HtmlCodeGet ( 'http://www.bm.ru' )
exec dbo.FindTables @txt
В результате получаем информацию о таблицах сайта в привычном для SQL Server-а виде:



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

Комментариев нет:

Отправить комментарий