internal/eusupply replaces the old Node scraper (eu-supply.js) for uk.eu-supply.com. The site has no API, so it posts the public search form, which now needs the form's current defaults and reads the HTML with golang.org/x/net/html. Each tender not stored yet has its own page read too, for its description, contract type, CPV codes and buyer address. Tenders are stored with the source "EU Supply - UK" as eusupply-uk-<tender id>, with one release holding the tender as read. The site publishes no rate limit, so the client sets its own: one request at a time, with a random wait between sources.eu_supply.request_min_gap and request_max_gap (2s to 10s). The source has the same 7-day backfill as Find a Tender's. Most of its tenders are also on Find a Tender, so a new one is stored Hidden and Skipped when another source's opportunity released within 30 days has a title at least 90% alike, and its buyer or description matches too (80% of the shorter one's significant words).
140 lines
3.7 KiB
Go
140 lines
3.7 KiB
Go
package eusupply
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// The helpers below read the parsed pages. The site's HTML is server
|
|
// rendered, so the elements are found by their ids and classes, as the old
|
|
// Node scraper found them with CSS selectors.
|
|
|
|
// findAll returns the elements under node (node included) that match, in
|
|
// document order.
|
|
func findAll(node *html.Node, match func(*html.Node) bool) []*html.Node {
|
|
var found []*html.Node
|
|
var walk func(*html.Node)
|
|
walk = func(current *html.Node) {
|
|
if current.Type == html.ElementNode && match(current) {
|
|
found = append(found, current)
|
|
}
|
|
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(node)
|
|
return found
|
|
}
|
|
|
|
// findFirst returns the first element under node that matches, or nil.
|
|
func findFirst(node *html.Node, match func(*html.Node) bool) *html.Node {
|
|
found := findAll(node, match)
|
|
if len(found) == 0 {
|
|
return nil
|
|
}
|
|
return found[0]
|
|
}
|
|
|
|
// isTag matches elements with the tag name.
|
|
func isTag(tag string) func(*html.Node) bool {
|
|
return func(node *html.Node) bool {
|
|
return node.Data == tag
|
|
}
|
|
}
|
|
|
|
// hasID matches the element with the id.
|
|
func hasID(id string) func(*html.Node) bool {
|
|
return func(node *html.Node) bool {
|
|
return attr(node, "id") == id
|
|
}
|
|
}
|
|
|
|
// hasClass matches elements with tag (any, if empty) and the class among
|
|
// their classes.
|
|
func hasClass(tag, class string) func(*html.Node) bool {
|
|
return func(node *html.Node) bool {
|
|
if tag != "" && node.Data != tag {
|
|
return false
|
|
}
|
|
return slices.Contains(strings.Fields(attr(node, "class")), class)
|
|
}
|
|
}
|
|
|
|
// children returns node's element children with the tag name.
|
|
func children(node *html.Node, tag string) []*html.Node {
|
|
var found []*html.Node
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if child.Type == html.ElementNode && child.Data == tag {
|
|
found = append(found, child)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// nextElement returns the element after node among its siblings, or nil.
|
|
func nextElement(node *html.Node) *html.Node {
|
|
sibling := node.NextSibling
|
|
for sibling != nil && sibling.Type != html.ElementNode {
|
|
sibling = sibling.NextSibling
|
|
}
|
|
return sibling
|
|
}
|
|
|
|
// attr returns the value of node's attribute key, or "".
|
|
func attr(node *html.Node, key string) string {
|
|
for _, attribute := range node.Attr {
|
|
if attribute.Key == key {
|
|
return attribute.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// rawText returns the text under node as written, with a newline for each
|
|
// <br>.
|
|
func rawText(node *html.Node) string {
|
|
var text strings.Builder
|
|
var walk func(*html.Node)
|
|
walk = func(current *html.Node) {
|
|
switch {
|
|
case current.Type == html.TextNode:
|
|
text.WriteString(current.Data)
|
|
case current.Type == html.ElementNode && current.Data == "br":
|
|
text.WriteString("\n")
|
|
}
|
|
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
|
walk(child)
|
|
}
|
|
}
|
|
walk(node)
|
|
return text.String()
|
|
}
|
|
|
|
// text returns the text under node with its white space collapsed: runs of
|
|
// spaces, non-breaking spaces and line breaks become one space, and the ends
|
|
// are trimmed.
|
|
func text(node *html.Node) string {
|
|
return collapseSpace(rawText(node))
|
|
}
|
|
|
|
// lines returns the non-empty lines of the text under node, each with its
|
|
// white space collapsed.
|
|
func lines(node *html.Node) []string {
|
|
var found []string
|
|
for line := range strings.SplitSeq(rawText(node), "\n") {
|
|
if collapsed := collapseSpace(line); collapsed != "" {
|
|
found = append(found, collapsed)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// collapseSpace collapses runs of white space (non-breaking spaces and zero
|
|
// width spaces included) into one space, and trims the ends.
|
|
func collapseSpace(value string) string {
|
|
value = strings.ReplaceAll(value, "\u200b", "")
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|