@wenathlan/saddle 1.8.2 → 1.8.5
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/README.md +9 -9
- package/browser/playwright.js +22 -0
- package/docs/.gitkeep +0 -0
- package/docs/gapmatrix.md +3 -3
- package/docs/libraryapi.md +1 -0
- package/docs/logs/.gitkeep +0 -0
- package/docs/packageaudit185.md +23 -0
- package/docs/plans/00.index.md +50 -0
- package/docs/plans/01.architecture.md +86 -0
- package/docs/plans/02.research.computer.use.md +58 -0
- package/docs/plans/03.research.captcha.bypass.md +68 -0
- package/docs/plans/04.research.sandbox.ai.md +52 -0
- package/docs/plans/05.capture.platform.md +57 -0
- package/docs/plans/06.dependencies.md +97 -0
- package/docs/plans/07.captcha.test.page.md +41 -0
- package/docs/plans/08.production.infra.md +70 -0
- package/docs/plans/09.database.schema.md +121 -0
- package/docs/plans/10.cloudinary.storage.md +57 -0
- package/docs/plans/11.movement.logs.json.md +72 -0
- package/docs/plans/12.research.atlas.agent.browser.md +79 -0
- package/docs/plans/13.research.anti.detection.md +898 -0
- package/docs/plans/14.research.proxy.md +1495 -0
- package/docs/plans/15.research.retry.rate.limit.md +1958 -0
- package/docs/plans/16.research.crawling.md +1417 -0
- package/docs/plans/17.research.caching.md +1610 -0
- package/docs/plans/18.research.content.extraction.md +1952 -0
- package/docs/plans/19.research.errors.events.md +1523 -0
- package/docs/plans/20.research.zod.validation.md +1350 -0
- package/docs/plans/21.research.batch.concurrency.md +1888 -0
- package/docs/plans/22.research.universal.runtime.md +944 -0
- package/docs/plans/23.research.ai.integration.md +1465 -0
- package/docs/plans/24.research.memory.persistence.md +1979 -0
- package/docs/plans/25.research.server.api.md +342 -0
- package/docs/plans/26.research.compilation.md +249 -0
- package/docs/plans/27.research.html.parsing.md +251 -0
- package/docs/plans/28.action.plan.md +50 -0
- package/docs/plans/29.api.reference.md +174 -0
- package/docs/plans/30.architecture.plan.md +94 -0
- package/docs/plans/31.auditoria.dados.md +163 -0
- package/docs/plans/32.bots.automacao.computacional.md +214 -0
- package/docs/plans/33.bots.codigo.revisao.md +220 -0
- package/docs/plans/34.bots.seguranca.cicd.md +366 -0
- package/docs/plans/35.comparativo.concorrencia.md +464 -0
- package/docs/plans/36.computational.memory.md +340 -0
- package/docs/plans/37.deploystrategy.md +394 -0
- package/docs/plans/38.flow.md +155 -0
- package/docs/plans/39.multi.platform.bot.md +252 -0
- package/docs/plans/40.npm.publish.md +250 -0
- package/docs/plans/41.o.que.falta.md +407 -0
- package/docs/plans/42.pesquisa.concorrencia.md +721 -0
- package/docs/plans/43.plan.universal.architecture.md +496 -0
- package/docs/plans/44.reference.md +100 -0
- package/docs/plans/45.robotarchitecture.md +237 -0
- package/docs/plans/46.scdnintegration.md +284 -0
- package/docs/plans/47.multiforge.readme.md +129 -0
- package/docs/plans/48.theory.v4.repo.os.md +152 -0
- package/docs/plans/49.third.party.infra.md +12 -0
- package/docs/plans/50.file.as.compute.md +39 -0
- package/docs/plans/51.architecture.virtual.processor.md +80 -0
- package/docs/plans/52.manifesto.v8.md +11 -0
- package/docs/plans/58.cdn.list.md +23 -0
- package/docs/plans/59.sql.frameworks.md +33 -0
- package/docs/plans/60.sql.thirdparty.md +26 -0
- package/docs/plans/61.objective.multiforge.md +63 -0
- package/docs/plans/62.huggingface.upload.md +26 -0
- package/docs/plans/63.kaggle.upload.md +24 -0
- package/docs/plans/64.npm.storage.md +30 -0
- package/docs/plans/65.rclone.terabox.md +32 -0
- package/docs/plans/66.buckets.and.models.todo.md +14 -0
- package/docs/plans/67.database.todo.md +13 -0
- package/docs/plans/68.deploy.packages.todo.md +12 -0
- package/docs/plans/69.report.human.operator.md +133 -0
- package/docs/plans/70.report.brain2qwerty.ems.md +135 -0
- package/docs/plans/71.report.hd.infinito.vram.md +155 -0
- package/docs/plans/72.plan.hd.infinito.node.md +146 -0
- package/docs/plans/73.plan.scifi.repos.md +125 -0
- package/docs/plans/74.000.manifesto.v8.flat.2..md +11 -0
- package/docs/plans/README.md +489 -0
- package/docs/plans/aggregate_platforms.mjs +146 -0
- package/docs/plans/examplesession.json +36 -0
- package/docs/plans/missing-facts.md +192 -0
- package/docs/plans/models.md +64 -0
- package/docs/plans/organize.cjs +270 -0
- package/docs/plans/platforms.md +2887 -0
- package/docs/plans/sites.md +31322 -0
- package/docs/platformpipelineaudit.md +18 -0
- package/docs/platformpipelines.md +13 -0
- package/docs/registryresearch.md +4 -0
- package/docs/release.md +4 -4
- package/docs/release182notes.md +2 -0
- package/docs/release184notes.md +7 -0
- package/docs/release185notes.md +7 -0
- package/docs/sources/farm.py +117 -0
- package/docs/sources/html/saddle1.html +132 -0
- package/docs/sources/html/saddle2.html +157 -0
- package/docs/sources/html/saddle3.html +119 -0
- package/docs/sources/html/saddle4.html +144 -0
- package/docs/sources/html/saddle5.html +72 -0
- package/docs/sources/html/saddle6.html +171 -0
- package/docs/sources/html/saddle7.html +236 -0
- package/docs/sources/saddle.ts +74 -0
- package/docs/sources/schema.prisma +88 -0
- package/docs/sources/script.sh +64 -0
- package/docs/sources/workflows.yml +458 -0
- package/docs/talks1/_body.txt +14 -0
- package/docs/talks1/_index.md +15 -0
- package/docs/talks1/_screenshot.png +0 -0
- package/docs/talks1/assistant-01.md +5 -0
- package/docs/talks1/assistant-02.md +5 -0
- package/docs/talks1/assistant-03.md +531 -0
- package/docs/talks1/assistant-04.md +26 -0
- package/docs/talks1/assistant-05.md +774 -0
- package/docs/talks1/assistant-06.md +1718 -0
- package/docs/talks1/scrape-share.cjs +185 -0
- package/docs/talks1/scrape-share.ts +183 -0
- package/docs/talks1/user-01.md +3 -0
- package/docs/talks1/user-02.md +3 -0
- package/docs/talks1/user-03.md +88 -0
- package/docs/talks1/user-04.md +3 -0
- package/docs/talks1/user-05.md +3 -0
- package/docs/talks1/user-06.md +88 -0
- package/docs/talks1/user-07.md +88 -0
- package/docs/talks2/_body.txt +14 -0
- package/docs/talks2/_index.md +16 -0
- package/docs/talks2/_screenshot.png +0 -0
- package/docs/talks2/assistant-01.md +5 -0
- package/docs/talks2/assistant-02.md +5 -0
- package/docs/talks2/assistant-03.md +424 -0
- package/docs/talks2/assistant-04.md +598 -0
- package/docs/talks2/assistant-05.md +1280 -0
- package/docs/talks2/assistant-06.md +1227 -0
- package/docs/talks2/assistant-07.md +1252 -0
- package/docs/talks2/user-01.md +3 -0
- package/docs/talks2/user-02.md +3 -0
- package/docs/talks2/user-03.md +88 -0
- package/docs/talks2/user-04.md +88 -0
- package/docs/talks2/user-05.md +88 -0
- package/docs/talks2/user-06.md +88 -0
- package/docs/talks2/user-07.md +3 -0
- package/docs/talks3/_body.txt +467 -0
- package/docs/talks3/_index.md +10 -0
- package/docs/talks3/_screenshot.png +0 -0
- package/docs/talks3/assistant-01.md +417 -0
- package/docs/talks3/assistant-02.md +417 -0
- package/docs/talks3/assistant-03.md +29 -0
- package/docs/talks3/assistant-04.md +727 -0
- package/docs/talks3/user-01.md +88 -0
- package/docs/talks3/user-02.md +88 -0
- package/docs/talks3/user-03.md +3 -0
- package/docs/talks3/user-04.md +3 -0
- package/docs/talks4/_body.txt +14 -0
- package/docs/talks4/_index.md +12 -0
- package/docs/talks4/_screenshot.png +0 -0
- package/docs/talks4/assistant-01.md +5 -0
- package/docs/talks4/assistant-02.md +5 -0
- package/docs/talks4/assistant-03.md +35 -0
- package/docs/talks4/assistant-04.md +512 -0
- package/docs/talks4/assistant-05.md +599 -0
- package/docs/talks4/user-01.md +3 -0
- package/docs/talks4/user-02.md +3 -0
- package/docs/talks4/user-03.md +88 -0
- package/docs/talks4/user-04.md +88 -0
- package/docs/talks4/user-05.md +7 -0
- package/docs/talks5/_body.txt +14 -0
- package/docs/talks5/_index.md +13 -0
- package/docs/talks5/_screenshot.png +0 -0
- package/docs/talks5/assistant-01.md +5 -0
- package/docs/talks5/assistant-02.md +5 -0
- package/docs/talks5/assistant-03.md +690 -0
- package/docs/talks5/assistant-04.md +758 -0
- package/docs/talks5/assistant-05.md +974 -0
- package/docs/talks5/user-01.md +3 -0
- package/docs/talks5/user-02.md +3 -0
- package/docs/talks5/user-03.md +105 -0
- package/docs/talks5/user-04.md +105 -0
- package/docs/talks5/user-05.md +63 -0
- package/docs/talks5/user-06.md +105 -0
- package/docs/talks6/_body.txt +14 -0
- package/docs/talks6/_index.md +9 -0
- package/docs/talks6/_screenshot.png +0 -0
- package/docs/talks6/assistant-01.md +5 -0
- package/docs/talks6/assistant-02.md +5 -0
- package/docs/talks6/assistant-03.md +1499 -0
- package/docs/talks6/user-01.md +3 -0
- package/docs/talks6/user-02.md +3 -0
- package/docs/talks6/user-03.md +88 -0
- package/docs/talks6/user-04.md +88 -0
- package/docs/talks7/_body.txt +14 -0
- package/docs/talks7/_index.md +10 -0
- package/docs/talks7/_screenshot.png +0 -0
- package/docs/talks7/assistant-01.md +5 -0
- package/docs/talks7/assistant-02.md +5 -0
- package/docs/talks7/assistant-03.md +523 -0
- package/docs/talks7/assistant-04.md +617 -0
- package/docs/talks7/user-01.md +3 -0
- package/docs/talks7/user-02.md +3 -0
- package/docs/talks7/user-03.md +105 -0
- package/docs/talks7/user-04.md +67 -0
- package/docs/talks8/conversa1.txt +1322 -0
- package/docs/talks8/conversa2.txt +237 -0
- package/docs/talks9/Beyond the Obvious_ 50 Plataformas Auto-Hospedadas de Forja de C/303/263digo para Al/303/251m de Gitea e GitLab.md" +174 -0
- package/docs/talks9/De NPM a Multi-Linguagem_ Uma Arquitetura T/303/251cnica para a Execu/303/247/303/243o Integrada de C/303/263digo no Ecossistema Node.js.md" +59 -0
- package/docs/talks9/De NPM a VMs Virtuais_ Uma An/303/241lise Arquitet/303/264nica para a Realiza/303/247/303/243o do Ciclo de Vida do Projeto SADDLE.md" +91 -0
- package/docs/talks9/Mapeamento da Engrenagem Computacional_ Uma Arquitetura para Execu/303/247/303/243o Isolada e Persist/303/252ncia em Ambientes Distribu/303/255dos.md" +116 -0
- package/docs/talks9/O Cen/303/241rio Pr/303/241tico do SADDLE_ Uma An/303/241lise de Viabilidade e Modelo de Ciclo de Vida Integrado.md" +128 -0
- package/docs/talks9/README (2).md +489 -0
- package/docs/talks9/README.md +198 -0
- package/docs/talks9/Viabilidade do Saddle_ Uma An/303/241lise T/303/251cnica da Transforma/303/247/303/243o de Armazenamento Remoto em Mem/303/263ria Computacional.md" +80 -0
- package/docs/talks9/conversa.txt +544 -0
- package/docs/talks9/other (2).md +39 -0
- package/docs/talks9/other.md +57 -0
- package/docs/talks9/outro.txt +24 -0
- package/extension/README.md +3 -3
- package/extension/serviceworker.js +63 -4
- package/extension/worker.js +3 -1
- package/package.json +12 -2
- package/packager/manifest.js +1 -1
- package/scrape/agent.ts +122 -0
- package/scrape/batch.ts +79 -0
- package/scrape/biome.json +76 -0
- package/scrape/browser.ts +222 -0
- package/scrape/cache.ts +84 -0
- package/scrape/chunking.ts +193 -0
- package/scrape/cli.ts +105 -0
- package/scrape/crawler.ts +115 -0
- package/scrape/dev-server.ts +94 -0
- package/scrape/errors.ts +132 -0
- package/scrape/events.ts +26 -0
- package/scrape/extract.ts +165 -0
- package/scrape/fetch.ts +105 -0
- package/scrape/formats.ts +85 -0
- package/scrape/headers.ts +71 -0
- package/scrape/index.ts +92 -0
- package/scrape/jsdom.d.ts +6 -0
- package/scrape/llms-txt.ts +84 -0
- package/scrape/middleware.ts +90 -0
- package/scrape/package-lock.json +9397 -0
- package/scrape/package.json +1420 -0
- package/scrape/pool.ts +95 -0
- package/scrape/port.ts +18 -0
- package/scrape/proxy.ts +103 -0
- package/scrape/rate-limiter.ts +95 -0
- package/scrape/renderer.ts +194 -0
- package/scrape/retry.ts +64 -0
- package/scrape/robots.ts +137 -0
- package/scrape/scrape.ts +123 -0
- package/scrape/serialize.ts +310 -0
- package/scrape/server.ts +137 -0
- package/scrape/session.ts +109 -0
- package/scrape/sitemap.ts +131 -0
- package/scrape/tokens.ts +45 -0
- package/scrape/tsconfig.json +28 -0
- package/scrape/types.ts +214 -0
- package/scrape/utils.ts +77 -0
- package/scrape/vite.config.ts +55 -0
- package/scrape/vitest.config.ts +17 -0
- package/workflow/templates.js +4 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ScrapeOptions, ScrapeResult, CrawlStats } from './types.js';
|
|
2
|
+
import { scrapeUrl } from './scrape.js';
|
|
3
|
+
|
|
4
|
+
export interface CrawlOptions extends ScrapeOptions {
|
|
5
|
+
maxDepth?: number;
|
|
6
|
+
maxPages?: number;
|
|
7
|
+
maxConcurrent?: number;
|
|
8
|
+
sameDomain?: boolean;
|
|
9
|
+
delayMs?: number;
|
|
10
|
+
onDiscover?: (url: string, depth: number) => void;
|
|
11
|
+
onResult?: (result: ScrapeResult) => void;
|
|
12
|
+
onError?: (url: string, error: Error) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CrawlEntry {
|
|
16
|
+
url: string;
|
|
17
|
+
depth: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isSameDomain(url1: string, url2: string): boolean {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(url1).hostname === new URL(url2).hostname;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeForDedup(url: string): string {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url);
|
|
31
|
+
u.hash = '';
|
|
32
|
+
u.search = '';
|
|
33
|
+
return u.href.replace(/\/+$/, '') || u.href;
|
|
34
|
+
} catch {
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function crawl(
|
|
40
|
+
startUrl: string,
|
|
41
|
+
options: CrawlOptions = {}
|
|
42
|
+
): Promise<{ results: ScrapeResult[]; stats: CrawlStats }> {
|
|
43
|
+
const {
|
|
44
|
+
maxDepth = 2,
|
|
45
|
+
maxPages = 50,
|
|
46
|
+
maxConcurrent = 3,
|
|
47
|
+
sameDomain = true,
|
|
48
|
+
delayMs = 1000,
|
|
49
|
+
onDiscover,
|
|
50
|
+
onResult,
|
|
51
|
+
onError,
|
|
52
|
+
...scrapeOpts
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
const startTime = Date.now();
|
|
56
|
+
const visited = new Set<string>();
|
|
57
|
+
const queue: CrawlEntry[] = [{ url: startUrl, depth: 0 }];
|
|
58
|
+
const results: ScrapeResult[] = [];
|
|
59
|
+
let failed = 0;
|
|
60
|
+
|
|
61
|
+
visited.add(normalizeForDedup(startUrl));
|
|
62
|
+
|
|
63
|
+
while (queue.length > 0 && results.length < maxPages) {
|
|
64
|
+
const batch = queue.splice(0, maxConcurrent);
|
|
65
|
+
const promises = batch.map(async (entry) => {
|
|
66
|
+
if (entry.depth > maxDepth) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = await scrapeUrl(entry.url, scrapeOpts);
|
|
70
|
+
results.push(result);
|
|
71
|
+
onResult?.(result);
|
|
72
|
+
|
|
73
|
+
// Extract links for further crawling
|
|
74
|
+
if (entry.depth < maxDepth) {
|
|
75
|
+
for (const link of result.links) {
|
|
76
|
+
const normalized = normalizeForDedup(link.href);
|
|
77
|
+
if (visited.has(normalized)) continue;
|
|
78
|
+
if (sameDomain && !isSameDomain(startUrl, link.href)) continue;
|
|
79
|
+
try {
|
|
80
|
+
new URL(link.href);
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
visited.add(normalized);
|
|
85
|
+
queue.push({ url: link.href, depth: entry.depth + 1 });
|
|
86
|
+
onDiscover?.(link.href, entry.depth + 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
failed++;
|
|
91
|
+
onError?.(entry.url, error as Error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await Promise.all(promises);
|
|
96
|
+
|
|
97
|
+
if (delayMs > 0 && queue.length > 0) {
|
|
98
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const duration = Date.now() - startTime;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
results,
|
|
106
|
+
stats: {
|
|
107
|
+
totalUrls: visited.size,
|
|
108
|
+
successful: results.length,
|
|
109
|
+
failed,
|
|
110
|
+
skipped: visited.size - results.length - failed,
|
|
111
|
+
duration,
|
|
112
|
+
avgResponseTime: results.length > 0 ? results.reduce((sum, r) => sum + r.duration, 0) / results.length : 0,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
|
|
4
|
+
import { scrapeUrl } from './scrape.js';
|
|
5
|
+
import { serializeResult } from './serialize.js';
|
|
6
|
+
import { resolveFormat, buildSerializeOptions } from './formats.js';
|
|
7
|
+
import { formatForAgent } from './agent.js';
|
|
8
|
+
import { randomPort } from './utils/port.js';
|
|
9
|
+
import type { ScrapeOptions } from './types.js';
|
|
10
|
+
|
|
11
|
+
const PORT = randomPort();
|
|
12
|
+
|
|
13
|
+
const server = createServer(async (req, res) => {
|
|
14
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
15
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
16
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
17
|
+
|
|
18
|
+
if (req.method === 'OPTIONS') {
|
|
19
|
+
res.writeHead(200);
|
|
20
|
+
res.end();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (req.method !== 'POST' || req.url !== '/api/scrape') {
|
|
25
|
+
res.writeHead(404);
|
|
26
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let body = '';
|
|
31
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
32
|
+
req.on('end', async () => {
|
|
33
|
+
try {
|
|
34
|
+
const { url, format: fmt, options = {} }: {
|
|
35
|
+
url: string;
|
|
36
|
+
format?: string;
|
|
37
|
+
options?: Record<string, unknown>;
|
|
38
|
+
} = JSON.parse(body);
|
|
39
|
+
if (!url) throw new Error('URL is required');
|
|
40
|
+
|
|
41
|
+
const format = resolveFormat(fmt || 'markdown');
|
|
42
|
+
const result = await scrapeUrl(url, {
|
|
43
|
+
timeout: 30000,
|
|
44
|
+
scroll: options.scroll as boolean,
|
|
45
|
+
extractLinks: (options.extractLinks as boolean) ?? true,
|
|
46
|
+
extractImages: (options.extractImages as boolean) ?? true,
|
|
47
|
+
extractTables: (options.extractTables as boolean) ?? true,
|
|
48
|
+
} as ScrapeOptions);
|
|
49
|
+
|
|
50
|
+
let response: Record<string, unknown>;
|
|
51
|
+
|
|
52
|
+
if (options.agent) {
|
|
53
|
+
const agentOutput = formatForAgent(result);
|
|
54
|
+
response = {
|
|
55
|
+
success: true,
|
|
56
|
+
title: result.title,
|
|
57
|
+
url: result.url,
|
|
58
|
+
duration: result.duration,
|
|
59
|
+
size: result.size,
|
|
60
|
+
agentOutput,
|
|
61
|
+
};
|
|
62
|
+
} else {
|
|
63
|
+
const serializeOpts = buildSerializeOptions(format);
|
|
64
|
+
const serialized = serializeResult(result, serializeOpts);
|
|
65
|
+
response = {
|
|
66
|
+
success: true,
|
|
67
|
+
title: result.title,
|
|
68
|
+
url: result.url,
|
|
69
|
+
format,
|
|
70
|
+
content: serialized.content,
|
|
71
|
+
duration: result.duration,
|
|
72
|
+
size: serialized.size,
|
|
73
|
+
metadata: result.metadata,
|
|
74
|
+
links: result.links,
|
|
75
|
+
images: result.images,
|
|
76
|
+
tables: result.tables,
|
|
77
|
+
result,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
|
+
res.end(JSON.stringify(response));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const error = err as Error;
|
|
85
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
server.listen(PORT, () => {
|
|
92
|
+
console.log(`API server running at http://localhost:${PORT}`);
|
|
93
|
+
console.log(`POST /api/scrape with { url, format?, options? }`);
|
|
94
|
+
});
|
package/scrape/errors.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const ErrorCode = {
|
|
2
|
+
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
|
3
|
+
INVALID_URL: 'INVALID_URL',
|
|
4
|
+
TIMEOUT: 'TIMEOUT',
|
|
5
|
+
BLOCKED: 'BLOCKED',
|
|
6
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
7
|
+
PROXY_ERROR: 'PROXY_ERROR',
|
|
8
|
+
PARSE_ERROR: 'PARSE_ERROR',
|
|
9
|
+
AUTH_REQUIRED: 'AUTH_REQUIRED',
|
|
10
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
11
|
+
BROWSER_NOT_AVAILABLE: 'BROWSER_NOT_AVAILABLE',
|
|
12
|
+
CRAWL_DEPTH_EXCEEDED: 'CRAWL_DEPTH_EXCEEDED',
|
|
13
|
+
MAX_RETRIES_EXCEEDED: 'MAX_RETRIES_EXCEEDED',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
|
|
17
|
+
|
|
18
|
+
export class WebScrapeError extends Error {
|
|
19
|
+
public readonly code: ErrorCode;
|
|
20
|
+
public readonly statusCode: number;
|
|
21
|
+
public readonly isRetryable: boolean;
|
|
22
|
+
public readonly timestamp: string;
|
|
23
|
+
public readonly details?: Record<string, unknown>;
|
|
24
|
+
public readonly cause?: Error;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
message: string,
|
|
28
|
+
code: ErrorCode,
|
|
29
|
+
statusCode: number,
|
|
30
|
+
isRetryable: boolean,
|
|
31
|
+
details?: Record<string, unknown>,
|
|
32
|
+
cause?: Error
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'WebScrapeError';
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.statusCode = statusCode;
|
|
38
|
+
this.isRetryable = isRetryable;
|
|
39
|
+
this.timestamp = new Date().toISOString();
|
|
40
|
+
this.details = details;
|
|
41
|
+
this.cause = cause;
|
|
42
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
43
|
+
Error.captureStackTrace?.(this, this.constructor);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
toJSON() {
|
|
47
|
+
return {
|
|
48
|
+
error: {
|
|
49
|
+
name: this.name,
|
|
50
|
+
code: this.code,
|
|
51
|
+
message: this.message,
|
|
52
|
+
statusCode: this.statusCode,
|
|
53
|
+
isRetryable: this.isRetryable,
|
|
54
|
+
timestamp: this.timestamp,
|
|
55
|
+
details: this.details,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ValidationError extends WebScrapeError {
|
|
62
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
63
|
+
super(message, ErrorCode.VALIDATION_FAILED, 400, false, details, cause);
|
|
64
|
+
this.name = 'ValidationError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class TimeoutError extends WebScrapeError {
|
|
69
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
70
|
+
super(message, ErrorCode.TIMEOUT, 504, true, details, cause);
|
|
71
|
+
this.name = 'TimeoutError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class BlockedError extends WebScrapeError {
|
|
76
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
77
|
+
super(message, ErrorCode.BLOCKED, 403, false, details, cause);
|
|
78
|
+
this.name = 'BlockedError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class RateLimitError extends WebScrapeError {
|
|
83
|
+
public readonly retryAfterMs?: number;
|
|
84
|
+
constructor(
|
|
85
|
+
message: string,
|
|
86
|
+
retryAfterMs?: number,
|
|
87
|
+
details?: Record<string, unknown>,
|
|
88
|
+
cause?: Error
|
|
89
|
+
) {
|
|
90
|
+
super(message, ErrorCode.RATE_LIMITED, 429, true, { ...details, retryAfterMs }, cause);
|
|
91
|
+
this.name = 'RateLimitError';
|
|
92
|
+
this.retryAfterMs = retryAfterMs;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class ProxyError extends WebScrapeError {
|
|
97
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
98
|
+
super(message, ErrorCode.PROXY_ERROR, 502, true, details, cause);
|
|
99
|
+
this.name = 'ProxyError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class ParseError extends WebScrapeError {
|
|
104
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
105
|
+
super(message, ErrorCode.PARSE_ERROR, 422, false, details, cause);
|
|
106
|
+
this.name = 'ParseError';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class AuthError extends WebScrapeError {
|
|
111
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
112
|
+
super(message, ErrorCode.AUTH_REQUIRED, 401, false, details, cause);
|
|
113
|
+
this.name = 'AuthError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class NetworkError extends WebScrapeError {
|
|
118
|
+
constructor(message: string, details?: Record<string, unknown>, cause?: Error) {
|
|
119
|
+
super(message, ErrorCode.NETWORK_ERROR, 503, true, details, cause);
|
|
120
|
+
this.name = 'NetworkError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class BrowserNotAvailableError extends WebScrapeError {
|
|
125
|
+
constructor(
|
|
126
|
+
message: string = 'Playwright is not installed. Install it with: npm install playwright',
|
|
127
|
+
details?: Record<string, unknown>
|
|
128
|
+
) {
|
|
129
|
+
super(message, ErrorCode.BROWSER_NOT_AVAILABLE, 500, false, details);
|
|
130
|
+
this.name = 'BrowserNotAvailableError';
|
|
131
|
+
}
|
|
132
|
+
}
|
package/scrape/events.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Emittery from 'emittery';
|
|
2
|
+
import type { ScrapeOptions } from './types.js';
|
|
3
|
+
|
|
4
|
+
export interface WebScrapeEvents {
|
|
5
|
+
'request:start': { url: string; options: ScrapeOptions; timestamp: number };
|
|
6
|
+
'request:response': { url: string; status: number; attempt: number; duration: number };
|
|
7
|
+
'request:error': { url: string; error: Error; attempt: number };
|
|
8
|
+
'request:retry': { url: string; attempt: number; maxRetries: number; delayMs: number };
|
|
9
|
+
'request:complete': { url: string; duration: number; success: boolean };
|
|
10
|
+
'cache:hit': { key: string };
|
|
11
|
+
'cache:miss': { key: string };
|
|
12
|
+
'cache:set': { key: string; ttlMs?: number };
|
|
13
|
+
'proxy:rotate': { proxy: string; reason: string };
|
|
14
|
+
'proxy:error': { proxy: string; error: Error };
|
|
15
|
+
'proxy:disabled': { proxy: string; reason: string };
|
|
16
|
+
'crawl:discover': { url: string; depth: number; parentUrl?: string };
|
|
17
|
+
'crawl:complete': { totalUrls: number; successful: number; failed: number; duration: number };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type EventEmitter = Emittery<WebScrapeEvents>;
|
|
21
|
+
|
|
22
|
+
export function createEventEmitter(): EventEmitter {
|
|
23
|
+
return new Emittery<WebScrapeEvents>();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const globalEmitter = createEventEmitter();
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import type { ExtractOptions, LinkInfo, ImageInfo, TableInfo, PageMetadata } from './types.js';
|
|
3
|
+
import { normalizeUrl, isInternalUrl } from './utils.js';
|
|
4
|
+
|
|
5
|
+
export interface ExtractedContent {
|
|
6
|
+
content: string;
|
|
7
|
+
text: string;
|
|
8
|
+
links: LinkInfo[];
|
|
9
|
+
images: ImageInfo[];
|
|
10
|
+
tables: TableInfo[];
|
|
11
|
+
metadata: PageMetadata;
|
|
12
|
+
jsonLd: unknown[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractReadableContent($: cheerio.CheerioAPI): string {
|
|
16
|
+
const articleSelectors = [
|
|
17
|
+
'article', '[role="main"]', 'main', '.post-content', '.article-content',
|
|
18
|
+
'.entry-content', '.content', '#content', '.prose', '.markdown-body',
|
|
19
|
+
];
|
|
20
|
+
for (const sel of articleSelectors) {
|
|
21
|
+
const el = $(sel).first();
|
|
22
|
+
if (el.length) return el.text().trim();
|
|
23
|
+
}
|
|
24
|
+
return $('body').text().trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractMetadata($: cheerio.CheerioAPI): PageMetadata {
|
|
28
|
+
const getMeta = (name: string): string | undefined => {
|
|
29
|
+
return $(`meta[name="${name}"], meta[property="${name}"]`).attr('content') || undefined;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
title: $('title').text() || getMeta('og:title') || '',
|
|
33
|
+
description: getMeta('description') || getMeta('og:description'),
|
|
34
|
+
favicon: $('link[rel="icon"]').attr('href') || $('link[rel="shortcut icon"]').attr('href'),
|
|
35
|
+
charset: $('meta[charset]').attr('charset') || $('meta[http-equiv="Content-Type"]').attr('content'),
|
|
36
|
+
language: $('html').attr('lang') || undefined,
|
|
37
|
+
author: getMeta('author'),
|
|
38
|
+
publishedDate: getMeta('article:published_time') || getMeta('date'),
|
|
39
|
+
ogImage: getMeta('og:image'),
|
|
40
|
+
ogType: getMeta('og:type'),
|
|
41
|
+
keywords: getMeta('keywords')?.split(',').map(k => k.trim()),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function extractJsonLd($: cheerio.CheerioAPI): unknown[] {
|
|
46
|
+
const results: unknown[] = [];
|
|
47
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse($(el).html() || '');
|
|
50
|
+
if (data['@graph']) {
|
|
51
|
+
results.push(...(Array.isArray(data['@graph']) ? data['@graph'] : [data['@graph']]));
|
|
52
|
+
} else {
|
|
53
|
+
results.push(data);
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
});
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractLinks($: cheerio.CheerioAPI, baseUrl: string): LinkInfo[] {
|
|
61
|
+
const links: LinkInfo[] = [];
|
|
62
|
+
$('a[href]').each((_, el) => {
|
|
63
|
+
const href = $(el).attr('href') || '';
|
|
64
|
+
const text = $(el).text().trim().slice(0, 200);
|
|
65
|
+
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
|
66
|
+
const fullUrl = normalizeUrl(href, baseUrl);
|
|
67
|
+
links.push({
|
|
68
|
+
href: fullUrl,
|
|
69
|
+
text: text || fullUrl,
|
|
70
|
+
isInternal: isInternalUrl(fullUrl, baseUrl),
|
|
71
|
+
isExternal: !isInternalUrl(fullUrl, baseUrl),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
return links;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractImages($: cheerio.CheerioAPI, baseUrl: string): ImageInfo[] {
|
|
78
|
+
const images: ImageInfo[] = [];
|
|
79
|
+
$('img[src]').each((_, el) => {
|
|
80
|
+
const src = normalizeUrl($(el).attr('src') || '', baseUrl);
|
|
81
|
+
const alt = $(el).attr('alt') || '';
|
|
82
|
+
const width = parseInt($(el).attr('width') || '') || undefined;
|
|
83
|
+
const height = parseInt($(el).attr('height') || '') || undefined;
|
|
84
|
+
if (src) images.push({ src, alt: alt.slice(0, 200), width, height });
|
|
85
|
+
});
|
|
86
|
+
return images;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function extractTables($: cheerio.CheerioAPI): TableInfo[] {
|
|
90
|
+
const tables: TableInfo[] = [];
|
|
91
|
+
$('table').each((_, table) => {
|
|
92
|
+
const headers: string[] = [];
|
|
93
|
+
const rows: string[][] = [];
|
|
94
|
+
$('thead tr th, thead tr td', table).each((_, th) => {
|
|
95
|
+
headers.push($(th).text().trim());
|
|
96
|
+
});
|
|
97
|
+
$('tbody tr, > tr', table).each((_, tr) => {
|
|
98
|
+
if ($(tr).parent().is('thead')) return;
|
|
99
|
+
const row: string[] = [];
|
|
100
|
+
$('td, th', tr).each((_, td) => {
|
|
101
|
+
row.push($(td).text().trim());
|
|
102
|
+
});
|
|
103
|
+
if (row.length) rows.push(row);
|
|
104
|
+
});
|
|
105
|
+
const caption = $('caption', table).text().trim() || undefined;
|
|
106
|
+
if (rows.length) tables.push({ headers, rows, caption });
|
|
107
|
+
});
|
|
108
|
+
return tables;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function htmlToText(html: string): string {
|
|
112
|
+
return html
|
|
113
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
114
|
+
.replace(/<\/p>/gi, '\n\n')
|
|
115
|
+
.replace(/<\/div>/gi, '\n')
|
|
116
|
+
.replace(/<\/h[1-6]>/gi, '\n')
|
|
117
|
+
.replace(/<\/li>/gi, '\n')
|
|
118
|
+
.replace(/<[^>]+>/g, '')
|
|
119
|
+
.replace(/ /g, ' ')
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>')
|
|
123
|
+
.replace(/"/g, '"')
|
|
124
|
+
.replace(/'/g, "'")
|
|
125
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
126
|
+
.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function extractContent(html: string, options: ExtractOptions = {}): Promise<ExtractedContent> {
|
|
130
|
+
const $ = cheerio.load(html);
|
|
131
|
+
const baseUrl = $('base').attr('href') || '';
|
|
132
|
+
|
|
133
|
+
if (options.removeSelectors?.length) {
|
|
134
|
+
for (const sel of options.removeSelectors) {
|
|
135
|
+
$(sel).remove();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const metadata = extractMetadata($);
|
|
140
|
+
const jsonLd = extractJsonLd($);
|
|
141
|
+
const links = options.preserveLinks !== false ? extractLinks($, baseUrl) : [];
|
|
142
|
+
const images = options.preserveImages !== false ? extractImages($, baseUrl) : [];
|
|
143
|
+
const tables = options.preserveTables !== false ? extractTables($) : [];
|
|
144
|
+
|
|
145
|
+
const text = options.readable
|
|
146
|
+
? extractReadableContent($)
|
|
147
|
+
: $('body').text().trim();
|
|
148
|
+
|
|
149
|
+
const content = htmlToText(text);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
content: options.maxLength ? content.slice(0, options.maxLength) : content,
|
|
153
|
+
text,
|
|
154
|
+
links,
|
|
155
|
+
images,
|
|
156
|
+
tables,
|
|
157
|
+
metadata,
|
|
158
|
+
jsonLd,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function extractReadable(html: string): Promise<string> {
|
|
163
|
+
const result = await extractContent(html, { readable: true });
|
|
164
|
+
return result.content;
|
|
165
|
+
}
|
package/scrape/fetch.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import type { ScrapeOptions } from './types.js';
|
|
3
|
+
import { getHeaders, getRandomProfile } from './headers.js';
|
|
4
|
+
import { WebScrapeError, ErrorCode } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export interface FetchResult {
|
|
7
|
+
html: string;
|
|
8
|
+
url: string;
|
|
9
|
+
status: number;
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
duration: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function fetchHtml(url: string, options: {
|
|
15
|
+
timeout?: number;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
userAgent?: string;
|
|
18
|
+
proxy?: string;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
} = {}): Promise<FetchResult> {
|
|
21
|
+
const startTime = Date.now();
|
|
22
|
+
const profile = getRandomProfile();
|
|
23
|
+
const baseHeaders = getHeaders(profile);
|
|
24
|
+
const headers = { ...baseHeaders, ...options.headers };
|
|
25
|
+
if (options.userAgent) headers['User-Agent'] = options.userAgent;
|
|
26
|
+
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout || 30000);
|
|
29
|
+
const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(url, {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers,
|
|
35
|
+
signal,
|
|
36
|
+
redirect: 'follow',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
clearTimeout(timeoutId);
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new WebScrapeError(
|
|
43
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
44
|
+
response.status === 403 ? ErrorCode.BLOCKED :
|
|
45
|
+
response.status === 429 ? ErrorCode.RATE_LIMITED :
|
|
46
|
+
response.status >= 500 ? ErrorCode.NETWORK_ERROR :
|
|
47
|
+
ErrorCode.NETWORK_ERROR,
|
|
48
|
+
response.status,
|
|
49
|
+
[408, 429, 500, 502, 503, 504].includes(response.status),
|
|
50
|
+
{ url, status: response.status }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const html = await response.text();
|
|
55
|
+
const responseHeaders: Record<string, string> = {};
|
|
56
|
+
response.headers.forEach((value, key) => { responseHeaders[key] = value; });
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
html,
|
|
60
|
+
url: response.url || url,
|
|
61
|
+
status: response.status,
|
|
62
|
+
headers: responseHeaders,
|
|
63
|
+
duration: Date.now() - startTime,
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
clearTimeout(timeoutId);
|
|
67
|
+
if (error instanceof WebScrapeError) throw error;
|
|
68
|
+
if ((error as Error).name === 'AbortError') {
|
|
69
|
+
throw new WebScrapeError(
|
|
70
|
+
`Request timeout after ${options.timeout || 30000}ms`,
|
|
71
|
+
ErrorCode.TIMEOUT,
|
|
72
|
+
504,
|
|
73
|
+
true,
|
|
74
|
+
{ url, timeout: options.timeout || 30000 }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw new WebScrapeError(
|
|
78
|
+
`Network error: ${(error as Error).message}`,
|
|
79
|
+
ErrorCode.NETWORK_ERROR,
|
|
80
|
+
503,
|
|
81
|
+
true,
|
|
82
|
+
{ url },
|
|
83
|
+
error as Error
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function fetchAndParse(url: string, options: Partial<ScrapeOptions> = {}): Promise<FetchResult> {
|
|
89
|
+
return fetchHtml(url, {
|
|
90
|
+
timeout: options.timeout,
|
|
91
|
+
headers: options.headers,
|
|
92
|
+
userAgent: options.userAgent,
|
|
93
|
+
proxy: options.proxy,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function detectRenderingMode(html: string): 'static' | 'spa' | 'hydrated' {
|
|
98
|
+
if (/<div id=["'](?:root|app|__next|__nuxt)["']>\s*<\/div>/.test(html)) return 'spa';
|
|
99
|
+
if (html.includes('__NEXT_DATA__') || html.includes('__NUXT__') || html.includes('__VUE_SSR_DATA__')) return 'hydrated';
|
|
100
|
+
const $ = cheerio.load(html);
|
|
101
|
+
if ($('script[type="application/ld+json"]').length > 0) return 'static';
|
|
102
|
+
const textContent = $.text().replace(/\s+/g, ' ').trim();
|
|
103
|
+
if (textContent.length > 500) return 'static';
|
|
104
|
+
return 'spa';
|
|
105
|
+
}
|