website-to-markdown 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +135 -5
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +3 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/content-type.d.ts.map +1 -1
  6. package/dist/content-type.js +9 -8
  7. package/dist/content-type.js.map +1 -1
  8. package/dist/crawl-snapshot.d.ts +2 -1
  9. package/dist/crawl-snapshot.d.ts.map +1 -1
  10. package/dist/crawl-snapshot.js +15 -8
  11. package/dist/crawl-snapshot.js.map +1 -1
  12. package/dist/fetch-page.d.ts +38 -22
  13. package/dist/fetch-page.d.ts.map +1 -1
  14. package/dist/fetch-page.js +209 -167
  15. package/dist/fetch-page.js.map +1 -1
  16. package/dist/hash.d.ts +3 -7
  17. package/dist/hash.d.ts.map +1 -1
  18. package/dist/hash.js +3 -7
  19. package/dist/hash.js.map +1 -1
  20. package/dist/html-metadata.d.ts.map +1 -1
  21. package/dist/html-metadata.js +16 -20
  22. package/dist/html-metadata.js.map +1 -1
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/limit.d.ts +1 -1
  28. package/dist/limit.js +1 -1
  29. package/dist/limit.js.map +1 -1
  30. package/dist/markdown-refs.d.ts +8 -0
  31. package/dist/markdown-refs.d.ts.map +1 -1
  32. package/dist/markdown-refs.js +104 -59
  33. package/dist/markdown-refs.js.map +1 -1
  34. package/dist/memory-limit.d.ts +1 -1
  35. package/dist/memory-limit.js +1 -1
  36. package/dist/memory-limit.js.map +1 -1
  37. package/dist/net-guard.d.ts +38 -2
  38. package/dist/net-guard.d.ts.map +1 -1
  39. package/dist/net-guard.js +88 -17
  40. package/dist/net-guard.js.map +1 -1
  41. package/dist/rate-limit-retry.d.ts.map +1 -1
  42. package/dist/rate-limit-retry.js +12 -21
  43. package/dist/rate-limit-retry.js.map +1 -1
  44. package/dist/read-body.d.ts +12 -0
  45. package/dist/read-body.d.ts.map +1 -0
  46. package/dist/read-body.js +40 -0
  47. package/dist/read-body.js.map +1 -0
  48. package/dist/retry-after.d.ts.map +1 -1
  49. package/dist/retry-after.js +8 -12
  50. package/dist/retry-after.js.map +1 -1
  51. package/dist/schema/index.d.ts +9 -0
  52. package/dist/schema/index.d.ts.map +1 -1
  53. package/dist/schema/index.js +28 -0
  54. package/dist/schema/index.js.map +1 -1
  55. package/dist/sitemap/index.d.ts +13 -4
  56. package/dist/sitemap/index.d.ts.map +1 -1
  57. package/dist/sitemap/index.js +58 -86
  58. package/dist/sitemap/index.js.map +1 -1
  59. package/dist/testing/index.d.ts +16 -0
  60. package/dist/testing/index.d.ts.map +1 -0
  61. package/dist/testing/index.js +54 -0
  62. package/dist/testing/index.js.map +1 -0
  63. package/dist/unchanged-page.d.ts +9 -0
  64. package/dist/unchanged-page.d.ts.map +1 -0
  65. package/dist/unchanged-page.js +31 -0
  66. package/dist/unchanged-page.js.map +1 -0
  67. package/package.json +12 -7
  68. package/src/cli.ts +3 -1
  69. package/src/content-type.ts +11 -9
  70. package/src/crawl-snapshot.ts +21 -8
  71. package/src/fetch-page.ts +304 -221
  72. package/src/hash.ts +3 -7
  73. package/src/html-metadata.ts +20 -18
  74. package/src/index.ts +7 -0
  75. package/src/limit.ts +1 -1
  76. package/src/markdown-refs.ts +129 -57
  77. package/src/memory-limit.ts +1 -1
  78. package/src/net-guard.ts +126 -16
  79. package/src/rate-limit-retry.ts +20 -20
  80. package/src/read-body.ts +38 -0
  81. package/src/retry-after.ts +11 -18
  82. package/src/schema/index.ts +34 -0
  83. package/src/sitemap/index.ts +83 -86
  84. package/src/testing/index.ts +60 -0
  85. package/src/unchanged-page.ts +37 -0
