Files
tiennm99 b04f9844f9 refactor(crawler): read the file lists from the source articles
Both sources carried their download links as a hardcoded array, which is not a
crawl: the lists could drift from what the articles actually published, and
nothing would say so. A source now names the article and how to name what it
finds there, and internal/article reads the links out of that page at run time.

2016 takes its filenames straight from the URL. 2017 cannot — the CDN names are
inconsistent (Angiang.xls, 1BaRiaVungTau.xls, 23HaiPhong.xls) — so it derives
them from the province in the link text, transliterated to ASCII the same way
go-parser builds ho_ten_ascii.

Filenames stay load-bearing: go-parser sorts inputs bytewise and inserts
last-wins, so they decide which row survives a duplicate exam number. Saved
copies of both articles are committed as fixtures, and a test asserts that
reading them and applying each naming rule reproduces data/<id> exactly, in both
directions. Resolve also rejects a page that yields the wrong number of links or
two links that would write the same file, since either silently costs the
dataset files that only the row-count guard would notice afterwards.

Verified against the live 2017 article: a from-scratch crawl of all 63 files
leaves the committed data unchanged.
2026-08-13 22:05:07 +07:00

145 lines
3.9 KiB
Go

// Package article reads download links out of a published web page.
//
// This is what makes the crawler a crawler: the file lists are not carried in
// the repository, they are read from the articles that published them, at run
// time. A source declares which page to read and how to name what it finds.
package article
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"golang.org/x/net/html"
)
// Link is one downloadable file found on a page.
type Link struct {
// URL is absolute, resolved against the page it was found on.
URL string
// Text is the anchor's visible text, whitespace-collapsed. Some pages use
// it for the thing being linked ("Bạc Liêu"), others for boilerplate
// ("xem TẠI ĐÂY"), so a source decides whether it is worth anything.
Text string
// File is the last segment of the URL path, query string excluded.
File string
}
// Extract returns every anchor on the page whose target ends in one of exts.
//
// Pure: no network, so the parsing rules can be tested against a saved copy of
// a real page. Order is document order, and duplicates are kept — deciding
// whether two links to the same file is a problem belongs to the caller, which
// knows what it would name them.
func Extract(pageURL string, body io.Reader, exts ...string) ([]Link, error) {
base, err := url.Parse(pageURL)
if err != nil {
return nil, fmt.Errorf("bad page URL %q: %w", pageURL, err)
}
doc, err := html.Parse(body)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", pageURL, err)
}
var out []Link
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
if link, ok := linkOf(base, n, exts); ok {
out = append(out, link)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return out, nil
}
// linkOf turns one <a> element into a Link, or reports that it is not one of
// the files being looked for.
func linkOf(base *url.URL, n *html.Node, exts []string) (Link, bool) {
var href string
for _, a := range n.Attr {
if strings.EqualFold(a.Key, "href") {
href = strings.TrimSpace(a.Val)
break
}
}
if href == "" {
return Link{}, false
}
// Relative hrefs are the norm on the pages this reads; resolving against
// the page URL is what lets a source name a mirror and get its files.
ref, err := url.Parse(href)
if err != nil {
return Link{}, false
}
abs := base.ResolveReference(ref)
file := path.Base(abs.Path)
if !hasExt(file, exts) {
return Link{}, false
}
return Link{URL: abs.String(), Text: textOf(n), File: file}, true
}
func hasExt(name string, exts []string) bool {
lower := strings.ToLower(name)
for _, e := range exts {
if strings.HasSuffix(lower, strings.ToLower(e)) {
return true
}
}
return false
}
// textOf collects an element's visible text, collapsing whitespace. Anchors on
// these pages wrap the label in styling tags, so the text is rarely a single
// child node.
func textOf(n *html.Node) string {
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(n.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.Join(strings.Fields(b.String()), " ")
}
// Fetch downloads a page and runs Extract on it.
func Fetch(ctx context.Context, client *http.Client, pageURL string, headers map[string]string, exts ...string) ([]Link, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", pageURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch %s: HTTP %d", pageURL, resp.StatusCode)
}
return Extract(pageURL, resp.Body, exts...)
}