Showing posts with label css. Show all posts
Showing posts with label css. Show all posts

Thursday, 26 August 2021

CSS Grid's 'grid-template-areas' are wonderfully intuitive

Finally, I have found a decent CSS grid layout technology. Twenty years ago, I used to use HTML tables to structure a page. Then we were told not to use tables and switch over to CSS instead but the CSS techniques at the time were inadequate and so I and many other programmers carried on with HTML tables. Now, I am happy to blog about CSS Grid's grid-template-areas which are wonderfully intuitive way to layout a page.

A really good YouTube video is Easily Structure your Layout with CSS Grid's 'grid-template-areas' and I have given the source code for this video below. I have also embedded the sample page into this blog entry, converting as required. You should find that this is a responsive page that will reduce to a column/stack if the browser's width is made narrow. The CSS has a 'mobile first' design in that the default declaration is for the reduced screen mobile stack whilst the media queries further down are where to find the full window declarations.

You really should watch the video in full but for those in a hurry the real essence is in the following extracts, first we have this grid-template-areas CSS property...

grid-template-areas:
    "sidebar header header header"
    "sidebar sect1  sect2  sect3"
    "sidebar main   main   main"
    "sidebar footer footer footer";

Then we have the HTML...

<body>
    <aside></aside>
    <header></header>
    <section></section>
    <section></section>
    <section></section>
    <main></main>
    <footer></footer>
</body>

Then these are tied together by specifying the grid-area property in each HTML element's CSS ...

aside { grid-area: sidebar; }
header { grid-area: header; }
section:nth-of-type(1) { grid-area: sect1; }
section:nth-of-type(2) { grid-area: sect2; }
section:nth-of-type(3) { grid-area: sect3; }
main { grid-area: main; }
footer { grid-area: footer; }

And that's it, full listing below. Speaking personally this will be my go to page when drawing up a web page from scratch. Enjoy!

Sample Page

Code Listings

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="main.css">
</head>
<body>
    <aside></aside>
    <header></header>
    <section></section>
    <section></section>
    <section></section>
    <main></main>
    <footer></footer>
</body>
</html></html>

main.css

body,
html {
    height: 100vh;
}

body {
    margin: 0;
    display: grid;
    grid-template-columns: 100%;
    grid-template-rows: repeat(5, auto);
    grid-template-areas:
        "sect1"
        "sect2"
        "sect3"
        "main"
        "footer";
}

aside {
    grid-area: sidebar;
    background-color: #007fff;
}

header {
    grid-area: header;
    background-color: #71b8eb;
}

section:nth-of-type(1) {
    grid-area: sect1;
    background-color: #B3D8FD;
}

section:nth-of-type(2) {
    grid-area: sect2;
    background-color: #5E86AF;
}

section:nth-of-type(3) {
    grid-area: sect3;
    background-color: #6D9FD2;
}

main {
    grid-area: main;
    background-color: #7DA9D5;
}

footer {
    grid-area: footer;
    background-color: #588EC3;
}

@media only screen and (min-width: 768px) {
    body {
        margin: 0;
        display: grid;
        grid-template-columns: auto 27% 27% 27%;
        grid-template-rows: 8% 30% auto 10%;
        grid-template-areas:
            "sidebar header header header"
            "sidebar sect1  sect2  sect3"
            "sidebar main   main   main"
            "sidebar footer footer footer";
    }
}

Links

Monday, 3 February 2020

Use VBA to generate CSS Grid markup

"The point is, ladies and gentlemen, that grids are good, grids clarify, cut through and capture the essence of a cool user experience.  Grids, in all of their forms, grids on spreadsheets, grids on street plans, grids on web pages have marked the upward surge in humankind and CSS Grids, you mark my words, will save the malfunctioning specification called HTML."   -  Not Gordon Gecko

Introduction

Before Excel there was Lotus 1-2-3 which was launched in 1983; seven years later in 1990 Sir Tim Berners Lee invented HTML but sadly missed basing the document layout on a grid despite the success of spreadsheets.  However he did give us HTML tables and these were grids but then the powers that be told us to stop using tables for page structure and instead use CSS features called "floats".  For me this was a wrong turn.

Microsoft's Windows Presentation Foundation (WPF) introduced a grid structure and so was (in opinion) a better technology for laying-out a user interface. It looks like some people in the HTML/CSS world, for example Rachel Andrew, saw WPF and decided to copy it. Good for them. CSS Grids is obviously the right answer and now I can now recommend to any client to build their GUI in HTML and not WPF.

But then comes the issue of tooling and I can't help feeling that the Excel cell grid is a good place to model the HTML interface and we can write some VBA code to convert it to HTML and CSS and that is what the code in this post does.

Thankfully, the way CSS Grids works turns out to be quite simple. See the links at the bottom for CSS Grids documentation.

The set up

So the VBA code below is best saved to its own workbook which I have called ExcelGridToCssGridByColor.xlsm. I found keeping the code separate from the layout data to be invaluable whilst I tracked down some hard bugs. An earlier iteration used names but this was troublesome and so I switched to using colors to denote the regions of a grid.

Next, I added a new workbook which contains only layout data and no code, I called mine CssGridLayoutsColors1.xlsx. In it I have three layout sheets but I started with one to begin with, called Default. On this Default sheet I define a stack of colored cells with identifiers which will be used for CSS identifers. The Default sheet looks like the leftmost screenshot below. It is meant to represent the layout for the smallest possible screen and this is why it is a single column of cells. The height of the rows is respected in the HTML (as is the column widths) so feel free to adjust the row heights.

Then, I cloned the Default sheet twice and renamed them min-width-500 and min-width-700 which forms the media query that drives the responsive web design that allows the page to take advantage of more screen real estate. On the these two new sheets and I gave myself two columns and three columns respectively and I moved the regions around. The cells on these extra sheets can contain whatever text you want, i.e. they don't have to be identifiers like the Default sheet. The final sheet contains the text that will reach the markup.

The three sheets should look like the following screen shot...

The code's output is both to the immediate window but also to a file; I called mine N:\CssGrids.html but you can change the filename in the first subroutine of code. The output file is HTML and CSS and a rendering is shown on the left below. The best browser to use for CSS Grids is Firefox because it has a Grid developer tool shown on the right in the screenshot below and this is why the page shot on the left is annotated with the grid lines and grid area names.

If you play with the browser window's width then you'll see it respond to narrowest widths with a single column and the widest widths with three columns. Hence you now have responsive web design and all this is achieved without using Bootstrap and its twelve columns (incidentally you can replicate that if you want, see the docs).

The Html file

Here is the output of HTML and CSS. Please see links below to the documentation to understand it.

<html>
<head>
<style>
.clssite {
  display: grid;
  grid-template-columns:  135fr;
  grid-template-rows:  28fr 28fr 87fr 15fr 15fr;
  grid-template-areas: "masthead"
                       "page_title"
                       "main_content"
                       "sidebar"
                       "footer"
                       ;
}

.clsfooter {
  grid-area: footer;
}

.clsmain_content {
  grid-area: main_content;
}

.clsmasthead {
  grid-area: masthead;
}

.clspage_title {
  grid-area: page_title;
}

.clssidebar {
  grid-area: sidebar;
}

@media (min-width: 500px) {
  .clssite {
    display: grid;
    grid-template-columns:  83fr 83fr;
    grid-template-rows:  28fr 28fr 87fr 15fr;
    grid-template-areas: "page_title page_title"
                         "main_content masthead"
                         "main_content sidebar"
                         "footer footer"
                         ;
  }
}

@media (min-width: 700px) {
  .clssite {
    display: grid;
    grid-template-columns:  96fr 48fr 48fr;
    grid-template-rows:  28fr 28fr 87fr;
    grid-template-areas: "page_title page_title page_title"
                         "main_content masthead masthead"
                         "main_content sidebar footer"
                         ;
  }
}

</style>
</head>
<body>
<div class="clssite">
<div class="clsfooter">footer</div>
<div class="clsmain_content">main_content</div>
<div class="clsmasthead">masthead</div>
<div class="clspage_title">page_title</div>
<div class="clssidebar">sidebar</div>
</div>
</body>
</html>

The VBA code

So finally here is the VBA code, you will need a Tools->Reference to Microsoft Scripting Runtime.

Option Explicit

'* Tools->References
'*   Microsoft Scripting Runtime

Private mdicLines As New Scripting.Dictionary
Private Const csMinWidth As String = "min-width-"
Private Const csAnchorCellAddress As String = "B3"

Sub Test()

    Dim wb As Excel.Workbook
    Set wb = Application.Workbooks.Item("CssGridLayoutsColors1.xlsx")

    With wb.Worksheets
        
        Dim wsDefault As Excel.Worksheet
        Set wsDefault = .Item("Default")
        
        Dim ashtLayoutSheets As Variant
        ashtLayoutSheets = Array(wsDefault, .Item("min-width-500"), .Item("min-width-700"))
    End With
    
    Dim dicKeyColors As Scripting.Dictionary
    Set dicKeyColors = ReadKeyColors(wsDefault)
    
    WriteCssGrid ashtLayoutSheets, dicKeyColors
    WriteToFile "N:\CssGrids.html"
End Sub

