1
0

clean.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. // Package clean turns extracted article markup into safe, well-formed XHTML
  2. // for an EPUB chapter.
  3. //
  4. // Prepare runs on the whole page before extraction; Content runs on the
  5. // extracted article, and Render serializes the result once images have been
  6. // processed.
  7. package clean
  8. import (
  9. "encoding/xml"
  10. "errors"
  11. "fmt"
  12. "io"
  13. "net/url"
  14. "regexp"
  15. "strings"
  16. "github.com/microcosm-cc/bluemonday"
  17. "golang.org/x/net/html"
  18. "golang.org/x/net/html/atom"
  19. )
  20. // policy is the allow-list from PLAN.md §5.3. Anything not listed is
  21. // removed; the text inside removed elements is kept, except for elements
  22. // such as script and style whose content is dropped too.
  23. var policy = func() *bluemonday.Policy {
  24. p := bluemonday.NewPolicy()
  25. p.AllowElements(
  26. "p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "code",
  27. "em", "strong", "b", "i", "u", "s", "sub", "sup", "small", "abbr", "cite", "q",
  28. "ul", "ol", "li", "dl", "dt", "dd",
  29. "table", "thead", "tbody", "tfoot", "tr", "th", "td",
  30. "hr", "br", "span", "div", "aside", "figure", "figcaption",
  31. )
  32. p.AllowAttrs("href").OnElements("a")
  33. p.AllowURLSchemes("http", "https", "mailto")
  34. p.AllowRelativeURLs(true) // made absolute by resolveLinks
  35. p.RequireParseableURLs(true)
  36. p.AllowAttrs("src", "alt").OnElements("img")
  37. p.AllowDataURIImages()
  38. p.AllowAttrs("title").OnElements("abbr")
  39. p.AllowAttrs("colspan", "rowspan").Matching(regexp.MustCompile(`^[1-9][0-9]{0,2}$`)).OnElements("th", "td")
  40. p.AllowAttrs("lang").Matching(regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{1,8})*$`)).Globally()
  41. p.AllowAttrs("dir").Matching(regexp.MustCompile(`^(ltr|rtl|auto)$`)).Globally()
  42. return p
  43. }()
  44. // Content sanitizes the children of n, the extracted article, and returns
  45. // them under a new <body> node. Relative links are resolved against base,
  46. // headings are shifted so the article's top level is <h2> below the chapter
  47. // title, and standalone images are wrapped in <figure>s with their
  48. // captions.
  49. func Content(n *html.Node, base *url.URL) (*html.Node, error) {
  50. restoreImageSources(n)
  51. var raw strings.Builder
  52. for c := n.FirstChild; c != nil; c = c.NextSibling {
  53. if err := html.Render(&raw, c); err != nil {
  54. return nil, err
  55. }
  56. }
  57. safe := policy.Sanitize(raw.String())
  58. // Re-parse the sanitized markup so the HTML parser repairs any invalid
  59. // nesting, then edit the tree.
  60. body := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}
  61. nodes, err := html.ParseFragment(strings.NewReader(safe), body)
  62. if err != nil {
  63. return nil, err
  64. }
  65. for _, c := range nodes {
  66. body.AppendChild(c)
  67. }
  68. resolveLinks(body, base)
  69. restoreCodeBlocks(body)
  70. buildFigures(body)
  71. shiftHeadings(body)
  72. removeEmpty(body)
  73. return body, nil
  74. }
  75. // Render serializes the children of body as an XHTML fragment. It fails
  76. // rather than return markup that isn't well-formed, because e-readers
  77. // refuse to open such books.
  78. func Render(body *html.Node) (string, error) {
  79. var out strings.Builder
  80. for c := body.FirstChild; c != nil; c = c.NextSibling {
  81. if err := html.Render(&out, c); err != nil {
  82. return "", err
  83. }
  84. }
  85. if err := WellFormed("<div>" + out.String() + "</div>"); err != nil {
  86. return "", fmt.Errorf("cleaned content is not well-formed XHTML: %w", err)
  87. }
  88. return out.String(), nil
  89. }
  90. // mangledScheme matches a mailto: or tel: link that go-trafilatura has
  91. // wrongly treated as relative and joined onto the page path, as in
  92. // "https://example.com/post/mailto:a@example.com".
  93. var mangledScheme = regexp.MustCompile(`/((?:mailto|tel):.*)$`)
  94. // resolveLinks makes every link href and image src absolute.
  95. func resolveLinks(n *html.Node, base *url.URL) {
  96. walk(n, func(n *html.Node) {
  97. key := "href"
  98. switch n.DataAtom {
  99. case atom.A:
  100. case atom.Img:
  101. key = "src"
  102. default:
  103. return
  104. }
  105. for i, a := range n.Attr {
  106. if a.Key != key {
  107. continue
  108. }
  109. href := a.Val
  110. if m := mangledScheme.FindStringSubmatch(href); m != nil && key == "href" {
  111. href = m[1]
  112. }
  113. if u, err := base.Parse(href); err == nil {
  114. n.Attr[i].Val = u.String()
  115. }
  116. }
  117. })
  118. }
  119. // blockParents are elements in which a <pre> may appear.
  120. var blockParents = map[atom.Atom]bool{
  121. atom.Body: true, atom.Div: true, atom.Blockquote: true, atom.Li: true, atom.Dd: true,
  122. atom.Td: true, atom.Th: true, atom.Figure: true, atom.Aside: true,
  123. }
  124. // restoreCodeBlocks turns go-trafilatura's block-level <code> elements back
  125. // into <pre><code>, so code keeps its line breaks and indentation. A <code>
  126. // counts as a block when it sits directly in a block container and holds
  127. // a line break or another <code>; inline code is left alone.
  128. func restoreCodeBlocks(n *html.Node) {
  129. var blocks []*html.Node
  130. walk(n, func(n *html.Node) {
  131. if n.DataAtom == atom.Code && n.Parent != nil && blockParents[n.Parent.DataAtom] &&
  132. (strings.Contains(text(n), "\n") || contains(n, atom.Code)) {
  133. blocks = append(blocks, n)
  134. }
  135. })
  136. for _, c := range blocks {
  137. c.DataAtom, c.Data = atom.Pre, "pre"
  138. // Unwrap nested <code> elements, then put a single one inside.
  139. inner := &html.Node{Type: html.ElementNode, Data: "code", DataAtom: atom.Code}
  140. for child := c.FirstChild; child != nil; child = c.FirstChild {
  141. c.RemoveChild(child)
  142. if child.DataAtom == atom.Code {
  143. for gc := child.FirstChild; gc != nil; gc = child.FirstChild {
  144. child.RemoveChild(gc)
  145. inner.AppendChild(gc)
  146. }
  147. continue
  148. }
  149. inner.AppendChild(child)
  150. }
  151. c.AppendChild(inner)
  152. }
  153. }
  154. var headingLevels = map[atom.Atom]int{atom.H1: 1, atom.H2: 2, atom.H3: 3, atom.H4: 4, atom.H5: 5, atom.H6: 6}
  155. var headingAtoms = []atom.Atom{0, atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6}
  156. // shiftHeadings moves all headings down by the same amount so that none is
  157. // an <h1>, which is reserved for the chapter title. Relative levels are
  158. // kept; anything pushed past <h6> stays at <h6>.
  159. func shiftHeadings(n *html.Node) {
  160. top := 7
  161. walk(n, func(n *html.Node) {
  162. if level, ok := headingLevels[n.DataAtom]; ok {
  163. top = min(top, level)
  164. }
  165. })
  166. shift := 2 - top
  167. if shift <= 0 {
  168. return
  169. }
  170. walk(n, func(n *html.Node) {
  171. if level, ok := headingLevels[n.DataAtom]; ok {
  172. a := headingAtoms[min(level+shift, 6)]
  173. n.DataAtom, n.Data = a, a.String()
  174. }
  175. })
  176. }
  177. // removeEmpty drops images left without a source (the sanitizer removes
  178. // unsafe ones), then paragraphs, headings and list items left with no text
  179. // and no image, such as those that only held a removed embed.
  180. func removeEmpty(n *html.Node) {
  181. var empty []*html.Node
  182. walk(n, func(n *html.Node) {
  183. if n.DataAtom == atom.Img && strings.TrimSpace(attr(n, "src")) == "" {
  184. empty = append(empty, n)
  185. }
  186. })
  187. for _, e := range empty {
  188. e.Parent.RemoveChild(e)
  189. }
  190. empty = nil
  191. walk(n, func(n *html.Node) {
  192. switch n.DataAtom {
  193. case atom.P, atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6, atom.Li:
  194. if strings.TrimSpace(text(n)) == "" && !contains(n, atom.Img) {
  195. empty = append(empty, n)
  196. }
  197. }
  198. })
  199. for _, e := range empty {
  200. if e.Parent != nil {
  201. e.Parent.RemoveChild(e)
  202. }
  203. }
  204. }
  205. // contains reports whether n has a descendant element of type a.
  206. func contains(n *html.Node, a atom.Atom) bool {
  207. found := false
  208. walk(n, func(d *html.Node) { found = found || (d != n && d.DataAtom == a) })
  209. return found
  210. }
  211. func walk(n *html.Node, f func(*html.Node)) {
  212. if n.Type == html.ElementNode {
  213. f(n)
  214. }
  215. for c := n.FirstChild; c != nil; c = c.NextSibling {
  216. walk(c, f)
  217. }
  218. }
  219. func text(n *html.Node) string {
  220. var b strings.Builder
  221. var rec func(*html.Node)
  222. rec = func(n *html.Node) {
  223. if n.Type == html.TextNode {
  224. b.WriteString(n.Data)
  225. }
  226. for c := n.FirstChild; c != nil; c = c.NextSibling {
  227. rec(c)
  228. }
  229. }
  230. rec(n)
  231. return b.String()
  232. }
  233. // WellFormed reports whether s parses as strict XML using only the five
  234. // predefined entities, as EPUB content documents must.
  235. func WellFormed(s string) error {
  236. d := xml.NewDecoder(strings.NewReader(s))
  237. d.Strict = true
  238. for {
  239. _, err := d.Token()
  240. if errors.Is(err, io.EOF) {
  241. return nil
  242. }
  243. if err != nil {
  244. return err
  245. }
  246. }
  247. }