@@ -1,13 +1,11 @@
1
- import { parseRetryAfter } from 'hanzio'
1
+ import { clamp, isFunction, isRecord, parseRetryAfter } from 'hanzio'
2
2
 
3
3
  const DEFAULT_RETRY_AFTER_MS = 1_000
4
4
  const MAX_RETRY_AFTER_MS = 5 * 60 * 1_000
5
5
 
6
6
  function headerGet(headers: unknown, name: string): string | null {
7
- if (!headers || typeof headers !== 'object') return null
8
- if (typeof (headers as { get?: unknown }).get === 'function') {
9
- return (headers as { get: (key: string) => string | null }).get(name)
10
- }
7
+ if (!isRecord(headers)) return null
8
+ if (isFunction(headers.get)) return headers.get(name)
11
9
  const record = headers as Record<string, string>
12
10
  return record[name] ?? record[name.toLowerCase()] ?? null
13
11
  }
@@ -20,23 +18,17 @@ function headerGet(headers: unknown, name: string): string | null {
20
18
  export function rateLimitFromError(
21
19
  error: unknown
22
20
  ): { retryAfterMs: number | null } | null {
23
- if (typeof error !== 'object' || !error) return null
24
- const status =
25
- 'status' in error && typeof error.status === 'number' ? error.status : null
26
- if (status !== 429) return null
21
+ if (!isRecord(error) || error.status !== 429) return null
27
22
 
28
23
  const raw =
29
- headerGet('headers' in error ? error.headers : null, 'retry-after') ??
30
- headerGet(
31
- 'responseHeaders' in error ? error.responseHeaders : null,
32
- 'retry-after'
33
- )
24
+ headerGet(error.headers, 'retry-after') ??
25
+ headerGet(error.responseHeaders, 'retry-after')
34
26
  const parsed = parseRetryAfter(raw)
35
27
  return {
36
28
  retryAfterMs:
37
29
  parsed === undefined
38
30
  ? null
39
- : Math.min(MAX_RETRY_AFTER_MS, Math.ceil(parsed))
31
+ : clamp(Math.ceil(parsed), 0, MAX_RETRY_AFTER_MS)
40
32
  }
41
33
  }
42
34
 
@@ -44,8 +36,9 @@ export function rateLimitFromError(
44
36
  export function retryAfterMsFromError(error: unknown) {
45
37
  const info = rateLimitFromError(error)
46
38
  if (!info) return null
47
- return Math.min(
48
- MAX_RETRY_AFTER_MS,
49
- Math.ceil(info.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS)
39
+ return clamp(
40
+ Math.ceil(info.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS),
41
+ 0,
42
+ MAX_RETRY_AFTER_MS
50
43
  )
51
44
  }
@@ -12,3 +12,37 @@ export type CrawlSnapshotInput = {
12
12
  /** `<snapshot>/<host>`: the bench's snapshot label, then the site. */
13
13
  name: string
14
14
  }
15
+
16
+ /** Every `FetchedPage.outcome`, for narrowing a value read back from storage. */
17
+ export const FETCH_OUTCOMES = [
18
+ 'ok',
19
+ 'unchanged',
20
+ 'gone',
21
+ 'blocked',
22
+ 'transient',
23
+ 'unsupported'
24
+ ] as const
25
+ export type FetchOutcome = (typeof FETCH_OUTCOMES)[number]
26
+
27
+ /** Every `FetchedPage.source`. `firecrawl-parse` is the `parsePdf` tier, whatever parser it is. */
28
+ export const FETCH_SOURCES = [
29
+ 'content-negotiation',
30
+ 'workers-ai',
31
+ 'browser-rendering',
32
+ 'not-modified',
33
+ 'firecrawl-parse'
34
+ ] as const
35
+ export type FetchSource = (typeof FETCH_SOURCES)[number]
36
+
37
+ /** Every `FetchedPage.captureIssue`: why a page came back incomplete or not at all. */
38
+ export const CAPTURE_ISSUES = [
39
+ 'markdown_limit',
40
+ 'html_byte_limit',
41
+ 'document_byte_limit',
42
+ 'workers_ai_rate_limited',
43
+ 'browser_rendering_rate_limited',
44
+ 'render_incomplete',
45
+ 'pdf_page_limit_unverified',
46
+ 'pdf_extraction_failed'
47
+ ] as const
48
+ export type CaptureIssue = (typeof CAPTURE_ISSUES)[number]
@@ -1,9 +1,31 @@
1
- import { escapeRegExp } from 'hanzio'
1
+ import { getUniqueValues, isOneOf, trimTrailingSlash } from 'hanzio'
2
+ import { isAllowedByRobots, parseRobotsTxt } from 'hanzio/sitemap'
2
3
  import { throughCrawlSnapshot } from '../crawl-snapshot.js'
4
+ import { type Fetch, guardedFetch } from '../net-guard.js'
5
+ import { decodeText, readBody } from '../read-body.js'
6
+ import { BROWSER_USER_AGENT } from '../user-agent.js'
7
+
8
+ export type { Fetch }
3
9
 
4
10
  const FETCH_TIMEOUT_MS = 10_000
5
11
  const MAX_SITEMAP_FILES = 20
6
12
  const MAX_URLS = 20_000
13
+ /** Google reads the first 500 KiB of a robots.txt and ignores the rest. */
14
+ const MAX_ROBOTS_BYTES = 500 * 1024
15
+ /**
16
+ * Read cap per sitemap file. 20,000 URLs fit in a few MB; a longer file is
17
+ * read up to here and its complete entries used.
18
+ */
19
+ const MAX_SITEMAP_BYTES = 10 * 1024 * 1024
20
+
21
+ export type SitemapOptions = {
22
+ /**
23
+ * The `fetch` robots.txt and sitemaps go through. Default
24
+ * {@link guardedFetch} with the default guard (public addresses only, every
25
+ * redirect checked); pass `converter.guardedFetch` to use the converter's.
26
+ */
27
+ fetch?: Fetch | undefined
28
+ }
7
29
 
8
30
  /** Conventional sitemap locations, tried in order when robots.txt names none. */
9
31
  const SITEMAP_ROOT_PATHS = [
@@ -64,28 +86,41 @@ export function parseSitemapXml(xml: string) {
64
86
  return { pages, nested }
65
87
  }
66
88
 
67
- function fetchText(url: string, accept: string) {
68
- return throughCrawlSnapshot(`text ${accept} ${url}`, () =>
69
- fetchTextLive(url, accept)
89
+ type TextRequest = { accept: string; maxBytes: number; fetch: Fetch }
90
+
91
+ function fetchText(url: string, request: TextRequest) {
92
+ return throughCrawlSnapshot(`text ${request.accept} ${url}`, () =>
93
+ fetchTextLive(url, request)
70
94
  )
71
95
  }
72
96
 
73
- async function fetchTextLive(url: string, accept: string) {
97
+ async function fetchTextLive(url: string, request: TextRequest) {
74
98
  try {
75
- const response = await fetch(url, {
99
+ const response = await request.fetch(url, {
76
100
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
77
- headers: { accept }
101
+ headers: { accept: request.accept, 'user-agent': BROWSER_USER_AGENT }
78
102
  })
79
- if (!response.ok) return null
80
103
  const type = response.headers.get('content-type') ?? ''
81
104
  // A CMS that renders its 404 page for a missing file answers 200 with HTML.
82
- if (type.includes('text/html')) return null
83
- return await response.text()
105
+ if (!response.ok || type.includes('text/html')) {
106
+ void response.body?.cancel().catch(() => {})
107
+ return null
108
+ }
109
+ return decodeText((await readBody(response, request.maxBytes)).bytes)
84
110
  } catch {
85
111
  return null
86
112
  }
87
113
  }
88
114
 
115
+ const ROBOTS_REQUEST = {
116
+ accept: 'text/plain,*/*;q=0.8',
117
+ maxBytes: MAX_ROBOTS_BYTES
118
+ }
119
+ const SITEMAP_REQUEST = {
120
+ accept: 'application/xml,text/xml;q=0.9,*/*;q=0.8',
121
+ maxBytes: MAX_SITEMAP_BYTES
122
+ }
123
+
89
124
  function originOf(websiteUrl: string) {
90
125
  try {
91
126
  return new URL(websiteUrl).origin
@@ -100,12 +135,18 @@ function originOf(websiteUrl: string) {
100
135
  * here), and its rules. An HTML body or any non-200 means "no robots.txt";
101
136
  * never throws.
102
137
  */
103
- export async function fetchRobots(websiteUrl: string) {
138
+ export async function fetchRobots(
139
+ websiteUrl: string,
140
+ options: SitemapOptions = {}
141
+ ) {
104
142
  const sitemaps: string[] = []
105
143
  const origin = originOf(websiteUrl)
106
144
  if (!origin) return { sitemaps, rules: [] }
107
145
  const robotsUrl = `${origin}/robots.txt`
108
- const body = await fetchText(robotsUrl, 'text/plain,*/*;q=0.8')
146
+ const body = await fetchText(robotsUrl, {
147
+ ...ROBOTS_REQUEST,
148
+ fetch: options.fetch ?? guardedFetch
149
+ })
109
150
  if (!body) return { sitemaps, rules: [] }
110
151
 
111
152
  for (const match of body.matchAll(ROBOTS_SITEMAP_RE)) {
@@ -114,15 +155,16 @@ export async function fetchRobots(websiteUrl: string) {
114
155
  try {
115
156
  const absolute = new URL(value, robotsUrl)
116
157
  // Only what we could actually GET - `ftp:` and friends are noise.
117
- if (absolute.protocol !== 'http:' && absolute.protocol !== 'https:')
118
- continue
119
- const href = absolute.toString()
120
- if (!sitemaps.includes(href)) sitemaps.push(href)
158
+ if (!isOneOf(['http:', 'https:'], absolute.protocol)) continue
159
+ sitemaps.push(absolute.toString())
121
160
  } catch {
122
161
  // A malformed Sitemap: line must not cost us the other lines.
123
162
  }
124
163
  }
125
- return { sitemaps, rules: parseRobotsRules(body) }
164
+ return {
165
+ sitemaps: getUniqueValues(sitemaps),
166
+ rules: parseRobotsRules(body)
167
+ }
126
168
  }
127
169
 
128
170
  export type RobotsRule = { allow: boolean; pattern: string }
@@ -133,75 +175,27 @@ export type RobotsRule = { allow: boolean; pattern: string }
133
175
  * the wildcard group is what the site asks of everyone. An empty `Disallow:`
134
176
  * allows everything and adds no rule.
135
177
  */
136
- export function parseRobotsRules(body: string) {
137
- const rules: RobotsRule[] = []
138
- let agents: string[] = []
139
- let inRules = false
140
-
141
- for (const raw of body.split(/\r?\n/)) {
142
- const match = /^\s*([a-z-]+)\s*:\s*(.*?)\s*$/i.exec(raw.replace(/#.*$/, ''))
143
- if (!match) continue
144
- const field = (match[1] ?? '').toLowerCase()
145
- const value = match[2] ?? ''
146
-
147
- if (field === 'user-agent') {
148
- // A user-agent line after rules opens a new group.
149
- if (inRules) agents = []
150
- inRules = false
151
- agents.push(value.toLowerCase())
152
- continue
153
- }
154
- if (field !== 'allow' && field !== 'disallow') continue
155
-
156
- inRules = true
157
- if (agents.includes('*') && value !== '')
158
- rules.push({ allow: field === 'allow', pattern: value })
159
- }
160
- return rules
161
- }
162
-
163
- /** Compiled once per pattern: the crawl asks for every link of every page. */
164
- const robotsPatternCache = new Map<string, RegExp>()
165
-
166
- function robotsPatternRegex(pattern: string) {
167
- const cached = robotsPatternCache.get(pattern)
168
- if (cached) return cached
169
-
170
- const anchored = pattern.endsWith('$')
171
- const body = (anchored ? pattern.slice(0, -1) : pattern)
172
- .split('*')
173
- .map((part) => escapeRegExp(part))
174
- .join('.*')
175
- const regex = new RegExp(`^${body}${anchored ? '$' : ''}`)
176
- // Every site's patterns land here; a long-lived worker starts over rather than grow.
177
- if (robotsPatternCache.size >= 5_000) robotsPatternCache.clear()
178
- robotsPatternCache.set(pattern, regex)
179
- return regex
178
+ export function parseRobotsRules(body: string): RobotsRule[] {
179
+ const { allow, disallow } = parseRobotsTxt(body)
180
+ return [
181
+ ...allow.map((pattern) => ({ allow: true, pattern })),
182
+ ...disallow.map((pattern) => ({ allow: false, pattern }))
183
+ ]
180
184
  }
181
185
 
182
186
  /**
183
187
  * Whether robots.txt lets us read this URL: the longest matching rule wins and
184
188
  * `Allow` wins a tie, as Google reads it. Shopify disallows `/cart/`, and the
185
- * crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart
186
- * (Good Habit audit §3.5).
189
+ * crawler had fetched `/cart/add?id=…&quantity=1` - a GET that fills a cart.
187
190
  */
188
191
  export function robotsAllows(rules: RobotsRule[], url: string) {
189
- let path: string
190
- try {
191
- const parsed = new URL(url)
192
- path = parsed.pathname + parsed.search
193
- } catch {
194
- return true
195
- }
196
-
197
- let best: RobotsRule | null = null
198
- for (const rule of rules) {
199
- if (!robotsPatternRegex(rule.pattern).test(path)) continue
200
- if (!best || rule.pattern.length > best.pattern.length) best = rule
201
- else if (rule.pattern.length === best.pattern.length && rule.allow)
202
- best = rule
203
- }
204
- return best?.allow ?? true
192
+ return isAllowedByRobots(
193
+ {
194
+ allow: rules.filter((rule) => rule.allow).map((rule) => rule.pattern),
195
+ disallow: rules.filter((rule) => !rule.allow).map((rule) => rule.pattern)
196
+ },
197
+ url
198
+ )
205
199
  }
206
200
 
207
201
  /**
@@ -212,7 +206,8 @@ export function robotsAllows(rules: RobotsRule[], url: string) {
212
206
  async function readSitemapTree(
213
207
  roots: string[],
214
208
  found: Map<string, { url: string; lastmod: string | null }>,
215
- seen: Set<string>
209
+ seen: Set<string>,
210
+ fetch: Fetch
216
211
  ) {
217
212
  let readable = false
218
213
  let level = [...roots]
@@ -230,7 +225,7 @@ async function readSitemapTree(
230
225
  }
231
226
  const bodies = await Promise.all(
232
227
  batch.map((sitemapUrl) =>
233
- fetchText(sitemapUrl, 'application/xml,text/xml;q=0.9,*/*;q=0.8')
228
+ fetchText(sitemapUrl, { ...SITEMAP_REQUEST, fetch })
234
229
  )
235
230
  )
236
231
  const next: string[] = []
@@ -263,10 +258,12 @@ async function readSitemapTree(
263
258
  */
264
259
  export async function discoverSitemapUrls(
265
260
  websiteUrl: string,
266
- robotsSitemaps: string[]
261
+ robotsSitemaps: string[],
262
+ options: SitemapOptions = {}
267
263
  ) {
268
264
  const origin = originOf(websiteUrl)
269
265
  if (!origin) return []
266
+ const fetch = options.fetch ?? guardedFetch
270
267
  const found = new Map<string, { url: string; lastmod: string | null }>()
271
268
  // Shared across roots so a broken sitemap index cannot spend the file budget
272
269
  // several times over.
@@ -276,12 +273,12 @@ export async function discoverSitemapUrls(
276
273
  // so all of them are read together - only the conventional roots compete.
277
274
  if (
278
275
  robotsSitemaps.length > 0 &&
279
- (await readSitemapTree(robotsSitemaps, found, seen))
276
+ (await readSitemapTree(robotsSitemaps, found, seen, fetch))
280
277
  ) {
281
278
  return [...found.values()]
282
279
  }
283
280
  for (const path of SITEMAP_ROOT_PATHS) {
284
- if (await readSitemapTree([`${origin}${path}`], found, seen)) break
281
+ if (await readSitemapTree([`${origin}${path}`], found, seen, fetch)) break
285
282
  }
286
283
  return [...found.values()]
287
284
  }
@@ -291,8 +288,8 @@ export function normalizeSitemapUrl(url: string) {
291
288
  try {
292
289
  const parsed = new URL(url)
293
290
  parsed.hash = ''
294
- return parsed.toString().replace(/\/+$/, '')
291
+ return trimTrailingSlash(parsed.toString())
295
292
  } catch {
296
- return url.replace(/\/+$/, '')
293
+ return trimTrailingSlash(url)
297
294
  }
298
295
  }
@@ -0,0 +1,60 @@
1
+ import type { FetchedPage, FetchPageInput } from '../fetch-page.js'
2
+ import { asUnchanged, isUnchangedFor } from '../unchanged-page.js'
3
+
4
+ /** A complete `FetchedPage`: an `ok` HTML read of `url` with nothing on it, plus `page`. */
5
+ export function fakePage(
6
+ page: Partial<FetchedPage> & { url: string }
7
+ ): FetchedPage {
8
+ return {
9
+ finalUrl: page.url,
10
+ status: 200,
11
+ outcome: 'ok',
12
+ source: 'workers-ai',
13
+ contentType: 'text/html; charset=utf-8',
14
+ etag: null,
15
+ lastModified: null,
16
+ markdown: '',
17
+ title: null,
18
+ lang: null,
19
+ canonical: null,
20
+ alternates: [],
21
+ alternateLanguages: {},
22
+ metaDescription: null,
23
+ jsonLd: [],
24
+ links: [],
25
+ images: [],
26
+ durationMs: 0,
27
+ captureIssue: null,
28
+ htmlHash: null,
29
+ ...page
30
+ }
31
+ }
32
+
33
+ /**
34
+ * A site in memory with the converter's `fetchPageAsMarkdown`: the pages
35
+ * given by URL, `gone` (404) for any other, `unchanged` (304) when the input's
36
+ * `etag`, `lastModified` or `htmlHash` matches the page's. `calls` lists every
37
+ * input in order.
38
+ */
39
+ export function fakeSite(
40
+ pages: Record<string, Omit<Partial<FetchedPage>, 'url'>>
41
+ ) {
42
+ const calls: FetchPageInput[] = []
43
+ async function fetchPageAsMarkdown(input: FetchPageInput) {
44
+ calls.push(input)
45
+ const known = pages[input.url]
46
+ if (!known) {
47
+ return fakePage({
48
+ url: input.url,
49
+ status: 404,
50
+ outcome: 'gone',
51
+ source: null
52
+ })
53
+ }
54
+ const page = fakePage({ ...known, url: input.url })
55
+ return isUnchangedFor(page, input)
56
+ ? { ...asUnchanged(page), status: 304 }
57
+ : page
58
+ }
59
+ return { fetchPageAsMarkdown, calls }
60
+ }
@@ -0,0 +1,37 @@
1
+ import type { FetchedPage, FetchPageInput } from './fetch-page.js'
2
+
3
+ /**
4
+ * Whether a full page read without validators is unchanged for a caller that
5
+ * holds these validators, decided the way the origin would have.
6
+ */
7
+ export function isUnchangedFor(page: FetchedPage, input: FetchPageInput) {
8
+ return (
9
+ page.outcome === 'ok' &&
10
+ Boolean(
11
+ (input.htmlHash && page.htmlHash === input.htmlHash) ||
12
+ (input.etag && page.etag === input.etag) ||
13
+ (input.lastModified && page.lastModified === input.lastModified)
14
+ )
15
+ )
16
+ }
17
+
18
+ /** `page` as the `unchanged` answer: validators and URLs kept, content dropped. */
19
+ export function asUnchanged(page: FetchedPage): FetchedPage {
20
+ return {
21
+ ...page,
22
+ outcome: 'unchanged',
23
+ source: 'not-modified',
24
+ markdown: '',
25
+ title: null,
26
+ lang: null,
27
+ canonical: null,
28
+ alternates: [],
29
+ alternateLanguages: {},
30
+ metaDescription: null,
31
+ jsonLd: [],
32
+ links: [],
33
+ images: [],
34
+ captureIssue: null,
35
+ htmlHash: null
36
+ }
37
+ }