Function ReadKeyColors(ByVal wsDefault As Excel.Worksheet) As Scripting.Dictionary
    
    Dim rngColorKey As Excel.Range
    Set rngColorKey = wsDefault.Range(csAnchorCellAddress).CurrentRegion
    
    Dim dicKeyColors As Scripting.Dictionary
    Set dicKeyColors = New Scripting.Dictionary

    Dim rngLoop As Excel.Range
    For Each rngLoop In rngColorKey
    
        Dim sColorName As String
        sColorName = rngLoop.Value
        
        If InStr(1, sColorName, " ", vbBinaryCompare) > 0 Then
            Err.Raise vbObjectError, "", "Input Error: Default sheet has cell content with spaces." & vbNewLine & _
                    "Please omit spaces as content here defines identifiers in the HTML/CSS."
        
        End If
        
        Dim lColor As Long
        lColor = rngLoop.Interior.Color
        
        If LenB(sColorName) > 0 Then
            If Not dicKeyColors.Exists(lColor) Then
                dicKeyColors.Add lColor, sColorName
            Else
                Err.Raise vbObjectError, "", "Key colors are not unique"
            End If
        End If
    Next

    Set ReadKeyColors = dicKeyColors

End Function

Sub WriteCssGrid(ByRef ashtLayoutSheets As Variant, ByVal dicKeyColors As Scripting.Dictionary)
    
    Call ValidateInputParameters(ashtLayoutSheets)

    Set mdicLines = Nothing '* reset the output buffer

    WL "<html>"
    WL "<head>"
    WL "<style>"
    
    Dim vLoop As Variant
    For Each vLoop In ashtLayoutSheets
        
        Dim wsLoop As Excel.Worksheet
        Set wsLoop = vLoop
        
        Call WriteCSSForGridSite(wsLoop, dicKeyColors)
    Next
    
    WL "</style>"
    WL "</head>"
    WL "<body>"

    Call WriteHtmlForGridSite(ashtLayoutSheets(UBound(ashtLayoutSheets)), dicKeyColors)

    WL "</body>"
    WL "</html>"

    Debug.Print VBA.Join(mdicLines.Items, vbNewLine)

End Sub


Sub WriteCSSForGridSite(ByVal wsLoop As Excel.Worksheet, ByVal dicKeyColors As Scripting.Dictionary)

    Dim lMinWidth As Long
    If wsLoop.Name = "Default" Then
        lMinWidth = 0
    Else
        lMinWidth = CLng(Mid$(wsLoop.Name, Len(csMinWidth) + 1))
    End If

    Dim bMediaQuery As Boolean
    bMediaQuery = (lMinWidth > 0)
    
    Dim rngSite As Excel.Range
    Set rngSite = wsLoop.Range(csAnchorCellAddress).CurrentRegion

    Dim sIndent As String
    sIndent = VBA.IIf(bMediaQuery, "  ", "")
    
    Dim dicRegions As Scripting.Dictionary
    Dim sAreas As String
    sAreas = DetermineRegionsByColor(wsLoop, dicKeyColors, lMinWidth, dicRegions)

    If bMediaQuery Then WL "@media (min-width: " & lMinWidth & "px) {"
    WL sIndent & ".clsSite  {"
    WL sIndent & "  display: grid;"
    WL sIndent & "  grid-template-columns: " & siteColumnsOrRows(rngSite, XlRowCol.xlColumns) & ";"
    WL sIndent & "  grid-template-rows: " & siteColumnsOrRows(rngSite, XlRowCol.xlRows) & ";"
    WL sIndent & "  grid-template-areas: " & sAreas & ";"
    WL sIndent & "}"
    If bMediaQuery Then WL "}"
    WL ""

    Dim wb As Excel.Workbook
    Set wb = rngSite.Worksheet.Parent
    
    If Not bMediaQuery Then

        Dim vRegion As Variant
        For Each vRegion In dicKeyColors.Items

            WL ".cls" & vRegion & " {"
            WL "  grid-area: " & vRegion & ";"

            WL "}"
            WL ""
        Next
    End If
End Sub


Function siteColumnsOrRows(ByVal rngSite As Excel.Range, ByVal eRowcol As XlRowCol) As String
    Dim sReturn As String
    
    If eRowcol = xlRows Then
        Dim rngRowLoop As Excel.Range
        For Each rngRowLoop In rngSite.Rows
            Dim lRowHeight As Long
            lRowHeight = rngRowLoop.Height
             
            sReturn = sReturn & " " & CStr(lRowHeight) & "fr"
        Next
    End If
    
    If eRowcol = xlColumns Then
        Dim rngColumnLoop As Excel.Range
        For Each rngColumnLoop In rngSite.Columns
            Dim lColumnWidth As Long
            lColumnWidth = rngColumnLoop.Width
             
            sReturn = sReturn & " " & CStr(lColumnWidth) & "fr"
        Next
    End If
    
    siteColumnsOrRows = sReturn
    
End Function

Function DetermineRegionsByColor(ByVal ws As Excel.Worksheet, _
                ByVal dicKeyColors As Scripting.Dictionary, ByVal lMinWidth As Long, _
                ByRef pdicRegions As Scripting.Dictionary) As String

    Dim rngAnchor As Excel.Range
    Set rngAnchor = ws.Range(csAnchorCellAddress)
    
    Dim rngCurrentRegion As Excel.Range
    Set rngCurrentRegion = rngAnchor.CurrentRegion
    
    Dim sReturn As String
    sReturn = ""
    
    Set pdicRegions = New Scripting.Dictionary
    
    Dim rngRowLoop As Excel.Range
    For Each rngRowLoop In rngCurrentRegion.Rows
    
        Dim sRow As String: sRow = """"
    
        Dim rngLoop As Excel.Range
        For Each rngLoop In rngRowLoop.Cells
                
            Dim sRegion As String
            sRegion = "."
            If dicKeyColors.Exists(rngLoop.Interior.Color) Then
                sRegion = dicKeyColors.Item(rngLoop.Interior.Color)
                
                If pdicRegions.Exists(sRegion) Then
                
                    Dim rngUnion As Excel.Range
                    Set rngUnion = Application.Union(rngLoop, pdicRegions.Item(sRegion))
                    
                    If rngUnion.Areas.Count > 1 Then
                        Err.Raise vbObjectError, "", "Error: Non-contiguous color block detected at cell " & ws.Name & "!" & rngLoop.Address
                    Else
                        Set pdicRegions.Item(sRegion) = rngUnion
                    End If
                Else
                    pdicRegions.Add sRegion, rngLoop
                
                End If
                
            End If
            sRow = sRow & sRegion & " "
        Next
        
        sRow = Trim(sRow) & """"
        
        sReturn = sReturn & sRow & vbNewLine & Space$(VBA.IIf(lMinWidth = 0, 23, 25))
        
    Next
    
    DetermineRegionsByColor = sReturn

End Function

