Turn the raw pages stored by Phase 2 into rows in the SQLite items table (AGENTS.md §11 Phase 3, FR-3/FR-4/FR-5/FR-12). - internal/parse: dependency-free HTML reader plus extractors for the ExtJS learning-task grid (subject, title, category, kind, due date, status), the home news feed (author, posted time, title, body) and the calendar week grid (day column + grid offset give the time; label gives title/room/teacher). - internal/parse: fail loudly. A challenge page, login page, changed layout or unknown due-date format is an explicit error, never an empty day. - FR-4: structure is parsed deterministically; importance is classified by an OmniRoute model (default openrouter/google/gemini-3.5-flash-lite) with a deterministic fallback. The classifier used is printed and recorded. - internal/llm: small OpenAI-compatible OmniRoute client (handles SSE replies). - internal/store: migration 002 adds source_page, importance and external_ref with a unique (child_id, source, type, external_ref) index; UpsertItems, CountItemsByType, SampleItems and ChildByName. - cmd/school parse: reads var/raw/<child>/<run>, upserts items, records the scan row (ok=0 plus the error on failure) and prints counts and samples. - scripts/parse-compass.sh and make parse run it in a one-off container against the service's own volume without restarting the service. - docs/phase-3-parse-notes.md records the approach, the FR-4 decision and the known gaps. Evidence on the live database: 56 rows for Harry (20 task, 1 message, 35 event). Re-running the same raw pages inserts 0 and updates 56. Co-Authored-By: Paperclip <noreply@paperclip.ing>
324 lines
7.7 KiB
Go
324 lines
7.7 KiB
Go
// Package parse turns stored Compass pages into structured items (AGENTS.md
|
|
// §4 FR-3/FR-4/FR-5, §11 Phase 3). It is a deterministic, dependency-free HTML
|
|
// reader: the Compass pages are machine-generated, so parsing the markup is more
|
|
// reliable than asking a model to transcribe a table.
|
|
package parse
|
|
|
|
import (
|
|
"bytes"
|
|
"html"
|
|
"strings"
|
|
)
|
|
|
|
// voidElements never have children and are therefore never pushed on the stack.
|
|
var voidElements = map[string]bool{
|
|
"area": true, "base": true, "br": true, "col": true, "embed": true,
|
|
"hr": true, "img": true, "input": true, "link": true, "meta": true,
|
|
"param": true, "source": true, "track": true, "wbr": true,
|
|
}
|
|
|
|
// rawTextElements have text content that must not be tokenised as markup.
|
|
var rawTextElements = map[string]bool{
|
|
"script": true, "style": true, "textarea": true,
|
|
}
|
|
|
|
// node is one element in the parsed document. Text content is stored as
|
|
// "#text" children so that text and elements keep their document order; use
|
|
// Text for the concatenated descendant text.
|
|
type node struct {
|
|
Tag string
|
|
Attrs map[string]string
|
|
Kids []*node
|
|
Parent *node
|
|
text string
|
|
}
|
|
|
|
// Class returns the element's class attribute.
|
|
func (n *node) Class() string { return n.Attrs["class"] }
|
|
|
|
// hasClass reports whether the element carries the exact class.
|
|
func (n *node) hasClass(cls string) bool {
|
|
for _, f := range strings.Fields(n.Class()) {
|
|
if f == cls {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasClassPart reports whether any class token contains part.
|
|
func (n *node) hasClassPart(part string) bool {
|
|
for _, f := range strings.Fields(n.Class()) {
|
|
if strings.Contains(f, part) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasAttr reports whether the attribute is present with a non-empty value.
|
|
func (n *node) hasAttr(key string) bool { return strings.TrimSpace(n.Attrs[key]) != "" }
|
|
|
|
// Text returns the concatenated text of the element and all descendants,
|
|
// whitespace-collapsed.
|
|
func (n *node) Text() string {
|
|
var b strings.Builder
|
|
n.writeText(&b)
|
|
return collapse(b.String())
|
|
}
|
|
|
|
func (n *node) writeText(b *strings.Builder) {
|
|
b.WriteString(n.text)
|
|
for _, k := range n.Kids {
|
|
k.writeText(b)
|
|
}
|
|
}
|
|
func collapse(s string) string { return strings.Join(strings.Fields(s), " ") }
|
|
|
|
// textUntil returns the concatenated text of the node's descendants up to and
|
|
// including the first matching direct-or-descendant element, exclusive of that
|
|
// element's subtree.
|
|
func (n *node) textUntil(pred func(*node) bool) string {
|
|
var b strings.Builder
|
|
var w func(x *node) bool
|
|
w = func(x *node) bool {
|
|
if pred(x) {
|
|
return false
|
|
}
|
|
b.WriteString(x.text)
|
|
for _, k := range x.Kids {
|
|
if !w(k) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
w(n)
|
|
return collapse(b.String())
|
|
}
|
|
|
|
// parseHTML tokenises and nests the byte slice. It never fails: malformed
|
|
// markup, stray tags and unclosed elements are tolerated, because ExtJS emits
|
|
// group headers whose <td> is not closed before the next table starts.
|
|
func parseHTML(data []byte) *node {
|
|
root := &node{Tag: "#root", Attrs: map[string]string{}}
|
|
stack := []*node{root}
|
|
n := len(data)
|
|
i := 0
|
|
|
|
top := func() *node { return stack[len(stack)-1] }
|
|
|
|
for i < n {
|
|
if data[i] != '<' {
|
|
j := bytes.IndexByte(data[i:], '<')
|
|
if j < 0 {
|
|
j = n - i
|
|
}
|
|
appendText(top(), string(data[i:i+j]))
|
|
i += j
|
|
continue
|
|
}
|
|
|
|
switch {
|
|
case bytes.HasPrefix(data[i:], []byte("<!--")):
|
|
end := bytes.Index(data[i:], []byte("-->"))
|
|
if end < 0 {
|
|
i = n
|
|
continue
|
|
}
|
|
i += end + 3
|
|
case bytes.HasPrefix(data[i:], []byte("<!")) || bytes.HasPrefix(data[i:], []byte("<?")):
|
|
end := bytes.IndexByte(data[i:], '>')
|
|
if end < 0 {
|
|
i = n
|
|
continue
|
|
}
|
|
i += end + 1
|
|
case bytes.HasPrefix(data[i:], []byte("</")):
|
|
k := i + 2
|
|
start := k
|
|
for k < n && isNameChar(data[k]) {
|
|
k++
|
|
}
|
|
name := strings.ToLower(string(data[start:k]))
|
|
gt := bytes.IndexByte(data[k:], '>')
|
|
if gt < 0 {
|
|
i = n
|
|
continue
|
|
}
|
|
i = k + gt + 1
|
|
stack = closeTag(stack, name)
|
|
default:
|
|
k := i + 1
|
|
start := k
|
|
for k < n && isNameChar(data[k]) {
|
|
k++
|
|
}
|
|
name := strings.ToLower(string(data[start:k]))
|
|
if name == "" { // a bare '<'
|
|
appendText(top(), "<")
|
|
i++
|
|
continue
|
|
}
|
|
attrs, selfClose, next := parseAttrs(data, k)
|
|
i = next
|
|
|
|
child := &node{Tag: name, Attrs: attrs, Parent: top()}
|
|
top().Kids = append(top().Kids, child)
|
|
|
|
if selfClose || voidElements[name] {
|
|
continue
|
|
}
|
|
stack = append(stack, child)
|
|
|
|
if rawTextElements[name] {
|
|
// Skip the content; scripts and styles are not parsed as markup.
|
|
if idx := indexFold(data[i:], "</"+name); idx >= 0 {
|
|
i += idx
|
|
} else {
|
|
i = n
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
// parseAttrs reads tag attributes starting at p (just past the tag name). It
|
|
// returns the attributes, whether the tag closed with "/>", and the index just
|
|
// past the closing '>'.
|
|
func parseAttrs(data []byte, p int) (map[string]string, bool, int) {
|
|
attrs := map[string]string{}
|
|
selfClose := false
|
|
n := len(data)
|
|
for p < n {
|
|
for p < n && isSpace(data[p]) {
|
|
p++
|
|
}
|
|
if p >= n {
|
|
break
|
|
}
|
|
if data[p] == '>' {
|
|
p++
|
|
break
|
|
}
|
|
if data[p] == '/' {
|
|
selfClose = true
|
|
p++
|
|
continue
|
|
}
|
|
start := p
|
|
for p < n && !isSpace(data[p]) && data[p] != '=' && data[p] != '>' && data[p] != '/' {
|
|
p++
|
|
}
|
|
name := strings.ToLower(string(data[start:p]))
|
|
q := p
|
|
for q < n && isSpace(data[q]) {
|
|
q++
|
|
}
|
|
var value string
|
|
if q < n && data[q] == '=' {
|
|
q++
|
|
for q < n && isSpace(data[q]) {
|
|
q++
|
|
}
|
|
if q < n && (data[q] == '"' || data[q] == '\'') {
|
|
quote := data[q]
|
|
q++
|
|
v := q
|
|
for q < n && data[q] != quote {
|
|
q++
|
|
}
|
|
value = string(data[v:q])
|
|
if q < n {
|
|
q++
|
|
}
|
|
} else {
|
|
v := q
|
|
for q < n && !isSpace(data[q]) && data[q] != '>' {
|
|
q++
|
|
}
|
|
value = string(data[v:q])
|
|
}
|
|
p = q
|
|
}
|
|
if name != "" {
|
|
attrs[name] = html.UnescapeString(value)
|
|
}
|
|
}
|
|
return attrs, selfClose, p
|
|
}
|
|
|
|
// appendText appends an entity-decoded text node to the parent, keeping its
|
|
// position in document order.
|
|
func appendText(n *node, s string) {
|
|
if s == "" {
|
|
return
|
|
}
|
|
n.Kids = append(n.Kids, &node{Tag: "#text", text: html.UnescapeString(s), Parent: n})
|
|
}
|
|
|
|
// closeTag pops elements up to and including the nearest matching tag and
|
|
// returns the remaining stack.
|
|
func closeTag(stack []*node, name string) []*node {
|
|
for i := len(stack) - 1; i > 0; i-- {
|
|
if stack[i].Tag == name {
|
|
return stack[:i]
|
|
}
|
|
}
|
|
return stack
|
|
}
|
|
|
|
func isNameChar(c byte) bool {
|
|
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
|
|
c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':' || c == '.'
|
|
}
|
|
|
|
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' }
|
|
|
|
// indexFold finds needle in haystack, ignoring ASCII case.
|
|
func indexFold(haystack []byte, needle string) int {
|
|
if len(needle) == 0 {
|
|
return 0
|
|
}
|
|
lower := bytes.ToLower(haystack)
|
|
return bytes.Index(lower, []byte(strings.ToLower(needle)))
|
|
}
|
|
|
|
// walk visits n and its descendants in document order. When fn returns false the
|
|
// node's children are skipped; siblings are still visited. To abort a whole
|
|
// traversal, the predicate must track its own stop flag.
|
|
func walk(n *node, fn func(*node) bool) {
|
|
if !fn(n) {
|
|
return
|
|
}
|
|
for _, k := range n.Kids {
|
|
walk(k, fn)
|
|
}
|
|
}
|
|
|
|
// find returns the first descendant (including n) matching pred.
|
|
func find(n *node, pred func(*node) bool) *node {
|
|
if pred(n) {
|
|
return n
|
|
}
|
|
for _, k := range n.Kids {
|
|
if got := find(k, pred); got != nil {
|
|
return got
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findAll returns every descendant (including n) matching pred, in document order.
|
|
func findAll(n *node, pred func(*node) bool) []*node {
|
|
var out []*node
|
|
walk(n, func(x *node) bool {
|
|
if pred(x) {
|
|
out = append(out, x)
|
|
}
|
|
return true
|
|
})
|
|
return out
|
|
}
|