meta.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. package extract
  2. import (
  3. "encoding/json"
  4. "net/url"
  5. "strings"
  6. "time"
  7. "golang.org/x/net/html"
  8. "golang.org/x/net/html/atom"
  9. )
  10. // pageMeta is metadata the page declares about itself, used to fill in
  11. // anything go-trafilatura didn't find, and for the cover image, which it
  12. // doesn't look for reliably.
  13. type pageMeta struct {
  14. Title, Author, SiteName, Description, Image string
  15. Published time.Time
  16. }
  17. // articleTypes are the schema.org types whose JSON-LD describes an article.
  18. var articleTypes = map[string]bool{
  19. "Article": true, "NewsArticle": true, "BlogPosting": true, "TechArticle": true,
  20. "ReportageNewsArticle": true, "AnalysisNewsArticle": true, "OpinionNewsArticle": true,
  21. "ScholarlyArticle": true, "Report": true,
  22. }
  23. // readPageMeta collects Open Graph, article: and Twitter meta tags and
  24. // schema.org JSON-LD from doc. JSON-LD wins over meta tags, and meta tags
  25. // over <title>. Relative image URLs are resolved against base.
  26. func readPageMeta(doc *html.Node, base *url.URL) pageMeta {
  27. tags := map[string]string{} // first value of each property/name, lowercased key
  28. var title string
  29. var ld []map[string]any
  30. walk(doc, func(n *html.Node) {
  31. switch n.DataAtom {
  32. case atom.Meta:
  33. key := strings.ToLower(attr(n, "property"))
  34. if key == "" {
  35. key = strings.ToLower(attr(n, "name"))
  36. }
  37. if v := strings.TrimSpace(attr(n, "content")); key != "" && v != "" {
  38. if _, seen := tags[key]; !seen {
  39. tags[key] = v
  40. }
  41. }
  42. case atom.Title:
  43. if title == "" {
  44. title = strings.TrimSpace(textContent(n))
  45. }
  46. case atom.Script:
  47. if strings.Contains(strings.ToLower(attr(n, "type")), "ld+json") {
  48. ld = append(ld, articleObjects(textContent(n))...)
  49. }
  50. }
  51. })
  52. m := pageMeta{
  53. Title: first(tags["og:title"], tags["twitter:title"], title),
  54. Author: first(tags["article:author"], tags["author"], tags["twitter:creator"]),
  55. SiteName: first(tags["og:site_name"], tags["application-name"]),
  56. Description: first(tags["og:description"], tags["description"], tags["twitter:description"]),
  57. Image: first(tags["og:image"], tags["og:image:url"], tags["twitter:image"], tags["twitter:image:src"]),
  58. Published: parseDate(first(tags["article:published_time"], tags["date"], tags["dc.date"])),
  59. }
  60. // An article:author that is a profile URL isn't a name.
  61. if strings.HasPrefix(m.Author, "http") {
  62. m.Author = ""
  63. }
  64. for _, o := range ld {
  65. if s := str(o["headline"]); s != "" {
  66. m.Title = s
  67. }
  68. if s := names(o["author"]); s != "" {
  69. m.Author = s
  70. }
  71. if s := names(o["publisher"]); s != "" && m.SiteName == "" {
  72. m.SiteName = s
  73. }
  74. if s := str(o["description"]); s != "" && m.Description == "" {
  75. m.Description = s
  76. }
  77. if s := imageURL(o["image"]); s != "" && m.Image == "" {
  78. m.Image = s
  79. }
  80. if t := parseDate(str(o["datePublished"])); !t.IsZero() {
  81. m.Published = t
  82. }
  83. }
  84. if m.Image != "" {
  85. if u, err := base.Parse(m.Image); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
  86. m.Image = u.String()
  87. } else {
  88. m.Image = ""
  89. }
  90. }
  91. return m
  92. }
  93. // articleObjects returns the article objects in a JSON-LD script, looking
  94. // inside arrays and @graph. Invalid JSON yields nothing.
  95. func articleObjects(script string) []map[string]any {
  96. var v any
  97. if json.Unmarshal([]byte(script), &v) != nil {
  98. return nil
  99. }
  100. var out []map[string]any
  101. var visit func(any)
  102. visit = func(v any) {
  103. switch v := v.(type) {
  104. case []any:
  105. for _, e := range v {
  106. visit(e)
  107. }
  108. case map[string]any:
  109. if isArticle(v["@type"]) {
  110. out = append(out, v)
  111. }
  112. visit(v["@graph"])
  113. }
  114. }
  115. visit(v)
  116. return out
  117. }
  118. func isArticle(t any) bool {
  119. switch t := t.(type) {
  120. case string:
  121. return articleTypes[t]
  122. case []any:
  123. for _, e := range t {
  124. if s, ok := e.(string); ok && articleTypes[s] {
  125. return true
  126. }
  127. }
  128. }
  129. return false
  130. }
  131. // names reads an author or publisher: a string, an object with a name, or
  132. // a list of those, joined with ", ".
  133. func names(v any) string {
  134. switch v := v.(type) {
  135. case string:
  136. return strings.TrimSpace(v)
  137. case map[string]any:
  138. return str(v["name"])
  139. case []any:
  140. var out []string
  141. for _, e := range v {
  142. if s := names(e); s != "" {
  143. out = append(out, s)
  144. }
  145. }
  146. return strings.Join(out, ", ")
  147. }
  148. return ""
  149. }
  150. // imageURL reads a schema.org image: a URL, an ImageObject, or a list.
  151. func imageURL(v any) string {
  152. switch v := v.(type) {
  153. case string:
  154. return strings.TrimSpace(v)
  155. case map[string]any:
  156. return first(str(v["url"]), str(v["contentUrl"]))
  157. case []any:
  158. for _, e := range v {
  159. if s := imageURL(e); s != "" {
  160. return s
  161. }
  162. }
  163. }
  164. return ""
  165. }
  166. func str(v any) string {
  167. s, _ := v.(string)
  168. return strings.TrimSpace(s)
  169. }
  170. func first(values ...string) string {
  171. for _, v := range values {
  172. if v = strings.TrimSpace(v); v != "" {
  173. return v
  174. }
  175. }
  176. return ""
  177. }
  178. // parseDate accepts the ISO 8601 forms pages use for publication dates.
  179. func parseDate(s string) time.Time {
  180. for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02"} {
  181. if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
  182. return t
  183. }
  184. }
  185. return time.Time{}
  186. }
  187. func attr(n *html.Node, key string) string {
  188. for _, a := range n.Attr {
  189. if a.Key == key {
  190. return a.Val
  191. }
  192. }
  193. return ""
  194. }
  195. func walk(n *html.Node, f func(*html.Node)) {
  196. if n.Type == html.ElementNode {
  197. f(n)
  198. }
  199. for c := n.FirstChild; c != nil; c = c.NextSibling {
  200. walk(c, f)
  201. }
  202. }