@detiq/crawler 0.1.3
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/Dockerfile +49 -0
- package/README.md +11 -0
- package/dist/accessibility-auditor.d.ts +29 -0
- package/dist/accessibility-auditor.js +57 -0
- package/dist/accessibility-auditor.js.map +7 -0
- package/dist/ai-login.d.ts +32 -0
- package/dist/ai-login.js +177 -0
- package/dist/ai-login.js.map +7 -0
- package/dist/auth-token-rotator.d.ts +47 -0
- package/dist/auth-token-rotator.js +58 -0
- package/dist/auth-token-rotator.js.map +7 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.js +329 -0
- package/dist/auth.js.map +7 -0
- package/dist/browser-extension.d.ts +37 -0
- package/dist/browser-extension.js +113 -0
- package/dist/browser-extension.js.map +7 -0
- package/dist/captcha-solver.d.ts +16 -0
- package/dist/captcha-solver.js +105 -0
- package/dist/captcha-solver.js.map +7 -0
- package/dist/color-contrast.d.ts +30 -0
- package/dist/color-contrast.js +78 -0
- package/dist/color-contrast.js.map +7 -0
- package/dist/content-chunker.d.ts +18 -0
- package/dist/content-chunker.js +152 -0
- package/dist/content-chunker.js.map +7 -0
- package/dist/content-dedup.d.ts +23 -0
- package/dist/content-dedup.js +60 -0
- package/dist/content-dedup.js.map +7 -0
- package/dist/cookie-consent.d.ts +25 -0
- package/dist/cookie-consent.js +78 -0
- package/dist/cookie-consent.js.map +7 -0
- package/dist/cookie-jar.d.ts +20 -0
- package/dist/cookie-jar.js +61 -0
- package/dist/cookie-jar.js.map +7 -0
- package/dist/crawler.d.ts +179 -0
- package/dist/crawler.js +3672 -0
- package/dist/crawler.js.map +7 -0
- package/dist/custom-js-injector.d.ts +42 -0
- package/dist/custom-js-injector.js +50 -0
- package/dist/custom-js-injector.js.map +7 -0
- package/dist/dark-mode-capture.d.ts +23 -0
- package/dist/dark-mode-capture.js +75 -0
- package/dist/dark-mode-capture.js.map +7 -0
- package/dist/dead-link-detector.d.ts +37 -0
- package/dist/dead-link-detector.js +50 -0
- package/dist/dead-link-detector.js.map +7 -0
- package/dist/element-hash.d.ts +9 -0
- package/dist/element-hash.js +9 -0
- package/dist/element-hash.js.map +7 -0
- package/dist/email-otp.d.ts +42 -0
- package/dist/email-otp.js +177 -0
- package/dist/email-otp.js.map +7 -0
- package/dist/engine-router.d.ts +47 -0
- package/dist/engine-router.js +94 -0
- package/dist/engine-router.js.map +7 -0
- package/dist/export/bulk-export.d.ts +26 -0
- package/dist/export/bulk-export.js +76 -0
- package/dist/export/bulk-export.js.map +7 -0
- package/dist/export/vector-export.d.ts +76 -0
- package/dist/export/vector-export.js +85 -0
- package/dist/export/vector-export.js.map +7 -0
- package/dist/extractors/advanced.d.ts +54 -0
- package/dist/extractors/advanced.js +245 -0
- package/dist/extractors/advanced.js.map +7 -0
- package/dist/extractors/artifact-envelope.d.ts +63 -0
- package/dist/extractors/artifact-envelope.js +63 -0
- package/dist/extractors/artifact-envelope.js.map +7 -0
- package/dist/failure-analysis-agent.d.ts +16 -0
- package/dist/failure-analysis-agent.js +40 -0
- package/dist/failure-analysis-agent.js.map +7 -0
- package/dist/feature-flag-detector.d.ts +30 -0
- package/dist/feature-flag-detector.js +55 -0
- package/dist/feature-flag-detector.js.map +7 -0
- package/dist/form-detector.d.ts +34 -0
- package/dist/form-detector.js +58 -0
- package/dist/form-detector.js.map +7 -0
- package/dist/frontier.d.ts +32 -0
- package/dist/frontier.js +83 -0
- package/dist/frontier.js.map +7 -0
- package/dist/gap-pass.d.ts +35 -0
- package/dist/gap-pass.js +81 -0
- package/dist/gap-pass.js.map +7 -0
- package/dist/graphql-detector.d.ts +39 -0
- package/dist/graphql-detector.js +86 -0
- package/dist/graphql-detector.js.map +7 -0
- package/dist/http-engine.d.ts +80 -0
- package/dist/http-engine.js +304 -0
- package/dist/http-engine.js.map +7 -0
- package/dist/incremental-diff.d.ts +59 -0
- package/dist/incremental-diff.js +61 -0
- package/dist/incremental-diff.js.map +7 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +7 -0
- package/dist/input-fuzzer.d.ts +30 -0
- package/dist/input-fuzzer.js +105 -0
- package/dist/input-fuzzer.js.map +7 -0
- package/dist/interactive-probe.d.ts +104 -0
- package/dist/interactive-probe.js +442 -0
- package/dist/interactive-probe.js.map +7 -0
- package/dist/job-queue.d.ts +171 -0
- package/dist/job-queue.js +2256 -0
- package/dist/job-queue.js.map +7 -0
- package/dist/js-coverage.d.ts +29 -0
- package/dist/js-coverage.js +56 -0
- package/dist/js-coverage.js.map +7 -0
- package/dist/js-router-detector.d.ts +17 -0
- package/dist/js-router-detector.js +90 -0
- package/dist/js-router-detector.js.map +7 -0
- package/dist/language-detector.d.ts +22 -0
- package/dist/language-detector.js +57 -0
- package/dist/language-detector.js.map +7 -0
- package/dist/link-classifier.d.ts +23 -0
- package/dist/link-classifier.js +40 -0
- package/dist/link-classifier.js.map +7 -0
- package/dist/live-view.d.ts +36 -0
- package/dist/live-view.js +100 -0
- package/dist/live-view.js.map +7 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +7 -0
- package/dist/markdown-extract.d.ts +8 -0
- package/dist/markdown-extract.js +54 -0
- package/dist/markdown-extract.js.map +7 -0
- package/dist/native-crawler.d.ts +69 -0
- package/dist/native-crawler.js +839 -0
- package/dist/native-crawler.js.map +7 -0
- package/dist/network-observer.d.ts +49 -0
- package/dist/network-observer.js +149 -0
- package/dist/network-observer.js.map +7 -0
- package/dist/network-throttle.d.ts +25 -0
- package/dist/network-throttle.js +34 -0
- package/dist/network-throttle.js.map +7 -0
- package/dist/openapi-generator.d.ts +49 -0
- package/dist/openapi-generator.js +89 -0
- package/dist/openapi-generator.js.map +7 -0
- package/dist/pdf-extractor.d.ts +28 -0
- package/dist/pdf-extractor.js +81 -0
- package/dist/pdf-extractor.js.map +7 -0
- package/dist/performance-metrics.d.ts +53 -0
- package/dist/performance-metrics.js +83 -0
- package/dist/performance-metrics.js.map +7 -0
- package/dist/phash.d.ts +38 -0
- package/dist/phash.js +115 -0
- package/dist/phash.js.map +7 -0
- package/dist/pii-scrubber.d.ts +21 -0
- package/dist/pii-scrubber.js +75 -0
- package/dist/pii-scrubber.js.map +7 -0
- package/dist/playwright-recorder.d.ts +39 -0
- package/dist/playwright-recorder.js +123 -0
- package/dist/playwright-recorder.js.map +7 -0
- package/dist/policies/crawl-safety.d.ts +9 -0
- package/dist/policies/crawl-safety.js +37 -0
- package/dist/policies/crawl-safety.js.map +7 -0
- package/dist/profiles/domain-profile.d.ts +19 -0
- package/dist/profiles/domain-profile.js +140 -0
- package/dist/profiles/domain-profile.js.map +7 -0
- package/dist/proxy.d.ts +50 -0
- package/dist/proxy.js +165 -0
- package/dist/proxy.js.map +7 -0
- package/dist/rate-limiter.d.ts +31 -0
- package/dist/rate-limiter.js +64 -0
- package/dist/rate-limiter.js.map +7 -0
- package/dist/redirect-tracker.d.ts +37 -0
- package/dist/redirect-tracker.js +65 -0
- package/dist/redirect-tracker.js.map +7 -0
- package/dist/redis-frontier.d.ts +48 -0
- package/dist/redis-frontier.js +126 -0
- package/dist/redis-frontier.js.map +7 -0
- package/dist/redis-queue.d.ts +48 -0
- package/dist/redis-queue.js +165 -0
- package/dist/redis-queue.js.map +7 -0
- package/dist/response-cache.d.ts +38 -0
- package/dist/response-cache.js +140 -0
- package/dist/response-cache.js.map +7 -0
- package/dist/responsive-detector.d.ts +29 -0
- package/dist/responsive-detector.js +46 -0
- package/dist/responsive-detector.js.map +7 -0
- package/dist/retry-policy.d.ts +20 -0
- package/dist/retry-policy.js +54 -0
- package/dist/retry-policy.js.map +7 -0
- package/dist/robots-txt.d.ts +9 -0
- package/dist/robots-txt.js +77 -0
- package/dist/robots-txt.js.map +7 -0
- package/dist/screen-reader.d.ts +16 -0
- package/dist/screen-reader.js +46 -0
- package/dist/screen-reader.js.map +7 -0
- package/dist/screenshot-masker.d.ts +28 -0
- package/dist/screenshot-masker.js +62 -0
- package/dist/screenshot-masker.js.map +7 -0
- package/dist/seo-checker.d.ts +33 -0
- package/dist/seo-checker.js +91 -0
- package/dist/seo-checker.js.map +7 -0
- package/dist/session-capture.d.ts +35 -0
- package/dist/session-capture.js +267 -0
- package/dist/session-capture.js.map +7 -0
- package/dist/session-export.d.ts +49 -0
- package/dist/session-export.js +117 -0
- package/dist/session-export.js.map +7 -0
- package/dist/sitemap-writer.d.ts +21 -0
- package/dist/sitemap-writer.js +47 -0
- package/dist/sitemap-writer.js.map +7 -0
- package/dist/sitemap.d.ts +51 -0
- package/dist/sitemap.js +152 -0
- package/dist/sitemap.js.map +7 -0
- package/dist/skyvern.d.ts +56 -0
- package/dist/skyvern.js +129 -0
- package/dist/skyvern.js.map +7 -0
- package/dist/sqs-queue.d.ts +2 -0
- package/dist/sqs-queue.js +47 -0
- package/dist/sqs-queue.js.map +7 -0
- package/dist/sso-token.d.ts +29 -0
- package/dist/sso-token.js +76 -0
- package/dist/sso-token.js.map +7 -0
- package/dist/stealth/human-behavior.d.ts +20 -0
- package/dist/stealth/human-behavior.js +168 -0
- package/dist/stealth/human-behavior.js.map +7 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +77 -0
- package/dist/telemetry.js.map +7 -0
- package/dist/test-executor.d.ts +67 -0
- package/dist/test-executor.js +311 -0
- package/dist/test-executor.js.map +7 -0
- package/dist/tls-fingerprint.d.ts +50 -0
- package/dist/tls-fingerprint.js +81 -0
- package/dist/tls-fingerprint.js.map +7 -0
- package/dist/url-pattern-completer.d.ts +28 -0
- package/dist/url-pattern-completer.js +87 -0
- package/dist/url-pattern-completer.js.map +7 -0
- package/dist/visual-diff-verdict.d.ts +40 -0
- package/dist/visual-diff-verdict.js +25 -0
- package/dist/visual-diff-verdict.js.map +7 -0
- package/dist/webhook-notifier.d.ts +30 -0
- package/dist/webhook-notifier.js +76 -0
- package/dist/webhook-notifier.js.map +7 -0
- package/dist/websocket-detector.d.ts +24 -0
- package/dist/websocket-detector.js +60 -0
- package/dist/websocket-detector.js.map +7 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +33 -0
- package/dist/worker.js.map +7 -0
- package/package.json +45 -0
- package/src/accessibility-auditor.ts +102 -0
- package/src/ai-login.ts +218 -0
- package/src/auth-token-rotator.ts +89 -0
- package/src/auth.ts +396 -0
- package/src/browser-extension.ts +186 -0
- package/src/captcha-solver.ts +143 -0
- package/src/color-contrast.ts +116 -0
- package/src/content-chunker.ts +170 -0
- package/src/content-dedup.ts +99 -0
- package/src/cookie-consent.ts +106 -0
- package/src/cookie-jar.ts +91 -0
- package/src/crawler.ts +4305 -0
- package/src/custom-js-injector.ts +93 -0
- package/src/dark-mode-capture.ts +110 -0
- package/src/dead-link-detector.ts +76 -0
- package/src/element-hash.ts +16 -0
- package/src/email-otp.ts +252 -0
- package/src/engine-router.ts +141 -0
- package/src/export/bulk-export.ts +85 -0
- package/src/export/vector-export.ts +160 -0
- package/src/extractors/advanced.ts +283 -0
- package/src/extractors/artifact-envelope.ts +111 -0
- package/src/failure-analysis-agent.ts +49 -0
- package/src/feature-flag-detector.ts +97 -0
- package/src/form-detector.ts +103 -0
- package/src/frontier.test.ts +64 -0
- package/src/frontier.ts +112 -0
- package/src/gap-pass.ts +122 -0
- package/src/graphql-detector.ts +129 -0
- package/src/http-engine.ts +433 -0
- package/src/incremental-diff.ts +121 -0
- package/src/index.ts +35 -0
- package/src/input-fuzzer.ts +129 -0
- package/src/interactive-probe.ts +623 -0
- package/src/job-queue.test.ts +120 -0
- package/src/job-queue.ts +2701 -0
- package/src/js-coverage.ts +87 -0
- package/src/js-router-detector.ts +112 -0
- package/src/language-detector.ts +82 -0
- package/src/link-classifier.ts +65 -0
- package/src/live-view.ts +141 -0
- package/src/logger.ts +9 -0
- package/src/markdown-extract.test.ts +73 -0
- package/src/markdown-extract.ts +76 -0
- package/src/native-crawler.ts +1106 -0
- package/src/network-observer.ts +204 -0
- package/src/network-throttle.ts +69 -0
- package/src/openapi-generator.ts +135 -0
- package/src/pdf-extractor.ts +123 -0
- package/src/performance-metrics.ts +134 -0
- package/src/phash.ts +151 -0
- package/src/pii-scrubber.ts +99 -0
- package/src/playwright-recorder.ts +170 -0
- package/src/policies/crawl-safety.ts +46 -0
- package/src/profiles/domain-profile.ts +167 -0
- package/src/proxy.ts +238 -0
- package/src/rate-limiter.ts +87 -0
- package/src/redirect-tracker.ts +97 -0
- package/src/redis-frontier.ts +143 -0
- package/src/redis-queue.ts +208 -0
- package/src/response-cache.ts +150 -0
- package/src/responsive-detector.ts +92 -0
- package/src/retry-policy.ts +87 -0
- package/src/robots-txt.ts +96 -0
- package/src/screen-reader.ts +89 -0
- package/src/screenshot-masker.ts +98 -0
- package/src/seo-checker.ts +134 -0
- package/src/session-capture.ts +325 -0
- package/src/session-export.test.ts +75 -0
- package/src/session-export.ts +172 -0
- package/src/sitemap-writer.ts +65 -0
- package/src/sitemap.test.ts +131 -0
- package/src/sitemap.ts +224 -0
- package/src/skyvern.ts +199 -0
- package/src/sqs-queue.ts +49 -0
- package/src/sso-token.ts +120 -0
- package/src/stealth/human-behavior.ts +191 -0
- package/src/telemetry.ts +122 -0
- package/src/test-executor.test.ts +72 -0
- package/src/test-executor.ts +424 -0
- package/src/tls-fingerprint.ts +111 -0
- package/src/url-pattern-completer.ts +114 -0
- package/src/visual-diff-verdict.test.ts +80 -0
- package/src/visual-diff-verdict.ts +57 -0
- package/src/webhook-notifier.ts +109 -0
- package/src/websocket-detector.ts +83 -0
- package/src/worker.ts +48 -0
- package/tsconfig.json +13 -0
- package/types/accessibility-auditor.d.ts +29 -0
- package/types/ai-login.d.ts +32 -0
- package/types/auth-token-rotator.d.ts +47 -0
- package/types/auth.d.ts +20 -0
- package/types/browser-extension.d.ts +37 -0
- package/types/bull-worker.d.ts +6 -0
- package/types/captcha-solver.d.ts +16 -0
- package/types/color-contrast.d.ts +30 -0
- package/types/content-chunker.d.ts +18 -0
- package/types/content-dedup.d.ts +23 -0
- package/types/cookie-consent.d.ts +25 -0
- package/types/cookie-jar.d.ts +20 -0
- package/types/crawler.d.ts +179 -0
- package/types/custom-js-injector.d.ts +42 -0
- package/types/dark-mode-capture.d.ts +23 -0
- package/types/dead-link-detector.d.ts +37 -0
- package/types/element-hash.d.ts +9 -0
- package/types/email-otp.d.ts +42 -0
- package/types/engine-router.d.ts +47 -0
- package/types/export/bulk-export.d.ts +26 -0
- package/types/export/vector-export.d.ts +76 -0
- package/types/extractors/advanced.d.ts +54 -0
- package/types/extractors/artifact-envelope.d.ts +63 -0
- package/types/failure-analysis-agent.d.ts +16 -0
- package/types/feature-flag-detector.d.ts +30 -0
- package/types/form-detector.d.ts +34 -0
- package/types/frontier.d.ts +32 -0
- package/types/gap-pass.d.ts +35 -0
- package/types/graphql-detector.d.ts +39 -0
- package/types/http-engine.d.ts +80 -0
- package/types/incremental-diff.d.ts +59 -0
- package/types/index.d.ts +35 -0
- package/types/input-fuzzer.d.ts +30 -0
- package/types/interactive-probe.d.ts +104 -0
- package/types/job-queue.d.ts +171 -0
- package/types/js-coverage.d.ts +29 -0
- package/types/js-router-detector.d.ts +17 -0
- package/types/language-detector.d.ts +22 -0
- package/types/link-classifier.d.ts +23 -0
- package/types/live-view.d.ts +36 -0
- package/types/logger.d.ts +2 -0
- package/types/markdown-extract.d.ts +8 -0
- package/types/native-crawler.d.ts +69 -0
- package/types/network-observer.d.ts +49 -0
- package/types/network-throttle.d.ts +25 -0
- package/types/openapi-generator.d.ts +49 -0
- package/types/pdf-extractor.d.ts +28 -0
- package/types/performance-metrics.d.ts +53 -0
- package/types/phash.d.ts +38 -0
- package/types/pii-scrubber.d.ts +21 -0
- package/types/playwright-recorder.d.ts +39 -0
- package/types/policies/crawl-safety.d.ts +9 -0
- package/types/profiles/domain-profile.d.ts +19 -0
- package/types/proxy.d.ts +50 -0
- package/types/rate-limiter.d.ts +31 -0
- package/types/redirect-tracker.d.ts +37 -0
- package/types/redis-frontier.d.ts +48 -0
- package/types/redis-queue.d.ts +48 -0
- package/types/response-cache.d.ts +38 -0
- package/types/responsive-detector.d.ts +29 -0
- package/types/retry-policy.d.ts +20 -0
- package/types/robots-txt.d.ts +9 -0
- package/types/screen-reader.d.ts +16 -0
- package/types/screenshot-masker.d.ts +28 -0
- package/types/seo-checker.d.ts +33 -0
- package/types/session-capture.d.ts +38 -0
- package/types/session-export.d.ts +49 -0
- package/types/sitemap-writer.d.ts +21 -0
- package/types/sitemap.d.ts +51 -0
- package/types/skyvern.d.ts +56 -0
- package/types/sqs-queue.d.ts +2 -0
- package/types/sso-token.d.ts +29 -0
- package/types/stealth/human-behavior.d.ts +20 -0
- package/types/telemetry.d.ts +34 -0
- package/types/test-executor.d.ts +67 -0
- package/types/tls-fingerprint.d.ts +50 -0
- package/types/url-pattern-completer.d.ts +28 -0
- package/types/visual-diff-verdict.d.ts +40 -0
- package/types/webhook-notifier.d.ts +30 -0
- package/types/websocket-detector.d.ts +24 -0
- package/types/worker.d.ts +1 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JS/CSS coverage collector via Playwright's built-in coverage API.
|
|
3
|
+
*
|
|
4
|
+
* For each crawled page, records what percentage of loaded JS/CSS was actually
|
|
5
|
+
* executed/applied. High unused code % → performance opportunity (code splitting, lazy loading).
|
|
6
|
+
*
|
|
7
|
+
* Uses Playwright's page.coverage API (wraps CDP Coverage domain).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Page } from 'playwright';
|
|
11
|
+
|
|
12
|
+
export interface CoverageEntry {
|
|
13
|
+
url: string;
|
|
14
|
+
type: 'js' | 'css';
|
|
15
|
+
totalBytes: number;
|
|
16
|
+
usedBytes: number;
|
|
17
|
+
unusedPercent: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CoverageReport {
|
|
21
|
+
pageUrl: string;
|
|
22
|
+
entries: CoverageEntry[];
|
|
23
|
+
totalJsBytes: number;
|
|
24
|
+
usedJsBytes: number;
|
|
25
|
+
totalCssBytes: number;
|
|
26
|
+
usedCssBytes: number;
|
|
27
|
+
jsUnusedPercent: number;
|
|
28
|
+
cssUnusedPercent: number;
|
|
29
|
+
collectedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function startCoverage(page: Page): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
await Promise.all([
|
|
35
|
+
page.coverage.startJSCoverage({ resetOnNavigation: false }),
|
|
36
|
+
page.coverage.startCSSCoverage({ resetOnNavigation: false }),
|
|
37
|
+
]);
|
|
38
|
+
} catch { /* coverage not available in all environments */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function stopAndCollectCoverage(page: Page, pageUrl: string): Promise<CoverageReport | null> {
|
|
42
|
+
try {
|
|
43
|
+
const [jsCoverage, cssCoverage] = await Promise.all([
|
|
44
|
+
page.coverage.stopJSCoverage().catch(() => [] as any[]),
|
|
45
|
+
page.coverage.stopCSSCoverage().catch(() => [] as any[]),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const entries: CoverageEntry[] = [];
|
|
49
|
+
|
|
50
|
+
for (const entry of jsCoverage) {
|
|
51
|
+
const totalBytes = entry.text?.length ?? 0;
|
|
52
|
+
const usedBytes = (entry.ranges ?? []).reduce((sum: number, r: any) => sum + (r.end - r.start), 0);
|
|
53
|
+
if (totalBytes > 0) {
|
|
54
|
+
entries.push({ url: entry.url, type: 'js', totalBytes, usedBytes, unusedPercent: Math.round((1 - usedBytes / totalBytes) * 100) });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const entry of cssCoverage) {
|
|
59
|
+
const totalBytes = entry.text?.length ?? 0;
|
|
60
|
+
const usedBytes = (entry.ranges ?? []).reduce((sum: number, r: any) => sum + (r.end - r.start), 0);
|
|
61
|
+
if (totalBytes > 0) {
|
|
62
|
+
entries.push({ url: entry.url, type: 'css', totalBytes, usedBytes, unusedPercent: Math.round((1 - usedBytes / totalBytes) * 100) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const jsEntries = entries.filter(e => e.type === 'js');
|
|
67
|
+
const cssEntries = entries.filter(e => e.type === 'css');
|
|
68
|
+
const totalJsBytes = jsEntries.reduce((s, e) => s + e.totalBytes, 0);
|
|
69
|
+
const usedJsBytes = jsEntries.reduce((s, e) => s + e.usedBytes, 0);
|
|
70
|
+
const totalCssBytes = cssEntries.reduce((s, e) => s + e.totalBytes, 0);
|
|
71
|
+
const usedCssBytes = cssEntries.reduce((s, e) => s + e.usedBytes, 0);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
pageUrl,
|
|
75
|
+
entries,
|
|
76
|
+
totalJsBytes,
|
|
77
|
+
usedJsBytes,
|
|
78
|
+
totalCssBytes,
|
|
79
|
+
usedCssBytes,
|
|
80
|
+
jsUnusedPercent: totalJsBytes > 0 ? Math.round((1 - usedJsBytes / totalJsBytes) * 100) : 0,
|
|
81
|
+
cssUnusedPercent: totalCssBytes > 0 ? Math.round((1 - usedCssBytes / totalCssBytes) * 100) : 0,
|
|
82
|
+
collectedAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2: JS Router Detection
|
|
3
|
+
*
|
|
4
|
+
* Scans the loaded page for client-side route tables declared by React Router v6,
|
|
5
|
+
* Vue Router, Angular Router, or custom window globals. Supplements the existing
|
|
6
|
+
* history.pushState interception (which only captures runtime navigation) with a
|
|
7
|
+
* static analysis of routes declared in the JS bundle.
|
|
8
|
+
*
|
|
9
|
+
* Called once per page after goto(). Runs entirely in-browser via page.evaluate().
|
|
10
|
+
* Adds discovered routes to the BFS frontier so the crawler visits them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { logger as rootLogger } from './logger.js';
|
|
14
|
+
|
|
15
|
+
const logger = rootLogger.child({ module: 'js-router-detector' });
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract absolute page URLs from the current page's JS router config.
|
|
19
|
+
* Returns deduplicated absolute URLs (same origin). Dynamic segments (:id, *)
|
|
20
|
+
* are skipped — the crawler can't resolve them without knowing valid IDs.
|
|
21
|
+
*/
|
|
22
|
+
export async function detectJsRoutes(page: any, origin: string): Promise<string[]> {
|
|
23
|
+
try {
|
|
24
|
+
const raw: string[] = await page.evaluate(() => {
|
|
25
|
+
const found: string[] = [];
|
|
26
|
+
|
|
27
|
+
// 1. Check common framework globals ─────────────────────────────────────
|
|
28
|
+
const windowGlobals = [
|
|
29
|
+
'__routes__', '__ROUTER__', '__vue_router__', '__router__',
|
|
30
|
+
'router', '_router', '__reactRouter', '__NEXT_DATA__',
|
|
31
|
+
];
|
|
32
|
+
for (const g of windowGlobals) {
|
|
33
|
+
try {
|
|
34
|
+
const val = (window as any)[g];
|
|
35
|
+
if (!val || typeof val !== 'object') continue;
|
|
36
|
+
|
|
37
|
+
// Next.js page manifest
|
|
38
|
+
if (g === '__NEXT_DATA__' && Array.isArray(val.buildManifest?.pages)) {
|
|
39
|
+
for (const p of val.buildManifest.pages) if (typeof p === 'string') found.push(p);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Router objects: options.routes, routes, getRoutes()
|
|
44
|
+
const routeArr =
|
|
45
|
+
(Array.isArray(val.options?.routes) ? val.options.routes : null) ??
|
|
46
|
+
(Array.isArray(val.routes) ? val.routes : null) ??
|
|
47
|
+
(typeof val.getRoutes === 'function' ? val.getRoutes() : null);
|
|
48
|
+
|
|
49
|
+
if (!Array.isArray(routeArr)) continue;
|
|
50
|
+
|
|
51
|
+
const flatRoutes = (arr: any[], prefix = ''): void => {
|
|
52
|
+
for (const r of arr) {
|
|
53
|
+
if (!r || typeof r !== 'object') continue;
|
|
54
|
+
const path: string = r.path ?? r.href ?? r.url ?? '';
|
|
55
|
+
const full = path.startsWith('/') ? path : `${prefix}/${path}`.replace(/\/\//g, '/');
|
|
56
|
+
if (full && full !== '/') found.push(full);
|
|
57
|
+
if (Array.isArray(r.children)) flatRoutes(r.children, full);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
flatRoutes(routeArr);
|
|
61
|
+
} catch { /* skip corrupt globals */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Scan inline <script> content for route patterns ───────────────────
|
|
65
|
+
// Two-pass: regex first, if >3 found trust them. Below 3 → skip (too noisy).
|
|
66
|
+
const routePatterns = [
|
|
67
|
+
/\bpath\s*:\s*['"](\/?[\w\-\/\.~@]+)['"]/g,
|
|
68
|
+
/\broute\s*\(\s*['"](\/?[\w\-\/\.~@]+)['"]/g,
|
|
69
|
+
/<Route[^>]+path\s*=\s*['"](\/?[\w\-\/\.~@]+)['"]/g,
|
|
70
|
+
/to\s*:\s*['"](\/?[\w\-\/\.~@]+)['"]/g,
|
|
71
|
+
];
|
|
72
|
+
const scripts = Array.from(document.querySelectorAll('script:not([src])'));
|
|
73
|
+
for (const el of scripts.slice(0, 15)) {
|
|
74
|
+
const src = el.textContent ?? '';
|
|
75
|
+
if (src.length < 100 || src.length > 800_000) continue;
|
|
76
|
+
for (const pat of routePatterns) {
|
|
77
|
+
const hits: string[] = [];
|
|
78
|
+
let m: RegExpExecArray | null;
|
|
79
|
+
pat.lastIndex = 0;
|
|
80
|
+
while ((m = pat.exec(src)) !== null && hits.length < 100) {
|
|
81
|
+
if (m[1] && m[1].startsWith('/') && m[1].length > 1) hits.push(m[1]);
|
|
82
|
+
}
|
|
83
|
+
// Only trust if we found >2 hits (reduces false positives)
|
|
84
|
+
if (hits.length > 2) for (const h of hits) found.push(h);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return found;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Post-process: resolve to absolute, dedup, drop dynamic segments
|
|
92
|
+
const DYNAMIC = /[*:?]/;
|
|
93
|
+
const ASSET = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|map|json|xml)$/i;
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
const result: string[] = [];
|
|
96
|
+
for (const r of raw) {
|
|
97
|
+
if (!r || typeof r !== 'string' || DYNAMIC.test(r) || ASSET.test(r)) continue;
|
|
98
|
+
const abs = r.startsWith('http') ? r : `${origin}${r.startsWith('/') ? '' : '/'}${r}`;
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(abs);
|
|
101
|
+
if (parsed.origin !== origin) continue;
|
|
102
|
+
} catch { continue; }
|
|
103
|
+
if (!seen.has(abs)) { seen.add(abs); result.push(abs); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (result.length > 0) logger.info({ count: result.length }, '[P2] JS router: routes detected');
|
|
107
|
+
return result;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logger.warn({ err: String(err) }, '[P2] JS router detection failed — non-fatal');
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language detector — identify page language from HTML attributes and content analysis.
|
|
3
|
+
*
|
|
4
|
+
* Sources (in priority order):
|
|
5
|
+
* 1. <html lang="..."> attribute
|
|
6
|
+
* 2. <meta http-equiv="Content-Language"> header
|
|
7
|
+
* 3. hreflang alternate links
|
|
8
|
+
* 4. Content-based n-gram fingerprint (detects top 20 languages)
|
|
9
|
+
*
|
|
10
|
+
* Used to: route pages to the correct markdown processor, group screens by locale,
|
|
11
|
+
* and detect missing hreflang implementations.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Page } from 'playwright';
|
|
15
|
+
|
|
16
|
+
// Frequency signatures for n-gram based detection
|
|
17
|
+
// Common trigrams by language (simplified — not production-grade)
|
|
18
|
+
const LANG_SIGNATURES: Record<string, RegExp> = {
|
|
19
|
+
en: /\b(the|and|for|are|but|not|you|all|can|had|her|was|one|our|out|day|get|has|him|his|how)\b/gi,
|
|
20
|
+
es: /\b(que|con|una|por|del|las|los|para|como|pero|más|este|esto|sus|ser|hay|sobre|entre|también)\b/gi,
|
|
21
|
+
fr: /\b(les|des|une|est|dans|pour|qui|par|sur|avec|que|pas|son|tout|mais|bien|comme|vous)\b/gi,
|
|
22
|
+
de: /\b(die|der|und|den|das|ein|ist|von|mit|dem|auf|sich|des|aber|auch|bei|aus|nach)\b/gi,
|
|
23
|
+
pt: /\b(que|não|uma|com|por|dos|para|como|mais|mas|seu|sua|nas|nos|ele|ela|são)\b/gi,
|
|
24
|
+
it: /\b(che|con|del|per|una|nel|non|sono|gli|dei|alla|delle|questa|questo|come|più)\b/gi,
|
|
25
|
+
nl: /\b(van|het|een|zijn|maar|ook|dan|door|op|uit|aan|bij|om|al|veel|haar|hun)\b/gi,
|
|
26
|
+
ja: /[-ゟ゠-ヿ]/g,
|
|
27
|
+
zh: /[一-鿿]/g,
|
|
28
|
+
ar: /[-ۿ]/g,
|
|
29
|
+
hi: /[ऀ-ॿ]/g,
|
|
30
|
+
ko: /[가-ᄀ-ᇿ]/g,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function detectFromContent(text: string): string {
|
|
34
|
+
if (!text || text.length < 50) return 'en';
|
|
35
|
+
let best = 'en';
|
|
36
|
+
let bestScore = 0;
|
|
37
|
+
for (const [lang, re] of Object.entries(LANG_SIGNATURES)) {
|
|
38
|
+
const matches = (text.match(re) ?? []).length;
|
|
39
|
+
const score = matches / Math.max(text.length / 100, 1);
|
|
40
|
+
if (score > bestScore) { bestScore = score; best = lang; }
|
|
41
|
+
}
|
|
42
|
+
return best;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LanguageDetectionResult {
|
|
46
|
+
url: string;
|
|
47
|
+
declaredLang: string; // from <html lang> or meta
|
|
48
|
+
detectedLang: string; // from content analysis
|
|
49
|
+
confidence: 'high' | 'medium' | 'low';
|
|
50
|
+
alternates: Record<string, string>; // lang → url from hreflang
|
|
51
|
+
isMultilingual: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function detectLanguage(page: Page, url: string): Promise<LanguageDetectionResult> {
|
|
55
|
+
const data = await page.evaluate(() => {
|
|
56
|
+
const htmlLang = document.documentElement.lang?.toLowerCase().split('-')[0] ?? '';
|
|
57
|
+
const metaLang = (document.querySelector('meta[http-equiv="Content-Language"]') as any)?.content?.toLowerCase().split('-')[0] ?? '';
|
|
58
|
+
const alternates: Record<string, string> = {};
|
|
59
|
+
document.querySelectorAll('link[rel="alternate"][hreflang]').forEach((el: any) => {
|
|
60
|
+
const lang = el.getAttribute('hreflang')?.toLowerCase();
|
|
61
|
+
const href = el.getAttribute('href') ?? '';
|
|
62
|
+
if (lang && lang !== 'x-default') alternates[lang] = href;
|
|
63
|
+
});
|
|
64
|
+
const bodyText = (document.body?.innerText ?? '').slice(0, 2000);
|
|
65
|
+
return { htmlLang, metaLang, alternates, bodyText };
|
|
66
|
+
}).catch(() => ({ htmlLang: '', metaLang: '', alternates: {}, bodyText: '' }));
|
|
67
|
+
|
|
68
|
+
const declaredLang = data.htmlLang || data.metaLang || '';
|
|
69
|
+
const detectedLang = detectFromContent(data.bodyText);
|
|
70
|
+
|
|
71
|
+
const confidence: LanguageDetectionResult['confidence'] =
|
|
72
|
+
declaredLang ? 'high' : data.bodyText.length > 200 ? 'medium' : 'low';
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
url,
|
|
76
|
+
declaredLang,
|
|
77
|
+
detectedLang: declaredLang || detectedLang,
|
|
78
|
+
confidence,
|
|
79
|
+
alternates: data.alternates,
|
|
80
|
+
isMultilingual: Object.keys(data.alternates).length > 1,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link classifier — assigns priority and category to discovered URLs.
|
|
3
|
+
*
|
|
4
|
+
* Categories:
|
|
5
|
+
* - 'nav' : site navigation (homepage, top-level pages) — highest priority
|
|
6
|
+
* - 'auth' : login/register/password flows — high priority
|
|
7
|
+
* - 'app' : authenticated app screens — high priority
|
|
8
|
+
* - 'content' : blog/docs/articles — medium priority
|
|
9
|
+
* - 'api' : REST/GraphQL API endpoints — detected, not crawled as screens
|
|
10
|
+
* - 'resource' : static assets (JS/CSS/img/fonts) — skip
|
|
11
|
+
* - 'external' : different domain — skip unless cross-domain crawl enabled
|
|
12
|
+
* - 'low-value' : pagination, sort, filter query strings — deprioritize
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type LinkCategory = 'nav' | 'auth' | 'app' | 'content' | 'api' | 'resource' | 'external' | 'low-value';
|
|
16
|
+
|
|
17
|
+
export interface ClassifiedLink {
|
|
18
|
+
url: string;
|
|
19
|
+
category: LinkCategory;
|
|
20
|
+
priority: number; // 0-100, higher = crawl sooner
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RESOURCE_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|mp4|mp3|pdf|zip|gz|tar|exe|dmg|apk)(\?|$)/i;
|
|
25
|
+
const API_PATTERN = /\/(api|graphql|gql|rest|v\d+)\//i;
|
|
26
|
+
const AUTH_PATTERN = /\/(login|signin|sign-in|register|signup|sign-up|auth|oauth|sso|password|forgot|reset|logout|signout)\b/i;
|
|
27
|
+
const LOW_VALUE_PATTERN = /[?&](page|p|offset|sort|order|filter|q|search|tag|category|color|size|brand)=/i;
|
|
28
|
+
const PAGINATION_PATTERN = /\/page\/\d+|\/p\/\d+|\bpage=\d+/i;
|
|
29
|
+
|
|
30
|
+
export function classifyLink(url: string, baseUrl: string): ClassifiedLink {
|
|
31
|
+
let parsed: URL;
|
|
32
|
+
let baseHost: string;
|
|
33
|
+
try {
|
|
34
|
+
parsed = new URL(url);
|
|
35
|
+
baseHost = new URL(baseUrl).hostname;
|
|
36
|
+
} catch {
|
|
37
|
+
return { url, category: 'low-value', priority: 0, reason: 'invalid URL' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const path = parsed.pathname + parsed.search;
|
|
41
|
+
const isExternal = parsed.hostname !== baseHost;
|
|
42
|
+
|
|
43
|
+
if (isExternal) return { url, category: 'external', priority: 0, reason: 'different domain' };
|
|
44
|
+
if (RESOURCE_EXT.test(path)) return { url, category: 'resource', priority: 0, reason: 'static asset extension' };
|
|
45
|
+
if (API_PATTERN.test(path)) return { url, category: 'api', priority: 10, reason: 'API path pattern' };
|
|
46
|
+
if (AUTH_PATTERN.test(path)) return { url, category: 'auth', priority: 90, reason: 'auth flow pattern' };
|
|
47
|
+
if (PAGINATION_PATTERN.test(path)) return { url, category: 'low-value', priority: 15, reason: 'pagination' };
|
|
48
|
+
if (LOW_VALUE_PATTERN.test(path)) return { url, category: 'low-value', priority: 20, reason: 'filter/sort query param' };
|
|
49
|
+
|
|
50
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
51
|
+
if (segments.length === 0) return { url, category: 'nav', priority: 100, reason: 'homepage' };
|
|
52
|
+
if (segments.length === 1) return { url, category: 'nav', priority: 80, reason: 'top-level nav' };
|
|
53
|
+
if (segments.length <= 3) return { url, category: 'app', priority: 60, reason: 'app screen' };
|
|
54
|
+
|
|
55
|
+
return { url, category: 'content', priority: 40, reason: 'deep content page' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function classifyLinks(urls: string[], baseUrl: string): ClassifiedLink[] {
|
|
59
|
+
return urls.map(u => classifyLink(u, baseUrl))
|
|
60
|
+
.sort((a, b) => b.priority - a.priority);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function filterCrawlable(classified: ClassifiedLink[]): ClassifiedLink[] {
|
|
64
|
+
return classified.filter(l => l.category !== 'resource' && l.category !== 'external' && l.category !== 'api');
|
|
65
|
+
}
|
package/src/live-view.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
|
|
4
|
+
interface LiveViewSession {
|
|
5
|
+
id: string;
|
|
6
|
+
browser: Browser;
|
|
7
|
+
context: BrowserContext;
|
|
8
|
+
page: Page;
|
|
9
|
+
appUrl: string;
|
|
10
|
+
lastFrame: string | null; // base64 JPEG
|
|
11
|
+
streaming: boolean;
|
|
12
|
+
frameInterval: ReturnType<typeof setInterval> | null;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sessions = new Map<string, LiveViewSession>();
|
|
17
|
+
const SESSION_TTL_MS = 15 * 60 * 1_000; // 15 min
|
|
18
|
+
|
|
19
|
+
function generateId(): string {
|
|
20
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Start a live view session: launches a headless browser, navigates to appUrl,
|
|
25
|
+
* begins capturing frames. Returns the session ID.
|
|
26
|
+
*/
|
|
27
|
+
export async function startLiveView(appUrl: string, storageState?: string): Promise<string> {
|
|
28
|
+
const id = generateId();
|
|
29
|
+
const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.platform === 'linux';
|
|
30
|
+
|
|
31
|
+
const browser = await chromium.launch({
|
|
32
|
+
headless: true,
|
|
33
|
+
args: isDocker ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] : [],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
37
|
+
|
|
38
|
+
// Restore session if provided
|
|
39
|
+
if (storageState) {
|
|
40
|
+
try {
|
|
41
|
+
const state = JSON.parse(storageState);
|
|
42
|
+
if (state.cookies?.length) await context.addCookies(state.cookies);
|
|
43
|
+
} catch { /* ignore */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const page = await context.newPage();
|
|
47
|
+
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
48
|
+
|
|
49
|
+
const session: LiveViewSession = {
|
|
50
|
+
id, browser, context, page, appUrl,
|
|
51
|
+
lastFrame: null, streaming: true, frameInterval: null,
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Start capturing frames (every 500ms)
|
|
56
|
+
session.frameInterval = setInterval(async () => {
|
|
57
|
+
if (!session.streaming) return;
|
|
58
|
+
try {
|
|
59
|
+
const buf = await page.screenshot({ type: 'jpeg', quality: 60, fullPage: false });
|
|
60
|
+
session.lastFrame = buf.toString('base64');
|
|
61
|
+
} catch { session.streaming = false; }
|
|
62
|
+
}, 500);
|
|
63
|
+
|
|
64
|
+
sessions.set(id, session);
|
|
65
|
+
|
|
66
|
+
// Auto-cleanup after TTL
|
|
67
|
+
setTimeout(() => closeLiveView(id).catch(() => {}), SESSION_TTL_MS);
|
|
68
|
+
|
|
69
|
+
logger.info({ id, appUrl }, '[live-view] Session started');
|
|
70
|
+
return id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the latest frame for a session (base64 JPEG).
|
|
75
|
+
*/
|
|
76
|
+
export function getFrame(id: string): string | null {
|
|
77
|
+
return sessions.get(id)?.lastFrame ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Navigate the live view browser to a URL.
|
|
82
|
+
*/
|
|
83
|
+
export async function liveViewNavigate(id: string, url: string): Promise<void> {
|
|
84
|
+
const session = sessions.get(id);
|
|
85
|
+
if (!session) throw new Error(`Live view session ${id} not found`);
|
|
86
|
+
await session.page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Capture storageState from the live view session, close the browser, return JSON string.
|
|
91
|
+
*/
|
|
92
|
+
export async function completeLiveView(id: string): Promise<string> {
|
|
93
|
+
const session = sessions.get(id);
|
|
94
|
+
if (!session) throw new Error(`Live view session ${id} not found or expired`);
|
|
95
|
+
|
|
96
|
+
const state = await session.context.storageState();
|
|
97
|
+
const json = JSON.stringify(state);
|
|
98
|
+
await closeLiveView(id);
|
|
99
|
+
return json;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send a mouse or keyboard input event to the live view browser.
|
|
104
|
+
*/
|
|
105
|
+
export async function sendInput(id: string, event: {
|
|
106
|
+
type: 'click' | 'type' | 'key';
|
|
107
|
+
x?: number; y?: number;
|
|
108
|
+
button?: 'left' | 'right' | 'middle';
|
|
109
|
+
text?: string;
|
|
110
|
+
key?: string;
|
|
111
|
+
}): Promise<void> {
|
|
112
|
+
const session = sessions.get(id);
|
|
113
|
+
if (!session) throw new Error(`Live view session ${id} not found`);
|
|
114
|
+
if (event.type === 'click' && event.x !== undefined && event.y !== undefined) {
|
|
115
|
+
await session.page.mouse.click(event.x, event.y, { button: event.button ?? 'left' });
|
|
116
|
+
} else if (event.type === 'type' && event.text) {
|
|
117
|
+
await session.page.keyboard.type(event.text);
|
|
118
|
+
} else if (event.type === 'key' && event.key) {
|
|
119
|
+
await session.page.keyboard.press(event.key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Close a live view session without capturing.
|
|
125
|
+
*/
|
|
126
|
+
export async function closeLiveView(id: string): Promise<void> {
|
|
127
|
+
const session = sessions.get(id);
|
|
128
|
+
if (!session) return;
|
|
129
|
+
session.streaming = false;
|
|
130
|
+
if (session.frameInterval) clearInterval(session.frameInterval);
|
|
131
|
+
sessions.delete(id);
|
|
132
|
+
await session.browser.close().catch(() => {});
|
|
133
|
+
logger.info({ id }, '[live-view] Session closed');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* List all active session IDs (for health checks / cleanup).
|
|
138
|
+
*/
|
|
139
|
+
export function listLiveViews(): string[] {
|
|
140
|
+
return [...sessions.keys()];
|
|
141
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
|
|
3
|
+
export const logger = pino({
|
|
4
|
+
level: process.env.LOG_LEVEL ?? 'info',
|
|
5
|
+
...(process.env.NODE_ENV === 'development'
|
|
6
|
+
? { transport: { target: 'pino-pretty', options: { colorize: true } } }
|
|
7
|
+
: {}),
|
|
8
|
+
base: { service: 'crawler' },
|
|
9
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractMarkdown } from './markdown-extract.js';
|
|
4
|
+
|
|
5
|
+
const ARTICLE_HTML = `<!doctype html><html><head><title>How Widgets Work</title></head>
|
|
6
|
+
<body>
|
|
7
|
+
<nav><a href="/">Home</a><a href="/about">About</a></nav>
|
|
8
|
+
<header><div class="logo">WidgetCo</div></header>
|
|
9
|
+
<article>
|
|
10
|
+
<h1>How Widgets Work</h1>
|
|
11
|
+
<p>${'Widgets are small mechanical devices used in countless everyday applications. '.repeat(10)}</p>
|
|
12
|
+
<p>${'This second paragraph explains the history of widget manufacturing in great detail. '.repeat(10)}</p>
|
|
13
|
+
<h2>Manufacturing Process</h2>
|
|
14
|
+
<p>${'The manufacturing process involves several precise steps performed by skilled technicians. '.repeat(10)}</p>
|
|
15
|
+
</article>
|
|
16
|
+
<footer><p>Copyright 2026</p></footer>
|
|
17
|
+
<script>console.log('tracking pixel');</script>
|
|
18
|
+
</body></html>`;
|
|
19
|
+
|
|
20
|
+
const APP_DASHBOARD_HTML = `<!doctype html><html><head><title>Dashboard</title></head>
|
|
21
|
+
<body>
|
|
22
|
+
<div id="app">
|
|
23
|
+
<nav class="sidebar"><a href="/home">Home</a><a href="/settings">Settings</a></nav>
|
|
24
|
+
<main>
|
|
25
|
+
<h1>Dashboard</h1>
|
|
26
|
+
<div class="stat-card"><span>Revenue</span><strong>$12,400</strong></div>
|
|
27
|
+
<div class="stat-card"><span>Users</span><strong>842</strong></div>
|
|
28
|
+
<button>Export CSV</button>
|
|
29
|
+
</main>
|
|
30
|
+
</div>
|
|
31
|
+
<script>window.__STATE__ = {};</script>
|
|
32
|
+
</body></html>`;
|
|
33
|
+
|
|
34
|
+
const EMPTY_HTML = `<!doctype html><html><head><title></title></head><body></body></html>`;
|
|
35
|
+
|
|
36
|
+
test('extractMarkdown: article page uses Readability and strips nav/footer chrome', () => {
|
|
37
|
+
const result = extractMarkdown(ARTICLE_HTML, 'https://example.com/blog/widgets');
|
|
38
|
+
assert.ok(result);
|
|
39
|
+
assert.equal(result!.method, 'readability');
|
|
40
|
+
assert.match(result!.markdown, /How Widgets Work/);
|
|
41
|
+
assert.match(result!.markdown, /Manufacturing Process/);
|
|
42
|
+
assert.doesNotMatch(result!.markdown, /Copyright 2026/);
|
|
43
|
+
assert.doesNotMatch(result!.markdown, /tracking pixel/);
|
|
44
|
+
assert.ok(result!.wordCount > 50);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('extractMarkdown: app dashboard (no article) falls back to full-page conversion', () => {
|
|
48
|
+
const result = extractMarkdown(APP_DASHBOARD_HTML, 'https://app.example.com/dashboard');
|
|
49
|
+
assert.ok(result);
|
|
50
|
+
assert.equal(result!.method, 'full-page');
|
|
51
|
+
assert.match(result!.markdown, /Dashboard/);
|
|
52
|
+
assert.match(result!.markdown, /Revenue/);
|
|
53
|
+
assert.match(result!.markdown, /Export CSV/);
|
|
54
|
+
assert.doesNotMatch(result!.markdown, /__STATE__/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('extractMarkdown: empty page returns null instead of an empty markdown string', () => {
|
|
58
|
+
const result = extractMarkdown(EMPTY_HTML, 'https://example.com/empty');
|
|
59
|
+
assert.equal(result, null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('extractMarkdown: malformed HTML never throws', () => {
|
|
63
|
+
assert.doesNotThrow(() => extractMarkdown('<html><body><div>unclosed', 'https://example.com/x'));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('extractMarkdown: script/style content is never leaked into markdown output', () => {
|
|
67
|
+
const html = `<!doctype html><html><head><title>Page</title><style>.x{color:red}</style></head>
|
|
68
|
+
<body><script>alert('xss-in-markdown')</script><h1>Real Content Heading For Testing</h1><p>${'Some real paragraph text that is long enough to be meaningful content here. '.repeat(6)}</p></body></html>`;
|
|
69
|
+
const result = extractMarkdown(html, 'https://example.com/page');
|
|
70
|
+
assert.ok(result);
|
|
71
|
+
assert.doesNotMatch(result!.markdown, /alert\(/);
|
|
72
|
+
assert.doesNotMatch(result!.markdown, /color:red/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML → Markdown extraction for crawled pages — matches crawl4AI's markdown
|
|
3
|
+
* output capability. Two-tier, mirroring crawl4AI's "markdown" vs "fit markdown":
|
|
4
|
+
*
|
|
5
|
+
* - "fit" markdown: Readability's best guess at the primary content (title +
|
|
6
|
+
* article body), stripped of nav/ads/chrome. Ideal for content/marketing
|
|
7
|
+
* pages and RAG chunking. null when Readability finds no article (most
|
|
8
|
+
* app UI screens — dashboards, forms — legitimately have none).
|
|
9
|
+
* - "full" markdown: the whole visible page converted straight to Markdown,
|
|
10
|
+
* always present. The fallback for app screens where there's no "article".
|
|
11
|
+
*
|
|
12
|
+
* Runs entirely on an already-captured `page.content()` string — no extra
|
|
13
|
+
* network fetch, no extra page navigation.
|
|
14
|
+
*/
|
|
15
|
+
import { parseHTML } from 'linkedom';
|
|
16
|
+
import { Readability } from '@mozilla/readability';
|
|
17
|
+
import TurndownService from 'turndown';
|
|
18
|
+
|
|
19
|
+
const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
|
20
|
+
turndown.remove(['script', 'style', 'noscript', 'svg', 'iframe'] as unknown as (keyof HTMLElementTagNameMap)[]);
|
|
21
|
+
|
|
22
|
+
export interface ExtractedMarkdown {
|
|
23
|
+
markdown: string;
|
|
24
|
+
method: 'readability' | 'full-page';
|
|
25
|
+
title: string | null;
|
|
26
|
+
wordCount: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stripNoiseElements(document: Document): void {
|
|
30
|
+
const noisy = document.querySelectorAll('script, style, noscript, svg, iframe, [aria-hidden="true"]');
|
|
31
|
+
noisy.forEach((el: any) => el.remove());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Best-effort HTML→Markdown for one crawled page. Never throws. */
|
|
35
|
+
export function extractMarkdown(html: string, url: string): ExtractedMarkdown | null {
|
|
36
|
+
try {
|
|
37
|
+
const { document } = parseHTML(html);
|
|
38
|
+
stripNoiseElements(document);
|
|
39
|
+
|
|
40
|
+
let readabilityResult: { title: string; content: string; textContent: string } | null = null;
|
|
41
|
+
try {
|
|
42
|
+
// Readability mutates the DOM it's given — clone via re-parsing so the
|
|
43
|
+
// full-page fallback below still has an intact document.
|
|
44
|
+
const { document: readerDoc } = parseHTML(html);
|
|
45
|
+
stripNoiseElements(readerDoc);
|
|
46
|
+
readabilityResult = new Readability(readerDoc as any, { charThreshold: 50 }).parse() as any;
|
|
47
|
+
} catch {
|
|
48
|
+
readabilityResult = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (readabilityResult?.content && readabilityResult.textContent.trim().length >= 50) {
|
|
52
|
+
const markdown = turndown.turndown(readabilityResult.content);
|
|
53
|
+
return {
|
|
54
|
+
markdown: `# ${readabilityResult.title || document.title || url}\n\n${markdown}`,
|
|
55
|
+
method: 'readability',
|
|
56
|
+
title: readabilityResult.title || document.title || null,
|
|
57
|
+
wordCount: readabilityResult.textContent.trim().split(/\s+/).length,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const body = document.body;
|
|
62
|
+
if (!body) return null;
|
|
63
|
+
const markdown = turndown.turndown(body.innerHTML || '');
|
|
64
|
+
const text = markdown.trim();
|
|
65
|
+
if (!text) return null;
|
|
66
|
+
const title = document.title || null;
|
|
67
|
+
return {
|
|
68
|
+
markdown: title ? `# ${title}\n\n${markdown}` : markdown,
|
|
69
|
+
method: 'full-page',
|
|
70
|
+
title,
|
|
71
|
+
wordCount: text.split(/\s+/).length,
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|