@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
package/scrape/pool.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AgentBrowser } from './browser.js';
|
|
2
|
+
import type { BrowserAgentConfig } from './types.js';
|
|
3
|
+
|
|
4
|
+
export interface PoolConfig {
|
|
5
|
+
maxSize?: number;
|
|
6
|
+
acquireTimeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface PoolEntry {
|
|
10
|
+
browser: AgentBrowser;
|
|
11
|
+
inUse: boolean;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class BrowserPool {
|
|
16
|
+
private pool: PoolEntry[] = [];
|
|
17
|
+
private waitQueue: Array<(browser: AgentBrowser) => void> = [];
|
|
18
|
+
private config: Required<PoolConfig>;
|
|
19
|
+
private browserConfig: BrowserAgentConfig;
|
|
20
|
+
|
|
21
|
+
constructor(config: PoolConfig = {}, browserConfig: BrowserAgentConfig = {}) {
|
|
22
|
+
this.config = {
|
|
23
|
+
maxSize: config.maxSize || 3,
|
|
24
|
+
acquireTimeoutMs: config.acquireTimeoutMs || 30000,
|
|
25
|
+
};
|
|
26
|
+
this.browserConfig = browserConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async acquire(): Promise<AgentBrowser> {
|
|
30
|
+
const idle = this.pool.find(e => !e.inUse && e.browser.isConnected());
|
|
31
|
+
if (idle) {
|
|
32
|
+
idle.inUse = true;
|
|
33
|
+
return idle.browser;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (this.pool.length < this.config.maxSize) {
|
|
37
|
+
const browser = new AgentBrowser(this.browserConfig);
|
|
38
|
+
await browser.launch();
|
|
39
|
+
const entry: PoolEntry = { browser, inUse: true, createdAt: Date.now() };
|
|
40
|
+
this.pool.push(entry);
|
|
41
|
+
return browser;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return new Promise<AgentBrowser>((resolve, reject) => {
|
|
45
|
+
const timeout = setTimeout(() => {
|
|
46
|
+
const idx = this.waitQueue.indexOf(waiter);
|
|
47
|
+
if (idx !== -1) this.waitQueue.splice(idx, 1);
|
|
48
|
+
reject(new Error('Timeout waiting for browser'));
|
|
49
|
+
}, this.config.acquireTimeoutMs);
|
|
50
|
+
|
|
51
|
+
const waiter = (browser: AgentBrowser) => {
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
resolve(browser);
|
|
54
|
+
};
|
|
55
|
+
this.waitQueue.push(waiter);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
release(browser: AgentBrowser): void {
|
|
60
|
+
const entry = this.pool.find(e => e.browser === browser);
|
|
61
|
+
if (entry) {
|
|
62
|
+
entry.inUse = false;
|
|
63
|
+
|
|
64
|
+
if (this.waitQueue.length > 0) {
|
|
65
|
+
const waiter = this.waitQueue.shift()!;
|
|
66
|
+
entry.inUse = true;
|
|
67
|
+
waiter(browser);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async destroy(): Promise<void> {
|
|
73
|
+
for (const entry of this.pool) {
|
|
74
|
+
await entry.browser.close().catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
this.pool = [];
|
|
77
|
+
for (const waiter of this.waitQueue) {
|
|
78
|
+
waiter(new AgentBrowser());
|
|
79
|
+
}
|
|
80
|
+
this.waitQueue = [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getStatus(): { total: number; idle: number; inUse: number; waiting: number } {
|
|
84
|
+
return {
|
|
85
|
+
total: this.pool.length,
|
|
86
|
+
idle: this.pool.filter(e => !e.inUse).length,
|
|
87
|
+
inUse: this.pool.filter(e => e.inUse).length,
|
|
88
|
+
waiting: this.waitQueue.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createPool(config?: PoolConfig, browserConfig?: BrowserAgentConfig): BrowserPool {
|
|
94
|
+
return new BrowserPool(config, browserConfig);
|
|
95
|
+
}
|
package/scrape/port.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a random port number in the ephemeral port range (1024-65535).
|
|
3
|
+
* The port is calculated once and then fixed for the lifetime of the process.
|
|
4
|
+
*/
|
|
5
|
+
let _cachedPort: number | null = null;
|
|
6
|
+
|
|
7
|
+
export function randomPort(): number {
|
|
8
|
+
if (_cachedPort !== null) return _cachedPort;
|
|
9
|
+
_cachedPort = Math.floor(Math.random() * 64511) + 1024;
|
|
10
|
+
return _cachedPort;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reset the cached port (for testing).
|
|
15
|
+
*/
|
|
16
|
+
export function resetPort(): void {
|
|
17
|
+
_cachedPort = null;
|
|
18
|
+
}
|
package/scrape/proxy.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ProxyConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
interface ProxyStats {
|
|
4
|
+
failures: number;
|
|
5
|
+
successes: number;
|
|
6
|
+
consecutiveFailures: number;
|
|
7
|
+
lastFailure?: number;
|
|
8
|
+
lastSuccess?: number;
|
|
9
|
+
disabled: boolean;
|
|
10
|
+
disabledAt?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ProxyRotationStrategy = 'round-robin' | 'random';
|
|
14
|
+
|
|
15
|
+
export interface ProxyPoolConfig {
|
|
16
|
+
maxFailures?: number;
|
|
17
|
+
reviveAfterMs?: number;
|
|
18
|
+
strategy?: ProxyRotationStrategy;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_POOL_CONFIG: Required<ProxyPoolConfig> = {
|
|
22
|
+
maxFailures: 3,
|
|
23
|
+
reviveAfterMs: 30 * 60 * 1000,
|
|
24
|
+
strategy: 'round-robin',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class ProxyPool {
|
|
28
|
+
private proxies: ProxyConfig[];
|
|
29
|
+
private stats = new Map<string, ProxyStats>();
|
|
30
|
+
private currentIndex = 0;
|
|
31
|
+
private config: Required<ProxyPoolConfig>;
|
|
32
|
+
|
|
33
|
+
constructor(proxies: ProxyConfig[], config: Partial<ProxyPoolConfig> = {}) {
|
|
34
|
+
this.proxies = proxies;
|
|
35
|
+
this.config = { ...DEFAULT_POOL_CONFIG, ...config };
|
|
36
|
+
for (const p of proxies) {
|
|
37
|
+
this.stats.set(p.url, { failures: 0, successes: 0, consecutiveFailures: 0, disabled: false });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private getActiveProxies(): ProxyConfig[] {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
return this.proxies.filter(p => {
|
|
44
|
+
const s = this.stats.get(p.url);
|
|
45
|
+
if (!s) return true;
|
|
46
|
+
if (s.disabled && s.disabledAt && (now - s.disabledAt) >= this.config.reviveAfterMs) {
|
|
47
|
+
s.disabled = false;
|
|
48
|
+
s.failures = 0;
|
|
49
|
+
s.consecutiveFailures = 0;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return !s.disabled;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
next(): ProxyConfig | null {
|
|
57
|
+
const active = this.getActiveProxies();
|
|
58
|
+
if (active.length === 0) return null;
|
|
59
|
+
|
|
60
|
+
if (this.config.strategy === 'random') {
|
|
61
|
+
return active[Math.floor(Math.random() * active.length)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const proxy = active[this.currentIndex % active.length];
|
|
65
|
+
this.currentIndex = (this.currentIndex + 1) % active.length;
|
|
66
|
+
return proxy;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
reportSuccess(proxy: ProxyConfig): void {
|
|
70
|
+
const s = this.stats.get(proxy.url);
|
|
71
|
+
if (s) {
|
|
72
|
+
s.successes++;
|
|
73
|
+
s.consecutiveFailures = 0;
|
|
74
|
+
s.lastSuccess = Date.now();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
reportFailure(proxy: ProxyConfig): void {
|
|
79
|
+
const s = this.stats.get(proxy.url);
|
|
80
|
+
if (s) {
|
|
81
|
+
s.failures++;
|
|
82
|
+
s.consecutiveFailures++;
|
|
83
|
+
s.lastFailure = Date.now();
|
|
84
|
+
if (s.consecutiveFailures >= this.config.maxFailures) {
|
|
85
|
+
s.disabled = true;
|
|
86
|
+
s.disabledAt = Date.now();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getStatus(): { total: number; active: number; disabled: number } {
|
|
92
|
+
const active = this.getActiveProxies();
|
|
93
|
+
return {
|
|
94
|
+
total: this.proxies.length,
|
|
95
|
+
active: active.length,
|
|
96
|
+
disabled: this.proxies.length - active.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createProxyPool(proxies: ProxyConfig[], config?: Partial<ProxyPoolConfig>): ProxyPool {
|
|
102
|
+
return new ProxyPool(proxies, config);
|
|
103
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
interface TokenBucket {
|
|
2
|
+
tokens: number;
|
|
3
|
+
lastRefill: number;
|
|
4
|
+
maxTokens: number;
|
|
5
|
+
refillRate: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RateLimiterConfig {
|
|
9
|
+
maxTokensPerInterval: number;
|
|
10
|
+
intervalMs: number;
|
|
11
|
+
maxConcurrent?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CONFIG: Required<RateLimiterConfig> = {
|
|
15
|
+
maxTokensPerInterval: 10,
|
|
16
|
+
intervalMs: 1000,
|
|
17
|
+
maxConcurrent: 5,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class RateLimiter {
|
|
21
|
+
private buckets = new Map<string, TokenBucket>();
|
|
22
|
+
private active = new Map<string, number>();
|
|
23
|
+
private config: Required<RateLimiterConfig>;
|
|
24
|
+
|
|
25
|
+
constructor(config: Partial<RateLimiterConfig> = {}) {
|
|
26
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private getBucket(key: string): TokenBucket {
|
|
30
|
+
if (!this.buckets.has(key)) {
|
|
31
|
+
this.buckets.set(key, {
|
|
32
|
+
tokens: this.config.maxTokensPerInterval,
|
|
33
|
+
lastRefill: Date.now(),
|
|
34
|
+
maxTokens: this.config.maxTokensPerInterval,
|
|
35
|
+
refillRate: this.config.maxTokensPerInterval / (this.config.intervalMs / 1000),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return this.buckets.get(key)!;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private refill(bucket: TokenBucket): void {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const elapsed = (now - bucket.lastRefill) / 1000;
|
|
44
|
+
bucket.tokens = Math.min(bucket.maxTokens, bucket.tokens + elapsed * bucket.refillRate);
|
|
45
|
+
bucket.lastRefill = now;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async acquire(key: string): Promise<void> {
|
|
49
|
+
const bucket = this.getBucket(key);
|
|
50
|
+
const currentActive = this.active.get(key) || 0;
|
|
51
|
+
|
|
52
|
+
if (currentActive >= this.config.maxConcurrent) {
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
54
|
+
return this.acquire(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.refill(bucket);
|
|
58
|
+
|
|
59
|
+
if (bucket.tokens < 1) {
|
|
60
|
+
const waitMs = ((1 - bucket.tokens) / bucket.refillRate) * 1000;
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, Math.ceil(waitMs)));
|
|
62
|
+
this.refill(bucket);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
bucket.tokens -= 1;
|
|
66
|
+
this.active.set(key, currentActive + 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
release(key: string): void {
|
|
70
|
+
const current = this.active.get(key) || 0;
|
|
71
|
+
if (current <= 1) {
|
|
72
|
+
this.active.delete(key);
|
|
73
|
+
} else {
|
|
74
|
+
this.active.set(key, current - 1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getStats(key: string): { tokens: number; active: number } {
|
|
79
|
+
const bucket = this.buckets.get(key);
|
|
80
|
+
if (bucket) this.refill(bucket);
|
|
81
|
+
return {
|
|
82
|
+
tokens: bucket?.tokens ?? this.config.maxTokensPerInterval,
|
|
83
|
+
active: this.active.get(key) || 0,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.buckets.clear();
|
|
89
|
+
this.active.clear();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createRateLimiter(config?: Partial<RateLimiterConfig>): RateLimiter {
|
|
94
|
+
return new RateLimiter(config);
|
|
95
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
interface PygameConfig {
|
|
6
|
+
pythonPath?: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
fps?: number;
|
|
10
|
+
fullscreen?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PYGAME_CONFIG: Required<PygameConfig> = {
|
|
14
|
+
pythonPath: 'python',
|
|
15
|
+
width: 1280,
|
|
16
|
+
height: 720,
|
|
17
|
+
fps: 30,
|
|
18
|
+
fullscreen: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function generatePygameScript(
|
|
22
|
+
imagePath: string,
|
|
23
|
+
config: Required<PygameConfig>,
|
|
24
|
+
interactive = false
|
|
25
|
+
): string {
|
|
26
|
+
const lines = [
|
|
27
|
+
'import sys, json',
|
|
28
|
+
'import pygame',
|
|
29
|
+
'pygame.init()',
|
|
30
|
+
`screen = pygame.display.set_mode((${config.width}, ${config.height})${config.fullscreen ? ', pygame.FULLSCREEN' : ''})`,
|
|
31
|
+
'pygame.display.set_caption("DevThink WebScrape Renderer")',
|
|
32
|
+
`clock = pygame.time.Clock()`,
|
|
33
|
+
'',
|
|
34
|
+
'try:',
|
|
35
|
+
` img = pygame.image.load(${JSON.stringify(imagePath)})`,
|
|
36
|
+
' img_rect = img.get_rect(center=(screen.get_width()//2, screen.get_height()//2))',
|
|
37
|
+
'except:',
|
|
38
|
+
' img = None',
|
|
39
|
+
'',
|
|
40
|
+
'font = pygame.font.Font(None, 36)',
|
|
41
|
+
'',
|
|
42
|
+
'running = True',
|
|
43
|
+
'while running:',
|
|
44
|
+
' for event in pygame.event.get():',
|
|
45
|
+
' if event.type == pygame.QUIT:',
|
|
46
|
+
' running = False',
|
|
47
|
+
' elif event.type == pygame.KEYDOWN:',
|
|
48
|
+
' if event.key == pygame.K_ESCAPE:',
|
|
49
|
+
' running = False',
|
|
50
|
+
...(interactive ? [
|
|
51
|
+
' elif event.key == pygame.K_SPACE:',
|
|
52
|
+
' print(json.dumps({"action": "next"}))',
|
|
53
|
+
' sys.stdout.flush()',
|
|
54
|
+
] : []),
|
|
55
|
+
'',
|
|
56
|
+
' screen.fill((30, 30, 40))',
|
|
57
|
+
' if img:',
|
|
58
|
+
' screen.blit(img, img_rect)',
|
|
59
|
+
' else:',
|
|
60
|
+
' text = font.render("No screenshot available", True, (200, 200, 200))',
|
|
61
|
+
' screen.blit(text, (config.width//2 - text.get_width()//2, config.height//2))',
|
|
62
|
+
'',
|
|
63
|
+
' pygame.display.flip()',
|
|
64
|
+
' clock.tick(30)',
|
|
65
|
+
'',
|
|
66
|
+
'pygame.quit()',
|
|
67
|
+
];
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function generateDoomScript(
|
|
72
|
+
config: Required<PygameConfig>
|
|
73
|
+
): string {
|
|
74
|
+
const lines = [
|
|
75
|
+
'import sys, json, asyncio, subprocess',
|
|
76
|
+
'import pygame',
|
|
77
|
+
'pygame.init()',
|
|
78
|
+
`screen = pygame.display.set_mode((${config.width}, ${config.height})${config.fullscreen ? ', pygame.FULLSCREEN' : ''})`,
|
|
79
|
+
'pygame.display.set_caption("DevThink WebScrape - DOOM")',
|
|
80
|
+
'',
|
|
81
|
+
'font = pygame.font.Font(None, 24)',
|
|
82
|
+
'small_font = pygame.font.Font(None, 18)',
|
|
83
|
+
'',
|
|
84
|
+
'frames = []',
|
|
85
|
+
'current_frame = 0',
|
|
86
|
+
'running = True',
|
|
87
|
+
'',
|
|
88
|
+
'def load_frames(path):',
|
|
89
|
+
' import os',
|
|
90
|
+
' frames_dir = os.path.expanduser(path)',
|
|
91
|
+
' if os.path.isdir(frames_dir):',
|
|
92
|
+
' files = sorted(os.listdir(frames_dir))',
|
|
93
|
+
' for f in files:',
|
|
94
|
+
' if f.endswith((".png", ".jpg", ".jpeg")):',
|
|
95
|
+
' try:',
|
|
96
|
+
' img = pygame.image.load(os.path.join(frames_dir, f))',
|
|
97
|
+
' frames.append(img)',
|
|
98
|
+
' except: pass',
|
|
99
|
+
'',
|
|
100
|
+
'load_frames("~/devthink/webscrape/doom_frames")',
|
|
101
|
+
'',
|
|
102
|
+
'while running:',
|
|
103
|
+
' for event in pygame.event.get():',
|
|
104
|
+
' if event.type == pygame.QUIT:',
|
|
105
|
+
' running = False',
|
|
106
|
+
' elif event.type == pygame.KEYDOWN:',
|
|
107
|
+
' if event.key == pygame.K_ESCAPE: running = False',
|
|
108
|
+
' elif event.key == pygame.K_LEFT: current_frame = max(0, current_frame - 1)',
|
|
109
|
+
' elif event.key == pygame.K_RIGHT: current_frame = min(len(frames)-1, current_frame + 1)',
|
|
110
|
+
' elif event.key == pygame.K_SPACE:',
|
|
111
|
+
' out = {"action": "frame", "index": current_frame, "total": len(frames)}',
|
|
112
|
+
' print(json.dumps(out))',
|
|
113
|
+
' sys.stdout.flush()',
|
|
114
|
+
'',
|
|
115
|
+
' screen.fill((20, 20, 30))',
|
|
116
|
+
' if frames and current_frame < len(frames):',
|
|
117
|
+
' img = pygame.transform.scale(frames[current_frame], (config.width, config.height - 60))',
|
|
118
|
+
' screen.blit(img, (0, 0))',
|
|
119
|
+
' else:',
|
|
120
|
+
' text = font.render("No DOOM frames loaded", True, (200, 80, 80))',
|
|
121
|
+
' screen.blit(text, (config.width//2 - 150, config.height//2))',
|
|
122
|
+
'',
|
|
123
|
+
' info = f"Frame {current_frame+1}/{len(frames)} | ESC: quit | <- ->: navigate | SPACE: capture"',
|
|
124
|
+
' info_surf = small_font.render(info, True, (180, 180, 180))',
|
|
125
|
+
' screen.blit(info_surf, (10, config.height - 40))',
|
|
126
|
+
'',
|
|
127
|
+
' pygame.display.flip()',
|
|
128
|
+
' clock.tick(30)',
|
|
129
|
+
'',
|
|
130
|
+
'pygame.quit()',
|
|
131
|
+
];
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class PygameRenderer {
|
|
136
|
+
private config: Required<PygameConfig>;
|
|
137
|
+
private process: ChildProcess | null = null;
|
|
138
|
+
private tempDir: string;
|
|
139
|
+
private screenDir: string;
|
|
140
|
+
|
|
141
|
+
constructor(config: PygameConfig = {}) {
|
|
142
|
+
this.config = { ...DEFAULT_PYGAME_CONFIG, ...config };
|
|
143
|
+
this.tempDir = join(process.cwd(), '.webscrape');
|
|
144
|
+
this.screenDir = join(this.tempDir, 'screenshots');
|
|
145
|
+
if (!existsSync(this.tempDir)) mkdirSync(this.tempDir, { recursive: true });
|
|
146
|
+
if (!existsSync(this.screenDir)) mkdirSync(this.screenDir, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async renderScreenshot(imageBuffer: Buffer): Promise<void> {
|
|
150
|
+
const imagePath = join(this.screenDir, 'current.png');
|
|
151
|
+
writeFileSync(imagePath, imageBuffer);
|
|
152
|
+
|
|
153
|
+
const script = generatePygameScript(imagePath, this.config);
|
|
154
|
+
const scriptPath = join(this.tempDir, 'render_screenshot.py');
|
|
155
|
+
writeFileSync(scriptPath, script);
|
|
156
|
+
|
|
157
|
+
await this.runScript(scriptPath);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async renderDoom(): Promise<void> {
|
|
161
|
+
const script = generateDoomScript(this.config);
|
|
162
|
+
const scriptPath = join(this.tempDir, 'render_doom.py');
|
|
163
|
+
writeFileSync(scriptPath, script);
|
|
164
|
+
|
|
165
|
+
await this.runScript(scriptPath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private runScript(scriptPath: string): Promise<void> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
this.process = spawn(this.config.pythonPath, [scriptPath], {
|
|
171
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.process.on('close', code => {
|
|
175
|
+
this.process = null;
|
|
176
|
+
if (code === 0) resolve();
|
|
177
|
+
else reject(new Error(`Pygame exited with code ${code}`));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.process.on('error', err => reject(err));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
close(): void {
|
|
185
|
+
if (this.process && !this.process.killed) {
|
|
186
|
+
this.process.kill();
|
|
187
|
+
this.process = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function createRenderer(config?: PygameConfig): PygameRenderer {
|
|
193
|
+
return new PygameRenderer(config);
|
|
194
|
+
}
|
package/scrape/retry.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import pRetry, { AbortError } from "p-retry";
|
|
2
|
+
import type { RetryConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
|
|
5
|
+
retries: 3,
|
|
6
|
+
delay: 1000,
|
|
7
|
+
backoff: "exponential",
|
|
8
|
+
maxDelay: 30000,
|
|
9
|
+
statusCodes: [408, 429, 500, 502, 503, 504],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const RETRYABLE_NETWORK_CODES = ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EPIPE", "ENOTFOUND"];
|
|
13
|
+
|
|
14
|
+
export function isRetryableError(error: Error): boolean {
|
|
15
|
+
const code = (error as any).code;
|
|
16
|
+
if (code && RETRYABLE_NETWORK_CODES.includes(code)) return true;
|
|
17
|
+
const status = (error as any).statusCode || (error as any).status;
|
|
18
|
+
if (status && [408, 429, 500, 502, 503, 504].includes(status)) return true;
|
|
19
|
+
if (error.name === "AbortError") return false;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function calculateDelay(attempt: number, config: Required<RetryConfig>): number {
|
|
24
|
+
switch (config.backoff) {
|
|
25
|
+
case "exponential":
|
|
26
|
+
return Math.min(config.delay * 2 ** attempt, config.maxDelay);
|
|
27
|
+
case "linear":
|
|
28
|
+
return Math.min(config.delay * (attempt + 1), config.maxDelay);
|
|
29
|
+
case "constant":
|
|
30
|
+
return config.delay;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function withRetry<T>(
|
|
35
|
+
fn: (attemptNumber: number) => Promise<T>,
|
|
36
|
+
config: Partial<RetryConfig> = {},
|
|
37
|
+
onRetry?: (error: Error, attempt: number) => void,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const cfg = { ...DEFAULT_RETRY_CONFIG, ...config };
|
|
40
|
+
|
|
41
|
+
return pRetry(
|
|
42
|
+
async (attemptNumber) => {
|
|
43
|
+
try {
|
|
44
|
+
return await fn(attemptNumber);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!isRetryableError(error as Error)) {
|
|
47
|
+
throw new AbortError((error as Error).message);
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
retries: cfg.retries,
|
|
54
|
+
minTimeout: calculateDelay(0, cfg),
|
|
55
|
+
maxTimeout: cfg.maxDelay,
|
|
56
|
+
randomize: cfg.backoff === "exponential",
|
|
57
|
+
onFailedAttempt: (context) => {
|
|
58
|
+
onRetry?.(context.error, context.attemptNumber);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { AbortError };
|
package/scrape/robots.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export interface RobotsDirective {
|
|
2
|
+
userAgent: string;
|
|
3
|
+
allow: string[];
|
|
4
|
+
disallow: string[];
|
|
5
|
+
crawlDelay?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RobotsTxt {
|
|
9
|
+
directives: RobotsDirective[];
|
|
10
|
+
sitemaps: string[];
|
|
11
|
+
crawlDelay?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const cache = new Map<string, { robots: RobotsTxt; fetchedAt: number }>();
|
|
15
|
+
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function parseRobotsTxt(content: string): RobotsTxt {
|
|
18
|
+
const lines = content.split('\n').map(l => l.trim());
|
|
19
|
+
const directives: RobotsDirective[] = [];
|
|
20
|
+
const sitemaps: string[] = [];
|
|
21
|
+
let current: RobotsDirective | null = null;
|
|
22
|
+
let globalCrawlDelay: number | undefined;
|
|
23
|
+
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
if (!line || line.startsWith('#')) continue;
|
|
26
|
+
|
|
27
|
+
const colonIndex = line.indexOf(':');
|
|
28
|
+
if (colonIndex === -1) continue;
|
|
29
|
+
|
|
30
|
+
const key = line.slice(0, colonIndex).trim().toLowerCase();
|
|
31
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
32
|
+
|
|
33
|
+
if (key === 'user-agent') {
|
|
34
|
+
current = { userAgent: value, allow: [], disallow: [] };
|
|
35
|
+
directives.push(current);
|
|
36
|
+
} else if (key === 'allow' && current) {
|
|
37
|
+
current.allow.push(value);
|
|
38
|
+
} else if (key === 'disallow' && current) {
|
|
39
|
+
if (value) current.disallow.push(value);
|
|
40
|
+
} else if (key === 'crawl-delay' && current) {
|
|
41
|
+
const delay = parseInt(value);
|
|
42
|
+
if (!isNaN(delay)) {
|
|
43
|
+
current.crawlDelay = delay;
|
|
44
|
+
globalCrawlDelay = delay;
|
|
45
|
+
}
|
|
46
|
+
} else if (key === 'sitemap') {
|
|
47
|
+
sitemaps.push(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { directives, sitemaps, crawlDelay: globalCrawlDelay };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function matchUserAgent(pattern: string, userAgent: string): boolean {
|
|
55
|
+
const lowerPattern = pattern.toLowerCase();
|
|
56
|
+
const lowerUA = userAgent.toLowerCase();
|
|
57
|
+
if (lowerPattern === '*') return true;
|
|
58
|
+
return lowerUA.includes(lowerPattern.replace(/\*/g, ''));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPathAllowed(path: string, directive: RobotsDirective): boolean {
|
|
62
|
+
// Check disallow rules first
|
|
63
|
+
for (const pattern of directive.disallow) {
|
|
64
|
+
if (pattern === '/') return false;
|
|
65
|
+
if (pattern && path.startsWith(pattern)) return false;
|
|
66
|
+
}
|
|
67
|
+
// Then check allow rules
|
|
68
|
+
for (const pattern of directive.allow) {
|
|
69
|
+
if (pattern === '/') return true;
|
|
70
|
+
if (pattern && path.startsWith(pattern)) return true;
|
|
71
|
+
}
|
|
72
|
+
// Default: allowed
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function fetchRobotsTxt(siteUrl: string, options: { timeout?: number; userAgent?: string } = {}): Promise<RobotsTxt> {
|
|
77
|
+
const base = siteUrl.replace(/\/$/, '');
|
|
78
|
+
const robotsUrl = `${base}/robots.txt`;
|
|
79
|
+
const userAgent = options.userAgent || 'DevThink-WebScrape/2.0';
|
|
80
|
+
|
|
81
|
+
const cached = cache.get(robotsUrl);
|
|
82
|
+
if (cached && (Date.now() - cached.fetchedAt) < CACHE_TTL) {
|
|
83
|
+
return cached.robots;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(robotsUrl, {
|
|
88
|
+
headers: { 'User-Agent': userAgent },
|
|
89
|
+
signal: AbortSignal.timeout(options.timeout || 5000),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const robots: RobotsTxt = { directives: [], sitemaps: [], crawlDelay: undefined };
|
|
94
|
+
cache.set(robotsUrl, { robots, fetchedAt: Date.now() });
|
|
95
|
+
return robots;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
const robots = parseRobotsTxt(text);
|
|
100
|
+
cache.set(robotsUrl, { robots, fetchedAt: Date.now() });
|
|
101
|
+
return robots;
|
|
102
|
+
} catch {
|
|
103
|
+
const robots: RobotsTxt = { directives: [], sitemaps: [], crawlDelay: undefined };
|
|
104
|
+
cache.set(robotsUrl, { robots, fetchedAt: Date.now() });
|
|
105
|
+
return robots;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isAllowed(url: string, robots: RobotsTxt, userAgent: string = '*'): boolean {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
const path = parsed.pathname;
|
|
113
|
+
|
|
114
|
+
for (const directive of robots.directives) {
|
|
115
|
+
if (matchUserAgent(directive.userAgent, userAgent)) {
|
|
116
|
+
return isPathAllowed(path, directive);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getCrawlDelay(robots: RobotsTxt, userAgent: string = '*'): number | undefined {
|
|
127
|
+
for (const directive of robots.directives) {
|
|
128
|
+
if (matchUserAgent(directive.userAgent, userAgent)) {
|
|
129
|
+
return directive.crawlDelay;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return robots.crawlDelay;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getSitemaps(robots: RobotsTxt): string[] {
|
|
136
|
+
return robots.sitemaps;
|
|
137
|
+
}
|