Sub WriteHtmlForGridSite(ByVal ws As Excel.Worksheet, ByVal dicKeyColors As Scripting.Dictionary)

    Dim dicRegions As Scripting.Dictionary
    DetermineRegionsByColor ws, dicKeyColors, 0, dicRegions
    
    WL "<div class=""clssite"">"

    Dim wb As Excel.Workbook
    Set wb = ws.Parent

    Dim vRegionLoop As Variant
    For Each vRegionLoop In dicRegions
    
        Dim rngRegion As Excel.Range
        Set rngRegion = dicRegions.Item(vRegionLoop)
        
        Dim vRangeValues As Variant
        vRangeValues = rngRegion.Value

        Dim sRangeValues As String: sRangeValues = ""
        If IsEmpty(vRangeValues) Then
            sRangeValues = ""
        ElseIf IsArray(vRangeValues) Then

            Dim vLoop As Variant
            For Each vLoop In vRangeValues
                sRangeValues = sRangeValues & CStr(vLoop)
            Next vLoop
        Else
            sRangeValues = CStr(vRangeValues)
        End If

        WL "<div class=""cls" & vRegionLoop & """>" & sRangeValues & "</div>"
    Next

    WL "</div>"
End Sub

Sub WL(sLineToWrite As String)
    mdicLines.Add mdicLines.Count, sLineToWrite
End Sub

Sub WriteToFile(ByVal sFileName As String)

    Dim fso As Scripting.FileSystemObject
    Set fso = New Scripting.FileSystemObject
    
    Dim txtOut As Scripting.TextStream
    Set txtOut = fso.CreateTextFile(sFileName, True)
    
    Dim vLine As Variant
    For Each vLine In mdicLines.Items
        txtOut.WriteLine vLine
    Next
    txtOut.Close
    Set txtOut = Nothing


End Sub

Function ValidateInputParameters(ByRef ashtLayoutSheets As Variant)
    Dim ws As Excel.Worksheet
    Dim vLoop As Variant
    Dim sErrMsg As String
    
    Const csOneDim As String = "Input Error: ashtLayoutSheets should be a one-dimensional array" & _
                                " of at least 1 worksheet"
                                
    Const csNameConvention As String = _
        "Input Error: ashtLayoutSheets contains worksheet '{ws.Name}' which breaks naming convention." & _
                vbNewLine & "The sheet should be called either 'Default' or " & _
                "begin with '" & csMinWidth & "' following by pixel number"
    
    '* Ensure ashtLayoutSheets is an array
    If Not IsArray(ashtLayoutSheets) Then Err.Raise vbObjectError, "", csOneDim
                
    '* Ensure ashtLayoutSheets array is one-dimensional
    Dim lLength As Long: lLength = -1
    On Error Resume Next
    lLength = UBound(ashtLayoutSheets) - LBound(ashtLayoutSheets) + 1
    On Error GoTo 0
    If lLength = -1 Then Err.Raise vbObjectError, "", csOneDim
                
    '* Ensure all given worksheets conform to naming convention
    For Each vLoop In ashtLayoutSheets
        Set ws = vLoop
        
        '* check the sheet is called either 'Default' or begins with 'min-width-'
        If ws.Name = "Default" Then
            '* fine, do nothing
        
        ElseIf Left$(ws.Name, Len(csMinWidth)) = csMinWidth Then
            '* check the remains are numeric
            
            Dim sPixel As String
            sPixel = Mid$(ws.Name, Len(csMinWidth) + 1)
            
            If Not IsNumeric(sPixel) Then Err.Raise vbObjectError, "", Replace(csNameConvention, "{ws.Name}", ws.Name)
        
        Else
            Err.Raise vbObjectError, "", Replace(csNameConvention, "{ws.Name}", ws.Name)
                
        End If
    Next
End Function

Links

Wednesday, 5 December 2018

HTML5 Animations with CSS & VBA: Fading in text

Browser GUI technologies are clearly better than VBA's. Html5 is a wonderful leap forward with its animation capabilities. However, sometimes you'll want some code to help avoid fiddling with CSS. Why not VBA?

First Message
Second Message
Third Message
Fourth Message

So I hope you just seen the above messages fade in one after the after. How is this achieved? Firstly, there is the opacity attribute (0 = invisible, 1 = visible). Then we have to animate the opacity and for that need the animation-name, animation-delay and animation-duration attributes. We also need a @keyframes rule to give the schedule for the opacity to change over time, in this example given as percentages. The animation-name attribute must tie in to the identifier given in the @keyframes rule. What follows is the source to drive this simple animation.

HTML to fade in text messages

  1. <style>
  2.     .allText {
  3.         font-size2rem;
  4.         font-familysans-serif;
  5.         color#000000;
  6.     }
  7.  
  8.     @keyframes FadeIn10_20 {
  9.         0% opacity0; }
  10.         10% opacity0; }
  11.         20% opacity1; }
  12.         100% opacity1; }
  13.     }
  14.  
  15.     .text0 {
  16.         animation-nameFadeIn10_20;
  17.         animation-delay0s;
  18.         animation-duration10s;
  19.     }
  20.  
  21.     @keyframes FadeIn30_40 {
  22.         0% opacity0; }
  23.         30% opacity0; }
  24.         40% opacity1; }
  25.         100% opacity1; }
  26.     }
  27.  
  28.     .text1 {
  29.         animation-nameFadeIn30_40;
  30.         animation-delay0s;
  31.         animation-duration10s;
  32.     }
  33.  
  34.     @keyframes FadeIn50_60 {
  35.         0% opacity0; }
  36.         50% opacity0; }
  37.         60% opacity1; }
  38.         100% opacity1; }
  39.     }
  40.  
  41.     .text2 {
  42.         animation-nameFadeIn50_60;
  43.         animation-delay0s;
  44.         animation-duration10s;
  45.     }
  46.  
  47.     @keyframes FadeIn70_80 {
  48.         0% opacity0; }
  49.         70% opacity0; }
  50.         80% opacity1; }
  51.         100% opacity1; }
  52.     }
  53.  
  54.     .text3 {
  55.         animation-nameFadeIn70_80;
  56.         animation-delay0s;
  57.         animation-duration10s;
  58.     }
  59. </style>
  60. <div class='allText text0'>First Message</div>
  61. <div class='allText text1'>Second Message</div>
  62. <div class='allText text2'>Third Message</div>
  63. <div class='allText text3'>Fourth Message</div>

Initially, I struggled with the animation directives, I grew frustrated editing source CSS manually so I wrote some VBA to help set the timings because a change in duration of one message had a knock-on effect on the following messages. I needed some code to total the timings in seconds and then express the timing of the animation events as percentages.

I am aware that there are CSS pre-processors out there such as LESS and SASS but I'd rather use VBA. Here is the source

modCSSFadeInAnimation Standard Module

  1. Option Explicit 
  2. Option Private Module
  3.  
  4. '*
  5. '* Brought to you by the Excel Development Platform Blog
  6. '* http://exceldevelopmentplatform.blogspot.com/2018/12/
  7. '*
  8.  
  9. Private mdicLines As Scripting.Dictionary
  10.  
  11. Private Type udtMessage
  12.     sText As String
  13.     dStart As Double 'In seconds
  14.     dFadeIn As Double 'In seconds
  15.     lStartPercentage As Long
  16.     lFadeInPercentage As Long
  17.     lTop As Long
  18.     dDuration As Double 'In seconds
  19. End Type
  20.  
  21. Private Type udtMessages
  22.     Messages() As udtMessage
  23. End Type
  24.  
  25. Private mlTotalSeconds As Long
  26.  
  27. Private Sub Main()
  28.  
  29.     Dim bFullHtmlDocument As Boolean
  30.     bFullHtmlDocument = False '*<---- change to True to get a full html document instead of a fragment
  31.  
  32.     Dim uMessages As udtMessages
  33.  
  34.     AddMessage uMessages, 10, 2, "First Message", 2
  35.     AddMessage uMessages, 20, 2, "Second Message"
  36.     AddMessage uMessages, 30, 2, "Third Message"
  37.     AddMessage uMessages, 40, 2, "Fourth Message"
  38.  
  39.     CalcFades uMessages
  40.     OpenStyleTag bFullHtmlDocument
  41.     If bFullHtmlDocument Then WritePositioningCSS uMessages
  42.     WriteAnimationCSS uMessages ', 30
  43.     WriteEndOfStyleBlock bFullHtmlDocument
  44.     WriteBody uMessages, bFullHtmlDocument
  45.  
  46.     Debug.Print Join(mdicLines.Items, vbNewLine)
  47.  
  48. End Sub
  49.  
  50. Private Sub AddMessage(ByRef uMessages As udtMessages, ByVal lTop As LongByVal dDuration As DoubleByVal sText As String,
  51.         Optional dStart0 As Double)
  52.     Dim lIndex As Long
  53.  
  54.     If Not IsArrayInitialized(uMessages) Then
  55.         '* not yet initialised
  56.         lIndex = 0
  57.         ReDim uMessages.Messages(0 To 0) As udtMessage
  58.  
  59.     Else
  60.         lIndex = UBound(uMessages.Messages) + 1
  61.         ReDim Preserve uMessages.Messages(0 To lIndex) As udtMessage
  62.     End If
  63.  
  64.     If lIndex = 0 Then
  65.         uMessages.Messages(lIndex).dStart = dStart0
  66.     Else
  67.         uMessages.Messages(lIndex).dStart = uMessages.Messages(lIndex - 1).dStart + uMessages.Messages(lIndex - 1).dDuration
  68.     End If
  69.     uMessages.Messages(lIndex).sText = sText
  70.     uMessages.Messages(lIndex).lTop = lTop
  71.     uMessages.Messages(lIndex).dDuration = dDuration
  72.  
  73. End Sub
  74.  
  75. Private Function IsArrayInitialized(ByRef uMessages As udtMessages)
  76.     On Error GoTo ErrHand
  77.     Dim lUbound As Long
  78.     lUbound = UBound(uMessages.Messages)
  79.     IsArrayInitialized = True
  80.     Exit Function
  81. ErrHand:
  82.  
  83. End Function
  84.  
  85. Private Sub CalcFades(ByRef uMessages As udtMessages, Optional dDefaultFade As Double = 1)
  86.  
  87.     Dim lLoop As Long
  88.  
  89.     For lLoop = LBound(uMessages.Messages) To UBound(uMessages.Messages)
  90.         If uMessages.Messages(lLoop).dFadeIn = 0 Then
  91.             uMessages.Messages(lLoop).dFadeIn = uMessages.Messages(lLoop).dStart - dDefaultFade
  92.         End If
  93.         If uMessages.Messages(lLoop).dFadeIn < 0 Then uMessages.Messages(lLoop).dFadeIn = 0
  94.     Next
  95.  
  96.     mlTotalSeconds = uMessages.Messages(UBound(uMessages.Messages)).dStart +
  97.                     uMessages.Messages(UBound(uMessages.Messages)).dDuration
  98.  
  99.     For lLoop = LBound(uMessages.Messages) To UBound(uMessages.Messages)
  100.         uMessages.Messages(lLoop).lStartPercentage = 100 * uMessages.Messages(lLoop).dStart / mlTotalSeconds
  101.         uMessages.Messages(lLoop).lFadeInPercentage = 100 * uMessages.Messages(lLoop).dFadeIn / mlTotalSeconds
  102.     Next
  103.  
  104. End Sub
  105.  
  106. Private Sub OpenStyleTag(Optional bWriteHtmlTags As Boolean False)
  107.     Set mdicLines = New Scripting.Dictionary
  108.     If bWriteHtmlTags Then
  109.         AddLine "<!DOCTYPE html>"
  110.         AddLine "<html>"
  111.         AddLine "<head>"
  112.         AddLine "<title>Presentation</title>"
  113.         AddLine "<meta name='viewport' content='width=device-width, initial-scale=1'></meta>"
  114.     End If
  115.     AddLine "<style>"
  116.     AddLine ".allText {"
  117.     AddLine "  font-size:2rem;"
  118.     AddLine "  font-family:sans-serif;"
  119.     AddLine "  color: #000000;"
  120.     AddLine "}"
  121.     AddLine ""
  122.  
  123. End Sub
  124.  
  125. Private Sub WritePositioningCSS(ByRef uMessages As udtMessages)
  126.     Dim lLoop As Long
  127.     For lLoop = LBound(uMessages.Messages) To UBound(uMessages.Messages)
  128.         AddLine ".text" & lLoop & " {"
  129.         AddLine "  position: absolute;"
  130.         AddLine "  left: 5%;"
  131.  
  132.         If uMessages.Messages(lLoop).lTop = 0 Then
  133.             AddLine "  top: " & 5 * (lLoop + 1) & "%;"
  134.         Else
  135.             AddLine "  top: " & uMessages.Messages(lLoop).lTop & "%;"
  136.         End If
  137.         AddLine "}"
  138.         AddLine ""
  139.     Next
  140. End Sub
  141.  
  142. Private Sub WriteAnimationCSS(ByRef uMessages As udtMessages, Optional lTotalSeconds As Variant)
  143.  
  144.     If Not IsMissing(lTotalSeconds) Then mlTotalSeconds = lTotalSeconds
  145.  
  146.     Dim lLoop As Long
  147.     For lLoop = LBound(uMessages.Messages) To UBound(uMessages.Messages)
  148.  
  149.         '* write keyframe, store keyframe name
  150.         Dim sKeyFrameName As String
  151.         KeyFramesFadeIn uMessages.Messages(lLoop).lFadeInPercentage, uMessages.Messages(lLoop).lStartPercentage, sKeyFrameName
  152.  
  153.         AddLine ".text" & lLoop & " {"
  154.         AddLine "  animation-name: " & sKeyFrameName & ";"
  155.         AddLine "  animation-delay: 0s;"
  156.         AddLine "  animation-duration: " & mlTotalSeconds & "s;"
  157.  
  158.         AddLine "}"
  159.         AddLine ""
  160.     Next
  161. End Sub
  162.  
  163. Private Sub WriteEndOfStyleBlock(Optional bWriteHeadTag As Boolean False)
  164.     AddLine "</style>"
  165.     If bWriteHeadTag Then AddLine "</head>"
  166.  
  167. End Sub
  168.  
  169. Private Sub WriteBody(ByRef uMessages As udtMessages, Optional bWriteHtmlAndBodyTags As Boolean False)
  170.     If bWriteHtmlAndBodyTags Then AddLine "<body>"
  171.  
  172.     Dim lLoop As Long
  173.     For lLoop = LBound(uMessages.Messages) To UBound(uMessages.Messages)
  174.  
  175.         AddLine "<div class='allText text" & lLoop & "'>" & uMessages.Messages(lLoop).sText & "</div>" 'position: absolute;"
  176.     Next
  177.  
  178.     If bWriteHtmlAndBodyTags Then
  179.         AddLine "</body>"
  180.         AddLine "</html>"
  181.     End If
  182.  
  183. End Sub
  184.  
  185. Private Function KeyFramesFadeIn(ByVal lStartPercent As LongByVal lEndPercent As LongByRef psKeyFrameName As String)
  186.  
  187.     psKeyFrameName = "FadeIn" & Pad(lStartPercent, 2, "0") & "_" & Pad(lEndPercent, 2, "0")
  188.  
  189.     AddLine "@keyframes " & psKeyFrameName & " {"
  190.     AddLine "    0% { opacity: 0; }"
  191.     AddLine "  " & Pad(lStartPercent, 3, " ") & "% { opacity: 0; }"
  192.     AddLine "  " & Pad(lEndPercent, 3, " ") & "% { opacity: 1; }"
  193.     AddLine "  100% { opacity: 1; }"
  194.     AddLine "}"
  195.     AddLine ""
  196.  
  197. End Function
  198.  
  199. Private Sub AddLine(ByVal sLine As String)
  200.     mdicLines.Add mdicLines.Count, sLine
  201. End Sub
  202.  
  203. Private Function Pad(ByVal lNum As LongByVal lLen As LongByVal sChar As StringAs String
  204.     If Len(CStr(lNum)) > lLen Then lLen = Len(CStr(lNum))
  205.     Pad = Right$(String$(lLen, sChar) & CStr(lNum), lLen)
  206. End Function
  207.  
  208.  

Thursday, 4 January 2018

VBA MSHTML Webscraping - Looking for a New Oven

So webscraping is a task that can sometimes be a breeze and other times a pain in the neck. Without a shadow of a doubt the problem of mal-formed HTML where <BR>,<IMG> and <P> tags are not closed thus compromising the structure of the document is one of the most lamentable departures from a standard I can think of. If the tags were closed properly then one could have a sporting chance of running a document through an Xml parser. There was a push to get HTML to be XML compliant with XHTML but it failed. Along with unclosed tags, an Xml parser will choke on textual attributes values without quotes and also characters that need to be entitised like & -> &amp;. If it would parse successfully with Xml then XPath would be the way to navigate the document.

In the absence of Xml nirvana, other strategies have emerged. The prevalent use of JQuery and CSS means the web page developers structure the page so that their Javascript selects nodes based on CSS selectors. We can do this in MSHTML (the type library whose full name is 'Microsoft HTML Object Library') with querySelectorAll and querySelector.

Even so, I find MSHTML to be buggy and querySelectorAll and querySelector don't always work. So I find myself writing code to loop through child nodes and next siblings etc. I also write code to test the node name and the attribtutes of each node. In short, I end up writing some helper functions which I hope I will re-use. I will post code here. But I will also post some findings which are new (to me). I've even ended up writing some good old fashioned string manipulation to excise a snippet of code. I've also written code to allow compatible snippets to be loaded into a mini Xml document. All these are given below in my MSHTMLComparables class.

The task - scrape details of ovens

So we need a new oven and I need to get some details from a local shop's website. The code is given below, I saved the web pages off manually to save network traffic. I am experienced in this but one of two new (to me) things emerged.

MSHTML parses HTML5 <Article> tags as type MSHTML.HTMLUnknownElement

So HTML5 introduces new tags, and until the MSHTML library gets updates it looks like the new tags will be reported as being of type HTMLUnknownElement. So some methods we're not available to hand. Can be worked around fine.

MSHTML if querying style attribute for CSS source use CssText

So HTML elements have attributes, attributes are key value pairs. One such attribute found on an element is the Style element, and it is curious that the value of this attribute is itself a key value pair collection. I wanted to get the full style value but I ended up having to query for the specific CSS name, i.e. Height. Type library browser shows two promising set of methods, getAttribute, setAttribute, removeAttribute and getExpression, setExpression, removeExpression. Looking at the documentation remarks the xxxExpression methods are for the IHTMLDocument2::expando properties and added after the original xxxAttribute methods.

I used objStyle.getAttribute("height") because I knew it existed. I wanted the whole CSS source but the method toString gives only [object] which is a JavaScript object's default string representation. I know a thing or two about JavaScript objects and there string representation in VBA and borrowed my own SO code (below) to investigate but the code generates 150 properties and not the one or two that I was expecting.



...
            Dim objStyle As Object
            Set objStyle = divProductImagesLoop.getAttribute("style")
            
            Dim dicStyleAttrib As Scripting.Dictionary
            Set dicStyleAttrib = New Scripting.Dictionary
            
            Call GetScriptEngine.Run("enumerateKeys", objStyle, dicStyleAttrib) '* this gives JSON rendering instead of "[object Object]"


...

Private Function GetScriptEngine() As ScriptControl
    '* see code from this SO Q & A
    ' https://stackoverflow.com/questions/37711073/in-excel-vba-on-windows-how-to-get-stringified-json-respresentation-instead-of
    Static soScriptEngine As ScriptControl
    If soScriptEngine Is Nothing Then
        Set soScriptEngine = New ScriptControl
        soScriptEngine.Language = "JScript"

        
        soScriptEngine.AddCode "function enumerateKeys(jsonObj, microsoftDict) { " & _
                                    "for (var key in jsonObj) { " & _
                                    "microsoftDict.Add(key, jsonObj[key]);  " & _
                                    "}}"
    End If
    Set GetScriptEngine = soScriptEngine
End Function

Anyway, it turns out the that there is a CssText property which I think is sparsely documented, here is a non-Microsoft blog post.

MSHTMLComparables Class - beginnings of a reusable class for web scraping tasks (I hope)


Option Explicit

Private mdicComparables As New Scripting.Dictionary

Private fso As New Scripting.FileSystemObject


Private Declare Function GetTempFileName Lib "kernel32.dll" Alias "GetTempFileNameA" (ByVal lpszPath As String, _
            ByVal lpPrefixString As String, ByVal wUnique As Long, ByRef lpTempFileName As String) As Long


Friend Function HashOfHash() As Long
    
    Dim vPrimes
    vPrimes = Array(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
    
    Dim lRet As Long

    Dim lIndex As Long
    Dim vKeyLoop As Variant
    For Each vKeyLoop In mdicComparables.Keys
        lRet = lRet + (mdicComparables.HashVal(vKeyLoop) * vPrimes(lIndex))
        lIndex = lIndex + 1
    Next vKeyLoop
    
    Dim vItemLoop
    For Each vItemLoop In mdicComparables.Items
        lRet = lRet + (mdicComparables.HashVal(vKeyLoop) * vPrimes(lIndex))
        lIndex = lIndex + 1
    Next vItemLoop
    
    
    HashOfHash = lRet
End Function

Friend Function TempFile() As String

    TempFile = fso.BuildPath(VBA.Environ$("TMP"), "TempFile" & HashOfHash & ".html")
    
End Function



Friend Function SpawnHTMLFragmentFromSrc(ByVal sSrc As String) As Object
    Debug.Assert Len(sSrc) > 0
    
    Dim sTempFile As String
    sTempFile = TempFile
    
    Dim txtOut As Scripting.TextStream
    Set txtOut = fso.CreateTextFile(sTempFile)
    
    txtOut.WriteLine "<html>"
    txtOut.WriteLine "<head>"
    txtOut.WriteLine "<title>Temporary work file created by " & ThisWorkbook.Path & "</title>"
    txtOut.WriteLine "</head>"
    txtOut.WriteLine "<body>"
    txtOut.WriteLine sSrc
    txtOut.WriteLine "</body>"
    txtOut.WriteLine "</html>"
    txtOut.Close
    
    Set txtOut = Nothing
    
    Dim oHtml4 As MSHTML.IHTMLDocument4
    Set oHtml4 = New MSHTML.HTMLDocument
    
    Dim oHtml As MSHTML.HTMLDocument
    Set oHtml = Nothing
    
    '* IHTMLDocument4.createDocumentFromUrl
    '* MSDN - IHTMLDocument4 createDocumentFromUrl method - https://msdn.microsoft.com/en-us/library/aa752523(v=vs.85).aspx
    Set oHtml = oHtml4.createDocumentFromUrl(sTempFile, "")
    While oHtml.readyState <> "complete"
        DoEvents  '* do not comment this out it is required to break into the code if in infinite loop
    Wend
    Debug.Assert oHtml.readyState = "complete"
    
    
    
    Dim htmlBody As MSHTML.htmlBody
    Set htmlBody = oHtml.querySelector("body")
    
    Set SpawnHTMLFragmentFromSrc = htmlBody.FirstChild
    
    Set oHtml4 = Nothing
    Set oHtml = Nothing
    Set htmlBody = Nothing

End Function


Friend Function SpawnXmlFragmentFromSrc(ByVal sSrc As String) As MSXML2.IXMLDOMElement
    Debug.Assert Len(sSrc) > 0
    
    
    Dim sTempFile As String
    sTempFile = TempFile
    
    Dim txtOut As Scripting.TextStream
    Set txtOut = fso.CreateTextFile(sTempFile)
    txtOut.WriteLine sSrc
    txtOut.Close
    
    Set txtOut = Nothing
    
    Dim oDoc As MSXML2.DOMDocument60
    Set oDoc = New MSXML2.DOMDocument60
    oDoc.load sTempFile

    Dim xmlErr As IXMLDOMParseError2
    Set xmlErr = oDoc.parseError

    Debug.Assert oDoc.parseError.ErrorCode = 0
    
    Set SpawnXmlFragmentFromSrc = oDoc.DocumentElement
    


End Function


Public Function BuildComparablesDict(ByVal vKVPs As Variant) As Scripting.Dictionary
    Set BuildComparablesDict = Nothing

    Dim vKVPLoop As Variant
    For Each vKVPLoop In vKVPs
        If Not IsArray(vKVPLoop) Then Exit Function
        mdicComparables.add vKVPLoop(0), vKVPLoop(1)
    Next

    Set BuildComparablesDict = mdicComparables

End Function


Public Function FindSnippetInSource(ByVal sSrc As String) As String
    On Error GoTo ErrHandler
    Debug.Assert Len(sSrc) > 0
    
    
    If Not mdicComparables.exists("nodeName") Then
        Err.Raise vbObjectError, "#programmer must supply nodeName to find both start and end tag!"
    Else
        Dim sNodeName As String
        sNodeName = mdicComparables.Item("nodeName")
        
        If Len(sNodeName) = 0 Then
            Err.Raise vbObjectError, "#programmer must supply non-null nodeName to find both start and end tag!"
        Else
        
            Dim sStartTag As String
            sStartTag = "<" & sNodeName
            
            Dim sEndTag As String
            sEndTag = "</" & sNodeName & ">"
            
            Dim lIndex As Long
            lIndex = 1
            
            Do
                DoEvents
                Dim bOk As Boolean
                bOk = True
                Dim lFindStartTag As Long
                lFindStartTag = VBA.InStr(lIndex, sSrc, sStartTag, vbTextCompare)
                
                If lFindStartTag > 0 Then
                    lIndex = lFindStartTag + 1
                
                    Dim lFindEndTag As Long
                    lFindEndTag = VBA.InStr(lFindStartTag, sSrc, sEndTag, vbTextCompare)
                    
                    If lFindEndTag > 0 Then
                        
                        Dim sSnippet As String
                        sSnippet = Mid$(sSrc, lFindStartTag, lFindEndTag - lFindStartTag + Len(sEndTag))
                        
                        Dim lFindStartTag2 As Long
                        lFindStartTag2 = VBA.InStr(1, sSnippet, ">", vbTextCompare)
                        
                        If lFindStartTag2 > 0 Then
                            Dim sStartTagAndAttributes As String
                            sStartTagAndAttributes = Left$(sSnippet, lFindStartTag2)
                        
                            Dim sAttributesOnly As String
                            sAttributesOnly = Mid$(sStartTagAndAttributes, Len("<" & sStartTag & " "), Len(sStartTagAndAttributes) - Len("<" & sStartTag & " "))
                        
                            Dim vSplitAttributeBlock As Variant
                            vSplitAttributeBlock = SplitStringBySpaceRespectingQuotes(sAttributesOnly)
                            
                            Dim dicMyAttributes As Scripting.Dictionary
                            Set dicMyAttributes = New Scripting.Dictionary
                            
                            Dim vSplitAttributeBlockLoop As Variant
                            For Each vSplitAttributeBlockLoop In vSplitAttributeBlock
                                
                                Dim vSplitAttributeKVP As Variant
                                vSplitAttributeKVP = VBA.Split(vSplitAttributeBlockLoop, "=")
                                
                                Debug.Assert LBound(vSplitAttributeKVP) = 0
                                Debug.Assert UBound(vSplitAttributeKVP) = 1
                                
                                Dim vCleanedValue As Variant
                                vCleanedValue = vSplitAttributeKVP(1)
                                If Left$(vCleanedValue, 1) = """" Then vCleanedValue = Mid$(vCleanedValue, 2)
                                If Right$(vCleanedValue, 1) = """" Then vCleanedValue = Left$(vCleanedValue, Len(vCleanedValue) - 1)
                            
                                dicMyAttributes.add vSplitAttributeKVP(0), vCleanedValue
                            
                            Next vSplitAttributeBlockLoop
                            
                            Dim sStartTagRewritten As String
                            sStartTagRewritten = UCase$(sStartTag) & " "
                            
                            Dim vRewriteLoop As Variant
                            For Each vRewriteLoop In dicMyAttributes
                                sStartTagRewritten = sStartTagRewritten & vRewriteLoop & "=""" & dicMyAttributes(vRewriteLoop) & """ "
                            Next vRewriteLoop
                            
                            sStartTagRewritten = Trim(sStartTagRewritten) & ">"
                            
                            sSnippet = sStartTagRewritten & Mid$(sSnippet, lFindStartTag2 + 1)
                            
                            Dim vComparableKeyLoop As Variant
                            For Each vComparableKeyLoop In mdicComparables.Keys
                            
                                If StrComp(vComparableKeyLoop, "nodeName", vbTextCompare) <> 0 Then
                                    If Not dicMyAttributes.exists(vComparableKeyLoop) Then
                                        bOk = False '* FAIL
                                    Else
                                        
                                        If Not mdicComparables.Item(vComparableKeyLoop) = dicMyAttributes.Item(vComparableKeyLoop) Then
                                            bOk = False '* FAIL
                                        End If
                                
                                    End If
                                End If
                            
                            Next vComparableKeyLoop
                        End If
                    
                    End If
                
                    
                
                End If
                DoEvents
            Loop Until bOk Or lFindStartTag <= 0
            
            
        End If
    
    End If
    
    If bOk Then FindSnippetInSource = sSnippet
SingleExit:
    Exit Function
ErrHandler:
    Stop
    Resume

End Function

Friend Function SplitStringBySpaceRespectingQuotes(ByVal sToBeSplit As String) As Variant


    Dim sQuotesSpacesPlaceHeld As String
    sQuotesSpacesPlaceHeld = ""

    Dim lLoop As Long
    Dim bInQuote As Boolean
    For lLoop = 1 To Len(sToBeSplit)
    
        Dim sChar As String * 1
        sChar = Mid$(sToBeSplit, lLoop, 1)
        
        Dim lAsc As Long
        lAsc = Asc(sChar)
        
        If lAsc = 34 Then
            
            bInQuote = Not bInQuote
            
            sQuotesSpacesPlaceHeld = sQuotesSpacesPlaceHeld & sChar
        ElseIf lAsc = 32 And bInQuote Then
            '* don't copy over the space because it will break out VBA.Split(sFoo," ") logic
            '* instead copy over "<>" which should appear in HTML/XML attributes blocks (they should be <>)
            '* don't forget to replace the "<>" with " " at the end!
            sQuotesSpacesPlaceHeld = sQuotesSpacesPlaceHeld & "<>"
        Else
            sQuotesSpacesPlaceHeld = sQuotesSpacesPlaceHeld & sChar
        End If
        
        
    Next lLoop
    
    Dim vSplit As Variant
    vSplit = VBA.Split(sQuotesSpacesPlaceHeld, " ")
    
    
    For lLoop = LBound(vSplit) To UBound(vSplit)
        vSplit(lLoop) = VBA.Replace(vSplit(lLoop), "<>", " ")
    Next lLoop
    
    SplitStringBySpaceRespectingQuotes = vSplit
    



End Function

Friend Function NodeMatchesComparables(ByVal objNode As Object) As Boolean
    Dim bOk As Boolean
    bOk = True '* good until proven otherwise

    '* has to match all, so need to loop thru all

    Dim vComparableKeyLoop As Variant
    For Each vComparableKeyLoop In mdicComparables.Keys
        
        Dim vCompare As Variant
        vCompare = Empty
        
        If vComparableKeyLoop = "class" Then
            vCompare = objNode.className
        ElseIf vComparableKeyLoop = "nodeName" Then
            vCompare = objNode.nodeName
        Else
            vCompare = objNode.getAttribute(vComparableKeyLoop)
        End If
        
        
                    
        If IsNull(vCompare) Then
            bOk = False
        Else
            If StrComp(vCompare, mdicComparables.Item(vComparableKeyLoop), vbTextCompare) <> 0 Then
                bOk = False
            End If
        End If
        
    Next vComparableKeyLoop
    
    NodeMatchesComparables = bOk
End Function

Public Function FindNextSiblingNodeByAttibutes(ByVal objStartNode As Object) As Object
    Debug.Assert Not objStartNode Is Nothing
    If mdicComparables.Count > 0 Then
            
        Dim objNodeLoop As Object
        Set objNodeLoop = objStartNode.NextSibling
    
        Do
    
            If NodeMatchesComparables(objNodeLoop) Then
                Set FindNextSiblingNodeByAttibutes = objNodeLoop
                Exit Do
            End If
            Set objNodeLoop = objNodeLoop.NextSibling
        
        Loop Until objNodeLoop Is Nothing
        
    End If

End Function

Public Function FindChildNodeByAttibutes(ByVal objStartNode As Object) As Object
    Debug.Assert Not objStartNode Is Nothing
        
    On Error GoTo ErrHandler
    
    If mdicComparables.Count > 0 Then
    
        Dim objChildNodes As Object
        Set objChildNodes = objStartNode.ChildNodes
    
        Dim lChildNodeLoop As Long
        For lChildNodeLoop = 0 To objChildNodes.Length - 1
            
            Dim objChildNodeLoop As Object
            Set objChildNodeLoop = objChildNodes.Item(lChildNodeLoop)

            If NodeMatchesComparables(objChildNodeLoop) Then
                Set FindChildNodeByAttibutes = objChildNodeLoop
                Exit For
            End If
    
        Next lChildNodeLoop
    End If
SingleExit:
    Exit Function
    
ErrHandler:
    Stop
    Resume

End Function



The main code module - contains web page specific logic


Option Explicit

'* Tools->Refernces Microsoft HTML Object Library

Sub TestScrapeProductDetailsFromMainPages()
    Dim wsOvens As Excel.Worksheet
    Set wsOvens = ThisWorkbook.Worksheets.Item("Ovens")

    Dim dicProducts As Scripting.Dictionary
    Set dicProducts = ScrapeProductDetailsFromMainPages

    wsOvens.Cells.clear



    Dim lRowLoop As Long
    lRowLoop = 2


    Dim dicColumnOrdinals As Scripting.Dictionary
    Set dicColumnOrdinals = New Scripting.Dictionary

    Dim vProductLoop As Variant
    For Each vProductLoop In dicProducts.Keys
        Dim dicProductLoop As Scripting.Dictionary
        Set dicProductLoop = dicProducts.Item(vProductLoop)

        Dim vFeatureKeyLoop As Variant
        For Each vFeatureKeyLoop In dicProductLoop
            
            If VBA.InStr(1, "|CollectionUnavailable|HomeDeliveryAvailable|noStock|CollectInStore|OutOfStock|OnlineOnly|", "|" & vFeatureKeyLoop & "|", vbTextCompare) = 0 Then
            
                If Not dicColumnOrdinals.exists(vFeatureKeyLoop) Then
                    dicColumnOrdinals.add vFeatureKeyLoop, dicColumnOrdinals.Count
                End If
            End If
        Next vFeatureKeyLoop
    Next
    
    
    dicColumnOrdinals.add "HomeDeliveryAvailable", dicColumnOrdinals.Count
    dicColumnOrdinals.add "CollectionUnavailable", dicColumnOrdinals.Count
    dicColumnOrdinals.add "noStock", dicColumnOrdinals.Count
    dicColumnOrdinals.add "CollectInStore", dicColumnOrdinals.Count
    dicColumnOrdinals.add "OutOfStock", dicColumnOrdinals.Count
    dicColumnOrdinals.add "OnlineOnly", dicColumnOrdinals.Count

'

    Dim vColOrd As Variant
    For Each vColOrd In dicColumnOrdinals.Keys
        wsOvens.Cells(1, dicColumnOrdinals.Item(vColOrd) + 1).Value2 = vColOrd
    Next
    
    For Each vProductLoop In dicProducts.Keys
        
        Set dicProductLoop = dicProducts.Item(vProductLoop)

        
        For Each vFeatureKeyLoop In dicProductLoop
            If Not dicColumnOrdinals.exists(vFeatureKeyLoop) Then
                wsOvens.Cells(1, dicColumnOrdinals.Count).Value2 = vFeatureKeyLoop

            End If
            wsOvens.Cells(lRowLoop, dicColumnOrdinals.Item(vFeatureKeyLoop) + 1).Value2 = dicProductLoop(vFeatureKeyLoop)
        Next vFeatureKeyLoop




        lRowLoop = lRowLoop + 1
    Next


    FormatOvensRows

End Sub

Function ScrapeProductDetailsFromMainPages() As Scripting.Dictionary

    Dim fso As Scripting.FileSystemObject
    Set fso = CreateObject("Scripting.FileSystemObject")


    Dim sFiles(1 To 2) As String
    sFiles(1) = "N:\COOKER\Built-in double ovens - Cheap Built-in double ovens Deals _ Currys 1_50.html"
    sFiles(2) = "N:\COOKER\Built-in double ovens - Cheap Built-in double ovens Deals _ Currys 2_50.html"


    Debug.Assert fso.FileExists(sFiles(1))
    Debug.Assert fso.FileExists(sFiles(2))

    Dim sSrc(1 To 2) As String
    sSrc(1) = fso.OpenTextFile(sFiles(1)).ReadAll
    sSrc(2) = fso.OpenTextFile(sFiles(2)).ReadAll


     Dim lFileLoop As Long
     For lFileLoop = 1 To 2


        '* Tools->Refernces Microsoft HTML Object Library
        Dim oHtml4 As MSHTML.IHTMLDocument4
        Set oHtml4 = New MSHTML.HTMLDocument

        Dim oHtml As MSHTML.HTMLDocument
        Set oHtml = Nothing

        '* IHTMLDocument4.createDocumentFromUrl
        '* MSDN - IHTMLDocument4 createDocumentFromUrl method - https://msdn.microsoft.com/en-us/library/aa752523(v=vs.85).aspx
        Set oHtml = oHtml4.createDocumentFromUrl(sFiles(lFileLoop), "")

        '* need to wait a little whilst the document parses
        '* because it is multithreaded
        While oHtml.readyState <> "complete"
            DoEvents  '* do not comment this out it is required to break into the code if in infinite loop
        Wend
        Debug.Assert oHtml.readyState = "complete"


        Dim sTest As String
        sTest = Left$(oHtml.body.outerHTML, 100)
        Debug.Assert Len(Trim(sTest)) > 50  '* just testing we got a substantial block of text, feel free to delete

        '* this is where the page specific logic now goes, here I am getting info from a StackOverflow page
        Dim htmlAnswers As Object 'MSHTML.DispHTMLElementCollection
        Set htmlAnswers = oHtml.getElementsByClassName("result-prd")

        Dim htmlProductListImages As Object
        Set htmlProductListImages = oHtml.getElementsByClassName("productListImage")


        Dim htmlDescs As Object
        Set htmlDescs = oHtml.getElementsByClassName("product-desc")

        Dim htmlAnchorIns As Object
        Set htmlAnchorIns = oHtml.querySelectorAll("a.in")


        Dim htmlDivsMainAmount As Object
        Set htmlDivsMainAmount = oHtml.querySelectorAll("div.main-amount")

        Dim htmlChannels As Object
        Set htmlChannels = oHtml.querySelectorAll("ul.simple.prd-channels")

        Dim htmlPromoMessages As Object
        Set htmlPromoMessages = oHtml.querySelectorAll("div.promoMessages")

        Debug.Assert htmlAnswers.Length = htmlPromoMessages.Length
        Debug.Assert htmlAnswers.Length = htmlChannels.Length
        Debug.Assert htmlAnswers.Length = htmlDivsMainAmount.Length
        Debug.Assert htmlAnswers.Length = htmlProductListImages.Length
        Debug.Assert htmlAnswers.Length = htmlDescs.Length
        Debug.Assert htmlAnswers.Length = htmlAnchorIns.Length


        Dim dicProducts As New Scripting.Dictionary


        Dim lAnswerLoop As Long
        For lAnswerLoop = 0 To htmlAnswers.Length - 1
            Dim dicProductLoop As Scripting.Dictionary
            Set dicProductLoop = New Scripting.Dictionary


            '* each grid cell has its own article
            dicProductLoop.add "articleId", htmlAnswers.Item(lAnswerLoop).getAttribute("id")

            '* grab pictures details like src,alt and CSStext
            AddPictureDetails dicProductLoop, htmlProductListImages.Item(lAnswerLoop)


            '* grab brand and product name
            AddBrandAndName dicProductLoop, htmlAnchorIns.Item(lAnswerLoop)

            '* grab review score (out of 10) and total reviews
            AddRevoo dicProductLoop, htmlAnchorIns.Item(lAnswerLoop)

            '* grab price now, and optionally price befor and saving
            AddPrices dicProductLoop, htmlDivsMainAmount.Item(lAnswerLoop)

            '* check availability, delivery, stock on order etc
            AddChannels dicProductLoop, htmlChannels.Item(lAnswerLoop)
 
            '* get online only
            AddPromoMessages dicProductLoop, htmlPromoMessages.Item(lAnswerLoop)

            '* build a dictionary of dictionaries
            dicProducts.add dicProducts.Count, dicProductLoop
            
            

            Set dicProductLoop = Nothing
            Debug.Print lAnswerLoop
        Next
    Next lFileLoop
    Set ScrapeProductDetailsFromMainPages = dicProducts
End Function

Private Function AddPromoMessages(ByVal dicProductLoop As Scripting.Dictionary, _
            ByVal htmlDivPromoMessages As MSHTML.HTMLDivElement)
            
    Dim oOnLineOnly As MSHTMLComparables
    Set oOnLineOnly = New MSHTMLComparables

    oOnLineOnly.BuildComparablesDict Array(Array("nodeName", "span"), Array("class", "label-online-only"))
    If InStr(1, htmlDivPromoMessages.outerHTML, "label-online-only", vbTextCompare) > 0 Then
        Dim sOnlineOnlySnippet As String
        sOnlineOnlySnippet = oOnLineOnly.FindSnippetInSource(htmlDivPromoMessages.innerHTML)
        
        If Len(sOnlineOnlySnippet) > 0 Then
        
            Dim xmlOnlineOnly As Object
            Set xmlOnlineOnly = oOnLineOnly.SpawnXmlFragmentFromSrc(sOnlineOnlySnippet)
            
            If Not xmlOnlineOnly Is Nothing Then
            
                dicProductLoop.add "OnlineOnly", xmlOnlineOnly.nodeTypedValue
            End If
            
            'Stop
        End If
    End If
            
End Function


Private Function AddChannels(ByVal dicProductLoop As Scripting.Dictionary, _
            ByVal htmlChannelList As MSHTML.HTMLListElement)

    Dim lLoop As Long
    For lLoop = 0 To htmlChannelList.ChildNodes.Length - 1
        Dim objChildNode As Object
        Set objChildNode = htmlChannelList.ChildNodes.Item(lLoop)

        Debug.Assert StrComp(objChildNode.nodeName, "li", vbTextCompare) = 0
        Dim listItem As MSHTML.HTMLLIElement
        Set listItem = objChildNode

        If listItem.className = "nostock" Then

            Dim vNoStock As Variant
            Dim vUnavailable As Variant
            Dim vCollectionUnavailable As Variant
            Dim vOutOfStock As Variant
            Dim vHomeDeliveryAvailable As Variant
            Dim vCollectInStore As Variant


            Dim lListItemChildLoop As Long
            For lListItemChildLoop = 0 To listItem.ChildNodes.Length - 1

                Dim objListItemChildLoop As Object
                Set objListItemChildLoop = listItem.ChildNodes.Item(lListItemChildLoop)
                If objListItemChildLoop.nodeName = "#text" Then
                    vNoStock = vNoStock & Trim(objListItemChildLoop.data) & ". "

                ElseIf StrComp(objListItemChildLoop.nodeName, "SPAN", vbTextCompare) = 0 Then
                    If objListItemChildLoop.className = "email-when-back" Then
                        vNoStock = vNoStock & "Email me when back. "
                    Else
                        Stop
                    End If
                    'Stop
                ElseIf StrComp(objListItemChildLoop.nodeName, "I", vbTextCompare) = 0 Then
                    '*ignore
                Else
                    Stop
                End If


            Next lListItemChildLoop

        ElseIf listItem.className = "available" Then
            If listItem.getAttribute("data-availability") = "homeDeliveryAvailable" Then
            
                Dim objHomedeliveryAvailChildLoop As Object
                For Each objHomedeliveryAvailChildLoop In listItem.ChildNodes
                    If objHomedeliveryAvailChildLoop.nodeName = "#text" Then
                        vHomeDeliveryAvailable = vHomeDeliveryAvailable & objHomedeliveryAvailChildLoop.data & ". "
                    End If
                
                Next
            ElseIf listItem.getAttribute("data-availability") = "collectInStoreUnavailable" Then
                Dim objCollectInStoreUnAvailableLoop2 As Object
                For Each objCollectInStoreUnAvailableLoop2 In listItem.ChildNodes
                    If objCollectInStoreUnAvailableLoop2.nodeName = "#text" Then
                        vCollectionUnavailable = vCollectionUnavailable & objCollectInStoreUnAvailableLoop2.data & ". "
                    End If
                
                Next
                
            ElseIf listItem.getAttribute("data-availability") = "collectInStoreAvailable" Then
                Dim objCollectInStoreAvailableLoop As Object
                For Each objCollectInStoreAvailableLoop In listItem.ChildNodes
                    If objCollectInStoreAvailableLoop.nodeName = "#text" Then
                        vCollectInStore = vCollectInStore & objCollectInStoreAvailableLoop.data & ". "
                    End If
                
                Next
                'Stop
            Else
                Stop
            End If
            'Stop

        ElseIf listItem.className = "unavailable" Then
            If listItem.getAttribute("data-availability") = "collectInStoreUnavailable" Then
                Dim objCollectInStoreUnAvailableLoop1 As Object
                For Each objCollectInStoreUnAvailableLoop1 In listItem.ChildNodes
                    If objCollectInStoreUnAvailableLoop1.nodeName = "#text" Then
                        vCollectionUnavailable = vCollectionUnavailable & objCollectInStoreUnAvailableLoop1.data & ". "
                    End If
                
                Next
                
            
            
                
            ElseIf listItem.getAttribute("data-availability") = "homeDeliveryUnavailable" Then
                Dim objHomeDeliveryUnavailableLoop As Object
                For Each objHomeDeliveryUnavailableLoop In listItem.ChildNodes
                    If objHomeDeliveryUnavailableLoop.nodeName = "#text" Then
                        vOutOfStock = vOutOfStock & objHomeDeliveryUnavailableLoop.data & ". "
                    End If
                
                Next
                
            Else
                Stop
            End If
            'Stop

        Else
            'If listItem.className = "available"
            Stop
        End If

    Next lLoop

    If Not IsEmpty(vHomeDeliveryAvailable) Then
        dicProductLoop.add "HomeDeliveryAvailable", Trim(vHomeDeliveryAvailable)
    End If

    If Not IsEmpty(vNoStock) Then
        dicProductLoop.add "noStock", Trim(vNoStock)
    End If

    If Not IsEmpty(vCollectionUnavailable) Then
        dicProductLoop.add "CollectionUnavailable", Trim(vCollectionUnavailable)
    End If
    
    If Not IsEmpty(vOutOfStock) Then
        dicProductLoop.add "OutOfStock", Trim(vOutOfStock)
    End If

    If Not IsEmpty(vCollectInStore) Then
        dicProductLoop.add "CollectInStore", Trim(vCollectInStore)
    End If

End Function

Private Function AddPrices(ByVal dicProductLoop As Scripting.Dictionary, _
            ByVal htmlDivMainAmount As MSHTML.HTMLDivElement)



    Dim oPrices As MSHTMLComparables
    Set oPrices = New MSHTMLComparables

    oPrices.BuildComparablesDict Array(Array("nodeName", "strong"), Array("class", "price"), Array("data-product", "price"))

    Dim sSnippet As String
    sSnippet = oPrices.FindSnippetInSource(htmlDivMainAmount.innerHTML)

    sSnippet = VBA.Replace(sSnippet, "£", "£")
    sSnippet = VBA.Replace(sSnippet, "£", "")

    Dim xmlMainPriceSnippet As Object
    Set xmlMainPriceSnippet = oPrices.SpawnXmlFragmentFromSrc(sSnippet)

    Dim vPriceNow As Variant
    vPriceNow = xmlMainPriceSnippet.nodeTypedValue
    
    Debug.Assert IsNumeric(vPriceNow)

    dicProductLoop.add "priceNow", CCur(vPriceNow)

    Dim xmlSpanPastAmount As MSHTML.HTMLSpanElement
    Dim xmlStrongSaving As MSHTML.HTMLPhraseElement

    Dim lChildLoop As Long
    For lChildLoop = 0 To htmlDivMainAmount.ChildNodes.Length - 1

        Dim objChildLoop As Object
        Set objChildLoop = htmlDivMainAmount.ChildNodes.Item(lChildLoop)

        If StrComp(objChildLoop.nodeName, "#text", vbTextCompare) = 0 Then
            '* empty text
        ElseIf StrComp(objChildLoop.nodeName, "strong", vbTextCompare) = 0 Then
            If objChildLoop.className = "saving" Then
                Set xmlStrongSaving = objChildLoop
            End If

        ElseIf StrComp(objChildLoop.nodeName, "span", vbTextCompare) = 0 Then
            Set xmlSpanPastAmount = objChildLoop
        End If

    Next lChildLoop

    If Not xmlSpanPastAmount Is Nothing Then
        If xmlSpanPastAmount.ChildNodes.Length > 0 Then
            dicProductLoop.add "pastAmount", xmlSpanPastAmount.FirstChild.innerText
        End If
    End If

    If Not xmlStrongSaving Is Nothing Then
        dicProductLoop.add "saving", xmlStrongSaving.innerText
    End If

End Function


Private Function AddRevoo(ByVal dicProductLoop As Scripting.Dictionary, _
            ByVal aProductDescLoop As MSHTML.HTMLAnchorElement)

    Static oRevooDiv As MSHTMLComparables
    If oRevooDiv Is Nothing Then
        Set oRevooDiv = New MSHTMLComparables
        oRevooDiv.BuildComparablesDict Array(Array("nodeName", "div"), Array("class", "reevoo-placeholder"))
    End If

    Dim sSnippet As String
    sSnippet = oRevooDiv.FindSnippetInSource(aProductDescLoop.outerHTML)

    Dim xmlRevooDiv As MSXML2.IXMLDOMElement
    Set xmlRevooDiv = oRevooDiv.SpawnXmlFragmentFromSrc(sSnippet)

    If Not xmlRevooDiv Is Nothing Then

        If xmlRevooDiv.LastChild.nodeTypedValue = "No reviews yet (0)" Then
            dicProductLoop.add "totalReviews", 0
        Else
            dicProductLoop.add "totalReviews", Application.Evaluate(xmlRevooDiv.LastChild.nodeTypedValue)

            Dim xmlClass As MSXML2.IXMLDOMAttribute
            Set xmlClass = xmlRevooDiv.FirstChild.Attributes.getNamedItem("class")

            Debug.Assert Left$(xmlClass.text, Len("reevoo-score score-")) = "reevoo-score score-"
            Dim vScore As Variant
            vScore = Mid$(xmlClass.text, Len("reevoo-score score-") + 1)
            Debug.Assert IsNumeric(vScore)
            dicProductLoop.add "revooScore", CDbl(vScore)
        End If

    End If



End Function

Private Function AddBrandAndName(ByVal dicProductLoop As Scripting.Dictionary, _
            ByVal aProductDescLoop As MSHTML.HTMLAnchorElement)

    Static oBrandSpan As MSHTMLComparables
    If oBrandSpan Is Nothing Then
        Set oBrandSpan = New MSHTMLComparables
        oBrandSpan.BuildComparablesDict Array(Array("nodeName", "span"), Array("data-product", "brand"))
    End If

    Dim xmlBrandSpan As MSXML2.IXMLDOMElement
    Set xmlBrandSpan = oBrandSpan.SpawnXmlFragmentFromSrc(oBrandSpan.FindSnippetInSource(aProductDescLoop.outerHTML))

    If Not xmlBrandSpan Is Nothing Then
        dicProductLoop.add "brand", xmlBrandSpan.nodeTypedValue
    End If

    Static oNameSpan As MSHTMLComparables
    If oNameSpan Is Nothing Then
        Set oNameSpan = New MSHTMLComparables
        oNameSpan.BuildComparablesDict Array(Array("nodeName", "span"), Array("data-product", "name"))
    End If

    Dim xmlNameSpan As MSXML2.IXMLDOMElement
    Set xmlNameSpan = oNameSpan.SpawnXmlFragmentFromSrc(oNameSpan.FindSnippetInSource(aProductDescLoop.outerHTML))

    If Not xmlNameSpan Is Nothing Then
        dicProductLoop.add "name", xmlNameSpan.nodeTypedValue
    End If


End Function

Private Function AddPictureDetails(ByVal dicProduct As Scripting.Dictionary, _
            ByVal divProductListImageLoop As MSHTML.HTMLDivElement)


    Dim anchor As MSHTML.HTMLAnchorElement
    Set anchor = divProductListImageLoop.FirstChild



    Dim divProductImages As MSHTML.HTMLDivElement
    Set divProductImages = anchor.FirstChild

    Dim objStyle As Object
    Set objStyle = divProductImages.getAttribute("style")

    dicProduct.add "imageCssText", objStyle.getAttribute("cssText")

    Dim imgProductImages As MSHTML.HTMLImg
    Set imgProductImages = divProductImages.FirstChild

    If imgProductImages.nodeName = "DIV" Then
        Set imgProductImages = imgProductImages.NextSibling
    End If

    Debug.Assert imgProductImages.className = "image"
    dicProduct.add "imageSrc", imgProductImages.getAttribute("src")
    dicProduct.add "imageAlt", imgProductImages.getAttribute("alt")
    Debug.Assert Len(imgProductImages.getAttribute("alt")) > 0

End Function


Sub TestSplitAttribsOnSpacesRespectingQuotes()

    Dim o As MSHTMLComparables
    Set o = New MSHTMLComparables

    Dim v
    v = o.SplitStringBySpaceRespectingQuotes("class=""bold italic underline"" id=""45""")
    Debug.Assert v(0) = "class=""bold italic underline"""
    Debug.Assert v(1) = "id=""45"""

