@link-assistant/web-capture 1.7.10 → 1.7.11
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.
- package/CHANGELOG.md +22 -0
- package/bin/web-capture.js +48 -28
- package/package.json +1 -1
- package/src/gdocs-fallback.js +152 -0
- package/src/gdocs.js +50 -3
- package/tests/unit/gdocs.test.js +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 71e7eb9: Fall back to the Google Docs export pipeline when browser capture cannot read editor model chunks, preserving archive content and embedded images.
|
|
8
|
+
- 7b4d2d2: Fix Rust CLI `--capture browser` silently routing through direct HTTP
|
|
9
|
+
fetches for non-Google-Docs URLs (issue #80):
|
|
10
|
+
- `rust/src/main.rs` now passes `--capture browser` through
|
|
11
|
+
`render_html` for markdown, archive, and html output formats so the
|
|
12
|
+
flag actually launches headless Chrome instead of calling
|
|
13
|
+
`fetch_html`.
|
|
14
|
+
- `rust/src/browser.rs` `capture_screenshot` is no longer a stub — it
|
|
15
|
+
launches headless Chrome with `--screenshot` and returns real PNG
|
|
16
|
+
bytes.
|
|
17
|
+
- Adds Rust integration tests that run a local HTTP fixture whose DOM
|
|
18
|
+
is mutated by JavaScript after load and verify the rendered HTML,
|
|
19
|
+
the CLI `--capture browser` markdown output, and the PNG signature
|
|
20
|
+
on the screenshot bytes.
|
|
21
|
+
|
|
22
|
+
No JS package behavior changes in this patch; the Rust CLI is the only
|
|
23
|
+
affected surface.
|
|
24
|
+
|
|
3
25
|
## 1.7.10
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/bin/web-capture.js
CHANGED
|
@@ -459,7 +459,7 @@ async function captureUrl(url, options) {
|
|
|
459
459
|
fetchGoogleDoc,
|
|
460
460
|
fetchGoogleDocAsMarkdown,
|
|
461
461
|
fetchGoogleDocFromDocsApi,
|
|
462
|
-
|
|
462
|
+
captureGoogleDocWithBrowserOrFallback,
|
|
463
463
|
selectGoogleDocsCaptureMethod,
|
|
464
464
|
} = await import('../src/gdocs.js');
|
|
465
465
|
const { stripBase64Images } = await import('../src/extract-images.js');
|
|
@@ -511,40 +511,60 @@ async function captureUrl(url, options) {
|
|
|
511
511
|
// Screenshot/PDF/DOCX formats should use the regular browser pipeline below.
|
|
512
512
|
} else if (gdocsMethod === 'browser-model') {
|
|
513
513
|
try {
|
|
514
|
-
const result = await
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
514
|
+
const result = await captureGoogleDocWithBrowserOrFallback(
|
|
515
|
+
absoluteUrl,
|
|
516
|
+
{
|
|
517
|
+
engine,
|
|
518
|
+
apiToken,
|
|
519
|
+
format: normalizedFormat,
|
|
520
|
+
log,
|
|
521
|
+
onFallback: (err) => {
|
|
522
|
+
console.error(
|
|
523
|
+
`Warning: Google Docs browser capture could not read editor content (${err.message}); falling back to Google Docs export API.`
|
|
524
|
+
);
|
|
525
|
+
},
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
const resultMethod = result.fallback ? result.method : gdocsMethod;
|
|
529
|
+
const resultCapture = result.capture || {};
|
|
519
530
|
log.debug(() => ({
|
|
520
|
-
event:
|
|
531
|
+
event: result.fallback
|
|
532
|
+
? 'gdocs.capture.browser-model.fallback-rendered'
|
|
533
|
+
: 'gdocs.capture.browser-model.rendered',
|
|
521
534
|
documentId: result.documentId,
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
535
|
+
method: resultMethod,
|
|
536
|
+
blocks: resultCapture.blocks?.length || 0,
|
|
537
|
+
tables: resultCapture.tables?.length || 0,
|
|
538
|
+
images: resultCapture.images?.length || result.images?.length || 0,
|
|
525
539
|
markdownBytes: Buffer.byteLength(result.markdown || ''),
|
|
526
540
|
htmlBytes: Buffer.byteLength(result.html || ''),
|
|
527
541
|
textBytes: Buffer.byteLength(result.text || ''),
|
|
528
542
|
}));
|
|
529
543
|
if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
544
|
+
let archiveResult;
|
|
545
|
+
if (result.fallback) {
|
|
546
|
+
archiveResult = result;
|
|
547
|
+
} else {
|
|
548
|
+
const { localizeGoogleDocsModelImages } =
|
|
549
|
+
await import('../src/gdocs.js');
|
|
550
|
+
const localized = await localizeGoogleDocsModelImages(result, {
|
|
551
|
+
log,
|
|
552
|
+
});
|
|
553
|
+
log.debug(() => ({
|
|
554
|
+
event: 'gdocs.capture.browser-model.archive.localized',
|
|
555
|
+
images: localized.images.length,
|
|
556
|
+
markdownBytes: Buffer.byteLength(localized.markdown),
|
|
557
|
+
htmlBytes: Buffer.byteLength(localized.html),
|
|
558
|
+
}));
|
|
559
|
+
archiveResult = {
|
|
543
560
|
...result,
|
|
544
561
|
markdown: localized.markdown,
|
|
545
562
|
html: localized.html,
|
|
546
563
|
images: localized.images,
|
|
547
|
-
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
await writeGoogleDocsArchive({
|
|
567
|
+
archiveResult,
|
|
548
568
|
absoluteUrl,
|
|
549
569
|
explicitOutput,
|
|
550
570
|
dataDir,
|
|
@@ -554,10 +574,10 @@ async function captureUrl(url, options) {
|
|
|
554
574
|
} else {
|
|
555
575
|
const rendered =
|
|
556
576
|
normalizedFormat === 'html'
|
|
557
|
-
? result.html
|
|
577
|
+
? result.html || result.content
|
|
558
578
|
: normalizedFormat === 'txt' || normalizedFormat === 'text'
|
|
559
|
-
? result.text
|
|
560
|
-
: result.markdown;
|
|
579
|
+
? result.text || result.content
|
|
580
|
+
: result.markdown || result.content;
|
|
561
581
|
const ext =
|
|
562
582
|
normalizedFormat === 'html'
|
|
563
583
|
? 'html'
|
|
@@ -570,7 +590,7 @@ async function captureUrl(url, options) {
|
|
|
570
590
|
ext,
|
|
571
591
|
explicitOutput,
|
|
572
592
|
dataDir,
|
|
573
|
-
label: `Google Doc (${
|
|
593
|
+
label: `Google Doc (${resultMethod})`,
|
|
574
594
|
});
|
|
575
595
|
}
|
|
576
596
|
return;
|
package/package.json
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Google Docs browser-model fallback helpers.
|
|
2
|
+
//
|
|
3
|
+
// When browser-model capture cannot read DOCS_modelChunk data from the editor
|
|
4
|
+
// page, fall back to the public export pipeline so that content and images
|
|
5
|
+
// are preserved (instead of returning an empty or lossy capture).
|
|
6
|
+
|
|
7
|
+
const GDOCS_BROWSER_MODEL_UNAVAILABLE = 'GDOCS_BROWSER_MODEL_UNAVAILABLE';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the sentinel error thrown when browser-model capture finds no chunks.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} message - Human-readable error message
|
|
13
|
+
* @returns {Error} Error carrying the sentinel code
|
|
14
|
+
*/
|
|
15
|
+
export function googleDocsBrowserModelUnavailableError(message) {
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.code = GDOCS_BROWSER_MODEL_UNAVAILABLE;
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether an error means Google Docs browser-model data was unavailable.
|
|
23
|
+
*
|
|
24
|
+
* @param {Error} err - Error to classify
|
|
25
|
+
* @returns {boolean} True if export fallback is appropriate
|
|
26
|
+
*/
|
|
27
|
+
export function isGoogleDocsBrowserModelUnavailableError(err) {
|
|
28
|
+
return (
|
|
29
|
+
err?.code === GDOCS_BROWSER_MODEL_UNAVAILABLE ||
|
|
30
|
+
String(err?.message || '').includes('did not expose DOCS_modelChunk data')
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch a Google Doc through the public export pipeline for a requested output
|
|
36
|
+
* format. This is used as the lossless fallback when browser-model capture
|
|
37
|
+
* cannot read DOCS_modelChunk data from the editor page.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} deps - Injected dependencies from gdocs.js
|
|
40
|
+
* @param {string} url - Google Docs URL
|
|
41
|
+
* @param {Object} [options] - Options
|
|
42
|
+
* @returns {Promise<Object>} Export result normalized for CLI rendering
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchGoogleDocByExportFormat(deps, url, options = {}) {
|
|
45
|
+
const { fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive } =
|
|
46
|
+
deps;
|
|
47
|
+
const { format = 'markdown', apiToken, log } = options;
|
|
48
|
+
const normalized = (format || 'markdown').toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (normalized === 'archive' || normalized === 'zip') {
|
|
51
|
+
const archiveResult = await fetchGoogleDocAsArchive(url, { apiToken, log });
|
|
52
|
+
return {
|
|
53
|
+
...archiveResult,
|
|
54
|
+
content: archiveResult.markdown,
|
|
55
|
+
sourceFormat: 'archive',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (normalized === 'markdown' || normalized === 'md') {
|
|
60
|
+
const result = await fetchGoogleDocAsMarkdown(url, { apiToken, log });
|
|
61
|
+
return {
|
|
62
|
+
...result,
|
|
63
|
+
content: result.markdown,
|
|
64
|
+
sourceFormat: 'markdown',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (normalized === 'html') {
|
|
69
|
+
const result = await fetchGoogleDoc(url, {
|
|
70
|
+
format: 'html',
|
|
71
|
+
apiToken,
|
|
72
|
+
log,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
...result,
|
|
76
|
+
html: result.content,
|
|
77
|
+
sourceFormat: 'html',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (normalized === 'txt' || normalized === 'text') {
|
|
82
|
+
const result = await fetchGoogleDoc(url, {
|
|
83
|
+
format: 'txt',
|
|
84
|
+
apiToken,
|
|
85
|
+
log,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
...result,
|
|
89
|
+
text: result.content,
|
|
90
|
+
sourceFormat: 'txt',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Unsupported Google Docs export fallback format "${format}".`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Capture a Google Doc through the browser model, falling back to public export
|
|
101
|
+
* when the editor does not expose model chunks.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} deps - Injected dependencies from gdocs.js
|
|
104
|
+
* @param {string} url - Google Docs URL
|
|
105
|
+
* @param {Object} [options] - Capture options
|
|
106
|
+
* @returns {Promise<Object>} Browser-model or public-export result
|
|
107
|
+
*/
|
|
108
|
+
export async function captureGoogleDocWithBrowserOrFallback(
|
|
109
|
+
deps,
|
|
110
|
+
url,
|
|
111
|
+
options = {}
|
|
112
|
+
) {
|
|
113
|
+
const { captureGoogleDocWithBrowser } = deps;
|
|
114
|
+
const {
|
|
115
|
+
format = 'markdown',
|
|
116
|
+
fallback = true,
|
|
117
|
+
onFallback,
|
|
118
|
+
...browserOptions
|
|
119
|
+
} = options;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const result = await captureGoogleDocWithBrowser(url, browserOptions);
|
|
123
|
+
return {
|
|
124
|
+
...result,
|
|
125
|
+
method: 'browser-model',
|
|
126
|
+
fallback: false,
|
|
127
|
+
};
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (!fallback || !isGoogleDocsBrowserModelUnavailableError(err)) {
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onFallback?.(err);
|
|
134
|
+
browserOptions.log?.warn?.(() => ({
|
|
135
|
+
event: 'gdocs.browser-model.fallback-public-export',
|
|
136
|
+
reason: err.message,
|
|
137
|
+
format,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const result = await fetchGoogleDocByExportFormat(deps, url, {
|
|
141
|
+
format,
|
|
142
|
+
apiToken: browserOptions.apiToken,
|
|
143
|
+
log: browserOptions.log,
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
...result,
|
|
147
|
+
method: 'public-export',
|
|
148
|
+
fallback: true,
|
|
149
|
+
browserError: err.message,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/gdocs.js
CHANGED
|
@@ -13,8 +13,18 @@ import { convertHtmlToMarkdown } from './lib.js';
|
|
|
13
13
|
import { createBrowser as defaultCreateBrowser } from './browser.js';
|
|
14
14
|
import { preprocessGoogleDocsExportHtml } from './gdocs-preprocess.js';
|
|
15
15
|
import { localizeGoogleDocsModelImages } from './gdocs-images.js';
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
import {
|
|
17
|
+
googleDocsBrowserModelUnavailableError,
|
|
18
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
19
|
+
fetchGoogleDocByExportFormat as fetchGoogleDocByExportFormatImpl,
|
|
20
|
+
captureGoogleDocWithBrowserOrFallback as captureGoogleDocWithBrowserOrFallbackImpl,
|
|
21
|
+
} from './gdocs-fallback.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
preprocessGoogleDocsExportHtml,
|
|
25
|
+
localizeGoogleDocsModelImages,
|
|
26
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
27
|
+
};
|
|
18
28
|
|
|
19
29
|
const GDOCS_URL_PATTERN = /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/;
|
|
20
30
|
|
|
@@ -308,6 +318,43 @@ export async function fetchGoogleDocAsMarkdown(url, options = {}) {
|
|
|
308
318
|
};
|
|
309
319
|
}
|
|
310
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Fetch a Google Doc through the public export pipeline for a requested output
|
|
323
|
+
* format. Thin wrapper over the fallback module that injects sibling fetchers.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} url - Google Docs URL
|
|
326
|
+
* @param {Object} [options] - Options
|
|
327
|
+
* @returns {Promise<Object>} Export result normalized for CLI rendering
|
|
328
|
+
*/
|
|
329
|
+
export function fetchGoogleDocByExportFormat(url, options = {}) {
|
|
330
|
+
return fetchGoogleDocByExportFormatImpl(
|
|
331
|
+
{ fetchGoogleDoc, fetchGoogleDocAsMarkdown, fetchGoogleDocAsArchive },
|
|
332
|
+
url,
|
|
333
|
+
options
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Capture a Google Doc through the browser model, falling back to public export
|
|
339
|
+
* when the editor does not expose model chunks. Thin wrapper.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} url - Google Docs URL
|
|
342
|
+
* @param {Object} [options] - Capture options
|
|
343
|
+
* @returns {Promise<Object>} Browser-model or public-export result
|
|
344
|
+
*/
|
|
345
|
+
export function captureGoogleDocWithBrowserOrFallback(url, options = {}) {
|
|
346
|
+
return captureGoogleDocWithBrowserOrFallbackImpl(
|
|
347
|
+
{
|
|
348
|
+
captureGoogleDocWithBrowser,
|
|
349
|
+
fetchGoogleDoc,
|
|
350
|
+
fetchGoogleDocAsMarkdown,
|
|
351
|
+
fetchGoogleDocAsArchive,
|
|
352
|
+
},
|
|
353
|
+
url,
|
|
354
|
+
options
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
311
358
|
/**
|
|
312
359
|
* Capture a Google Doc from the editor page model (`DOCS_modelChunk`).
|
|
313
360
|
*
|
|
@@ -409,7 +456,7 @@ export async function captureGoogleDocWithBrowser(url, options = {}) {
|
|
|
409
456
|
textBytes: Buffer.byteLength(capture.text || ''),
|
|
410
457
|
}));
|
|
411
458
|
if (capture.blocks.length === 0) {
|
|
412
|
-
throw
|
|
459
|
+
throw googleDocsBrowserModelUnavailableError(
|
|
413
460
|
'Google Docs editor page did not expose DOCS_modelChunk data'
|
|
414
461
|
);
|
|
415
462
|
}
|
package/tests/unit/gdocs.test.js
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
buildEditUrl,
|
|
7
7
|
buildDocsApiUrl,
|
|
8
8
|
extractBase64Images,
|
|
9
|
+
captureGoogleDocWithBrowserOrFallback,
|
|
10
|
+
isGoogleDocsBrowserModelUnavailableError,
|
|
9
11
|
parseGoogleDocsModelChunks,
|
|
10
12
|
renderGoogleDocsCapture,
|
|
11
13
|
renderDocsApiDocument,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
import fs from 'fs';
|
|
23
25
|
import os from 'os';
|
|
24
26
|
import path from 'path';
|
|
27
|
+
import nock from 'nock';
|
|
25
28
|
|
|
26
29
|
jest.setTimeout(30000);
|
|
27
30
|
|
|
@@ -539,6 +542,49 @@ describe('gdocs', () => {
|
|
|
539
542
|
});
|
|
540
543
|
});
|
|
541
544
|
|
|
545
|
+
describe('captureGoogleDocWithBrowserOrFallback (issue #81)', () => {
|
|
546
|
+
afterEach(() => {
|
|
547
|
+
nock.cleanAll();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('falls back to public export archive when the editor exposes no model chunks', async () => {
|
|
551
|
+
const tinyPng =
|
|
552
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
|
553
|
+
nock('https://docs.google.com')
|
|
554
|
+
.get('/document/d/fallback-doc/export')
|
|
555
|
+
.query({ format: 'html' })
|
|
556
|
+
.reply(
|
|
557
|
+
200,
|
|
558
|
+
`<html><body><h1>Fallback Doc</h1><p>Complete text</p><img src="data:image/png;base64,${tinyPng}" alt="diagram"></body></html>`,
|
|
559
|
+
{ 'content-type': 'text/html; charset=utf-8' }
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const fallbackErrors = [];
|
|
563
|
+
const result = await captureGoogleDocWithBrowserOrFallback(
|
|
564
|
+
'https://docs.google.com/document/d/fallback-doc/edit',
|
|
565
|
+
{
|
|
566
|
+
format: 'archive',
|
|
567
|
+
waitMs: 0,
|
|
568
|
+
createBrowser: async () =>
|
|
569
|
+
fakeBrowserReturningModel({ chunks: [], cidUrlMap: {} }),
|
|
570
|
+
onFallback: (err) => fallbackErrors.push(err),
|
|
571
|
+
}
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
expect(result.method).toBe('public-export');
|
|
575
|
+
expect(result.fallback).toBe(true);
|
|
576
|
+
expect(result.markdown).toContain('Fallback Doc');
|
|
577
|
+
expect(result.markdown).toContain('Complete text');
|
|
578
|
+
expect(result.images).toHaveLength(1);
|
|
579
|
+
expect(result.html).toContain('images/image-01.png');
|
|
580
|
+
expect(fallbackErrors).toHaveLength(1);
|
|
581
|
+
expect(isGoogleDocsBrowserModelUnavailableError(fallbackErrors[0])).toBe(
|
|
582
|
+
true
|
|
583
|
+
);
|
|
584
|
+
expect(nock.isDone()).toBe(true);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
542
588
|
describe('GDOCS_EXPORT_FORMATS', () => {
|
|
543
589
|
it('contains all expected formats', () => {
|
|
544
590
|
expect(GDOCS_EXPORT_FORMATS).toEqual({
|
|
@@ -806,3 +852,23 @@ describe('gdocs', () => {
|
|
|
806
852
|
});
|
|
807
853
|
});
|
|
808
854
|
});
|
|
855
|
+
|
|
856
|
+
function fakeBrowserReturningModel(modelData) {
|
|
857
|
+
return {
|
|
858
|
+
async newPage() {
|
|
859
|
+
return {
|
|
860
|
+
async addInitScript() {},
|
|
861
|
+
async setUserAgent() {},
|
|
862
|
+
async setExtraHTTPHeaders() {},
|
|
863
|
+
async setViewport() {},
|
|
864
|
+
async goto() {},
|
|
865
|
+
async waitForTimeout() {},
|
|
866
|
+
async evaluate() {
|
|
867
|
+
return modelData;
|
|
868
|
+
},
|
|
869
|
+
async close() {},
|
|
870
|
+
};
|
|
871
|
+
},
|
|
872
|
+
async close() {},
|
|
873
|
+
};
|
|
874
|
+
}
|