1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
|
package liturgy
import (
"fmt"
"html"
"regexp"
"strings"
)
// lectionaryTabs lists the two reading sets carried on the page: the
// lectionary in force since Advent 2015 ("nowy") and the one it replaced
// ("stary"). They are not interchangeable -- the acclamation can cite a
// different book entirely, and the older set contains malformed citations.
// Pick by id, never by position in the markup, or a reordering silently
// switches edition.
var lectionaryTabs = []string{"tabnowy0all", "tabstary0all"}
var (
brRe = regexp.MustCompile(`(?i)<br\s*/?>`)
tagRe = regexp.MustCompile(`<[^>]+>`)
wsRe = regexp.MustCompile(`[ \t]+`)
h2Re = regexp.MustCompile(`(?s)<h2>(.*?)</h2>`)
h4Re = regexp.MustCompile(`(?s)<h4>(.*?)</h4>`)
pRe = regexp.MustCompile(`(?s)<p>(.*?)</p>`)
citationRe = regexp.MustCompile(`\((.+)\)\s*$`)
// dayNamePRe matches every classed <p><em>...</em></p> on the page; only
// the one whose class carries both "fw-bold" and a "color-" role (see
// dayNameParaMatches) is the day's celebration name -- the page also
// carries a plain fw-bold (no color-) lookalike higher up that must not
// win instead.
dayNamePRe = regexp.MustCompile(`(?s)<p class="([^"]*)">\s*<em>(.*?)</em>\s*</p>`)
// dayColourRe matches niedziela.pl's "Kolor szat: <word>" vestment-colour
// line, tolerating the <span>/<strong> markup wrapped around the colour
// word on the page (see internal/liturgy/testdata/2026-07-22.html).
dayColourRe = regexp.MustCompile(`Kolor szat:\s*(?:<[^>]+>\s*)*([\p{L}]+)`)
)
// modernColours maps niedziela.pl's Polish vestment-colour words to
// DayInfo's normalized colour names; anything not listed here (including a
// multi-option line like "zielony albo biały albo czerwony", which matches
// only its first word) is left for the caller to treat as "" if absent.
var modernColours = map[string]string{
"biały": "white",
"zielony": "green",
"fioletowy": "violet",
"czerwony": "red",
"różowy": "rose",
}
// panePattern matches the opening tag of the tab-pane div carrying the given
// tab id, e.g. `<div class="tab-pane fade " id="tabnowy0all">`.
func panePattern(tab string) *regexp.Regexp {
return regexp.MustCompile(`<div class="tab-pane[^"]*"\s+id="` + regexp.QuoteMeta(tab) + `">`)
}
// htmlToLines turns an HTML fragment into a list of non-empty text lines.
// <br> marks a verse line break (used in psalms/acclamations); other tags
// are dropped, entities decoded, and intra-line whitespace collapsed.
func htmlToLines(fragment string) []string {
fragment = brRe.ReplaceAllString(fragment, "\n")
fragment = tagRe.ReplaceAllString(fragment, "")
text := html.UnescapeString(fragment)
var lines []string
for _, ln := range strings.Split(text, "\n") {
ln = strings.TrimSpace(wsRe.ReplaceAllString(ln, " "))
if ln != "" {
lines = append(lines, ln)
}
}
return lines
}
// Parse extracts every reading section from the page's preferred lectionary
// tab (falling back to the superseded one), erroring loudly if neither tab is
// present on the page, or the tab is found but carries no sections -- a
// layout change should never be mistaken for a quiet day with no readings.
func Parse(pageHTML string) ([]Section, error) {
var loc []int
for _, tab := range lectionaryTabs {
if m := panePattern(tab).FindStringIndex(pageHTML); m != nil {
loc = m
break
}
}
if loc == nil {
if strings.Contains(pageHTML, "Przykro nam") {
return nil, fmt.Errorf("no reading published for this date yet")
}
return nil, fmt.Errorf(
"no reading tab (%s) found on page -- the site layout may have changed",
strings.Join(lectionaryTabs, "/"),
)
}
rest := pageHTML[loc[1]:]
block := rest
if nxt := strings.Index(rest, `<div class="tab-pane`); nxt != -1 {
block = rest[:nxt]
}
heads := h2Re.FindAllStringSubmatchIndex(block, -1)
var sections []Section
czytanieCount := 0
for i, h := range heads {
bodyEnd := len(block)
if i+1 < len(heads) {
bodyEnd = heads[i+1][0]
}
body := block[h[1]:bodyEnd]
heading := strings.Join(htmlToLines(block[h[2]:h[3]]), " ")
subtitle := ""
if sub := h4Re.FindStringSubmatch(body); sub != nil {
subtitle = strings.Join(htmlToLines(sub[1]), " ")
}
var paragraphs [][]string
for _, p := range pRe.FindAllStringSubmatch(body, -1) {
if lines := htmlToLines(p[1]); len(lines) > 0 {
paragraphs = append(paragraphs, lines)
}
}
// A heading is expected to always carry a parenthetical citation;
// if one is somehow missing, leave Citation empty rather than
// failing the whole parse over one section.
citation, _ := ExtractCitation(heading)
sections = append(sections, Section{
Heading: heading,
Subtitle: subtitle,
Citation: citation,
PartID: partID(heading, &czytanieCount),
Paragraphs: paragraphs,
})
}
// A layout change can leave the tab findable but empty; say so rather
// than returning nothing and looking like a quiet day.
if len(sections) == 0 {
return nil, fmt.Errorf("reading tab found but no sections in it -- the site layout may have changed")
}
return sections, nil
}
// ParseDayInfo extracts the day's celebration name and liturgical colour
// from a niedziela.pl page: Name is the inner text of the <p class="...
// fw-bold color-XXX"><em>NAME</em></p> paragraph (there is also an earlier,
// plain fw-bold-but-no-color- lookalike on the page -- see dayNamePRe --
// which must not match instead), and Colour comes from the page's "Kolor
// szat: <word>" line, mapped via modernColours (case-insensitive; unknown
// word -> ""). Season is always "" -- the modern lectionary folds its
// temporal context into Name on temporal days rather than carrying it
// separately. A page whose markup doesn't match either pattern (a layout
// change, or a fixture with neither) yields a zero DayInfo, never an error:
// the readings are the load-bearing content, the header is a nice-to-have.
func ParseDayInfo(pageHTML string) DayInfo {
var info DayInfo
for _, m := range dayNamePRe.FindAllStringSubmatch(pageHTML, -1) {
class := m[1]
if strings.Contains(class, "fw-bold") && strings.Contains(class, "color-") {
info.Name = strings.Join(htmlToLines(m[2]), " ")
break
}
}
if m := dayColourRe.FindStringSubmatch(pageHTML); m != nil {
info.Colour = modernColours[strings.ToLower(m[1])]
}
return info
}
// partID assigns the stable liturgical-part identifier for a section heading.
// A second "1. czytanie" heading on the same day (a split feast offering two
// alternative first readings) becomes "drugie_czytanie" instead of colliding
// with the first ("pierwsze_czytanie"). Anything unrecognised is "".
func partID(heading string, czytanieCount *int) string {
switch {
case strings.HasPrefix(heading, "1. czytanie"):
*czytanieCount++
if *czytanieCount == 1 {
return "pierwsze_czytanie"
}
return "drugie_czytanie"
case strings.HasPrefix(heading, "Psalm"):
return "psalm"
case strings.HasPrefix(heading, "Aklamacja"):
return "aklamacja"
case strings.HasPrefix(heading, "Ewangelia"):
return "ewangelia"
default:
return ""
}
}
// ExtractCitation pulls the citation from a section heading:
// "Ewangelia (Mt 7, 1-5)" -> "Mt 7, 1-5".
func ExtractCitation(heading string) (string, error) {
m := citationRe.FindStringSubmatch(heading)
if m == nil {
return "", fmt.Errorf("no reference found in heading: %q", heading)
}
return strings.TrimSpace(m[1]), nil
}
|