End Sub

Sub FormatOvensRows()
    Dim wsOvens As Excel.Worksheet
    Set wsOvens = ThisWorkbook.Worksheets.Item("Ovens")
    wsOvens.Cells.ClearFormats

    Dim rowLoop As Excel.Range
    For Each rowLoop In wsOvens.UsedRange.rows
        If rowLoop.row > 1 Then
            DoEvents
            Dim lRow As Long
            lRow = rowLoop.row - 2
    
            Dim lRowMod50 As Long
            lRowMod50 = lRow Mod 50
    
            Dim lRowMod8 As Long
            lRowMod8 = lRowMod50 Mod 8
    
            Debug.Print lRowMod8
    
            If lRowMod8 \ 4 = 0 Then
                rowLoop.Interior.Color = rgbAliceBlue
    
            End If

        End If
    Next
    
    wsOvens.Names("Me").RefersToR1C1 = "=Ovens!RC"
    
    
    
    Dim rngDataRows As Excel.Range
    Set rngDataRows = wsOvens.UsedRange.Offset(1, 0).Resize(wsOvens.UsedRange.rows.Count - 1)
    
    Dim rngPriceNo As Excel.Range
    Set rngPriceNo = rngDataRows.columns("I")
    
    Dim lNoStockOffset As Long
    lNoStockOffset = Application.Evaluate("MATCH(""noStock"",1:1,0)-MATCH(""priceNow"",1:1,0)")
    
    Dim lOutOfStockOffset As Long
    lOutOfStockOffset = Application.Evaluate("MATCH(""outOfStock"",1:1,0)-MATCH(""priceNow"",1:1,0)")
    
    With rngPriceNo.FormatConditions.add( _
        Type:=xlExpression, _
        Formula1:="=(len(offset(me,0," & lNoStockOffset & "))+len(offset(me,0," & lOutOfStockOffset & ")))>0")
        
        .Font.Color = rgbGrey
    
    End With
    

    With rngPriceNo.Font
        .Color = -16776961
        .TintAndShade = 0
        .name = "Calibri"
        .FontStyle = "Bold"
        .size = 11
        .Strikethrough = False
        .Superscript = False
        .Subscript = False
        .OutlineFont = False
        .Shadow = False
        .Underline = xlUnderlineStyleNone
        .Color = -16776961
        .TintAndShade = 0
        .ThemeFont = xlThemeFontMinor
    End With
    
    rngPriceNo.NumberFormat = "$#,##0.00"


    With rngDataRows.columns("J").Font
        .name = "Calibri"
        .FontStyle = "Regular"
        .size = 11
        .Strikethrough = False
        .Superscript = False
        .Subscript = False
        .OutlineFont = False
        .Shadow = False
        .Underline = xlUnderlineStyleNone
        .ThemeColor = xlThemeColorDark1
        .TintAndShade = -0.349986267
        .ThemeFont = xlThemeFontMinor
    End With


    With rngDataRows.columns("K").Font
        .name = "Calibri"
        .FontStyle = "Bold"
        .size = 11
        .Strikethrough = False
        .Superscript = False
        .Subscript = False
        .OutlineFont = False
        .Shadow = False
        .Underline = xlUnderlineStyleNone
        .ThemeColor = xlThemeColorLight1
        .TintAndShade = 0
        .ThemeFont = xlThemeFontMinor
    End With


End Sub




So here is a screenshot of the output in Excel worksheet

And here is screenshot of part of the original web page