mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-08-23 19:25:26 +00:00
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.
145 lines
3.9 KiB
Go
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...)
|
|
}
|