webcrawl 1.2.4 → 1.2.7
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/dist/cli.js +3 -2
- package/dist/core/engine/awswaf.d.ts +37 -0
- package/dist/core/engine/awswaf.js +189 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +8 -0
- package/dist/core/extract/index.js +8 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +642 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +182 -0
- package/dist/core/search/catalog.d.ts +174 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/package.json +82 -4
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path scoping for map/crawl: restrict discovered URLs to a subpath and/or
|
|
3
|
+
* include/exclude path patterns. Patterns match the URL path (+ query):
|
|
4
|
+
* "/docs" prefix at a segment boundary ("/docs", "/docs/a", not "/docs-old")
|
|
5
|
+
* "/blog/*" `*` matches any characters
|
|
6
|
+
* "/api/*\/v1$" trailing `$` anchors the end
|
|
7
|
+
*/
|
|
8
|
+
function toRegExp(pattern) {
|
|
9
|
+
const p = pattern.trim();
|
|
10
|
+
const anchored = p.endsWith("$");
|
|
11
|
+
const raw = anchored ? p.slice(0, -1) : p;
|
|
12
|
+
const hasWildcard = raw.includes("*");
|
|
13
|
+
const body = raw
|
|
14
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
15
|
+
.replace(/\*/g, ".*");
|
|
16
|
+
if (anchored)
|
|
17
|
+
return new RegExp(`^${body}$`);
|
|
18
|
+
if (hasWildcard)
|
|
19
|
+
return new RegExp(`^${body}`);
|
|
20
|
+
// Plain prefix: match at a segment boundary.
|
|
21
|
+
const trimmed = body.replace(/\/+$/, "");
|
|
22
|
+
return new RegExp(`^${trimmed}(?:/|$|\\?)`);
|
|
23
|
+
}
|
|
24
|
+
function normalizePattern(p) {
|
|
25
|
+
let s = String(p ?? "").trim();
|
|
26
|
+
if (!s)
|
|
27
|
+
return null;
|
|
28
|
+
// Accept full URLs by taking their path.
|
|
29
|
+
if (/^https?:\/\//i.test(s)) {
|
|
30
|
+
try {
|
|
31
|
+
s = new URL(s).pathname;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!s.startsWith("/") && !s.startsWith("*"))
|
|
38
|
+
s = `/${s}`;
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
export function buildPathScope(startUrl, options = {}) {
|
|
42
|
+
const clean = (list) => (list ?? []).map(normalizePattern).filter((x) => !!x).slice(0, 100);
|
|
43
|
+
let includes = clean(options.includePaths);
|
|
44
|
+
const excludes = clean(options.excludePaths);
|
|
45
|
+
if (includes.length === 0 && options.restrictToSubpath !== false) {
|
|
46
|
+
try {
|
|
47
|
+
const path = new URL(startUrl).pathname.replace(/\/+$/, "");
|
|
48
|
+
if (path && path !== "")
|
|
49
|
+
includes = [path];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// ignore invalid start URL
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const inc = includes.map(toRegExp);
|
|
56
|
+
const exc = excludes.map(toRegExp);
|
|
57
|
+
let startKey = "";
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(startUrl);
|
|
60
|
+
startKey = (u.pathname.replace(/\/+$/, "") || "/") + u.search;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
includes,
|
|
67
|
+
excludes,
|
|
68
|
+
signature: `inc=${includes.join(",")}|exc=${excludes.join(",")}`,
|
|
69
|
+
allows(url) {
|
|
70
|
+
let key;
|
|
71
|
+
try {
|
|
72
|
+
const u = new URL(url);
|
|
73
|
+
key = (u.pathname || "/") + u.search;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
// The start URL itself is always allowed.
|
|
79
|
+
if ((key.replace(/\/+$/, "") || "/") === startKey)
|
|
80
|
+
return true;
|
|
81
|
+
if (exc.some((r) => r.test(key)))
|
|
82
|
+
return false;
|
|
83
|
+
if (inc.length && !inc.some((r) => r.test(key)))
|
|
84
|
+
return false;
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare class UnsafeUrlError extends Error {
|
|
2
|
+
constructor(message?: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function isPrivateAddress(ip: string): boolean;
|
|
5
|
+
export type HostResolver = (host: string) => Promise<string[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Throws `UnsafeUrlError` unless `raw` is an http(s) URL whose host resolves
|
|
8
|
+
* only to public addresses. Call it before every request and every redirect hop.
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertSafeUrl(raw: string, resolve?: HostResolver): Promise<URL>;
|
|
11
|
+
export declare const MAX_REDIRECTS = 8;
|
|
12
|
+
export interface HopResponse {
|
|
13
|
+
status: number;
|
|
14
|
+
headers: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Runs `send` for `url` and follows redirects manually, validating every hop
|
|
18
|
+
* with `assertSafeUrl`. `send` must NOT follow redirects itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function followSafely<T extends HopResponse>(url: string, send: (hopUrl: string) => Promise<T>, resolve?: HostResolver): Promise<{
|
|
21
|
+
res: T;
|
|
22
|
+
finalUrl: string;
|
|
23
|
+
}>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
export class UnsafeUrlError extends Error {
|
|
4
|
+
constructor(message = "That address isn't publicly reachable") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "UnsafeUrlError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isPrivateAddress(ip) {
|
|
10
|
+
const v = isIP(ip);
|
|
11
|
+
if (v === 4) {
|
|
12
|
+
const [a, b] = ip.split(".").map(Number);
|
|
13
|
+
return (a === 0 ||
|
|
14
|
+
a === 10 ||
|
|
15
|
+
a === 127 ||
|
|
16
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
17
|
+
(a === 169 && b === 254) ||
|
|
18
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
19
|
+
(a === 192 && b === 168) ||
|
|
20
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
21
|
+
a >= 224);
|
|
22
|
+
}
|
|
23
|
+
if (v === 6) {
|
|
24
|
+
const s = ip.toLowerCase();
|
|
25
|
+
if (s.startsWith("::ffff:"))
|
|
26
|
+
return isPrivateAddress(s.slice(7));
|
|
27
|
+
return (s === "::" ||
|
|
28
|
+
s === "::1" ||
|
|
29
|
+
s.startsWith("fc") ||
|
|
30
|
+
s.startsWith("fd") ||
|
|
31
|
+
s.startsWith("fe8") ||
|
|
32
|
+
s.startsWith("fe9") ||
|
|
33
|
+
s.startsWith("fea") ||
|
|
34
|
+
s.startsWith("feb"));
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const defaultResolve = async (h) => (await lookup(h, { all: true })).map((r) => r.address);
|
|
39
|
+
/** Local development escape hatch (never set in production). */
|
|
40
|
+
function allowPrivate() {
|
|
41
|
+
return process.env.WEBCRAWL_ALLOW_PRIVATE_URLS === "true";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Throws `UnsafeUrlError` unless `raw` is an http(s) URL whose host resolves
|
|
45
|
+
* only to public addresses. Call it before every request and every redirect hop.
|
|
46
|
+
*/
|
|
47
|
+
export async function assertSafeUrl(raw, resolve = defaultResolve) {
|
|
48
|
+
let u;
|
|
49
|
+
try {
|
|
50
|
+
u = new URL(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new UnsafeUrlError("Invalid URL");
|
|
54
|
+
}
|
|
55
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
56
|
+
throw new UnsafeUrlError("Only http and https addresses are supported");
|
|
57
|
+
}
|
|
58
|
+
if (allowPrivate())
|
|
59
|
+
return u;
|
|
60
|
+
const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
61
|
+
if (host === "localhost" ||
|
|
62
|
+
host.endsWith(".localhost") ||
|
|
63
|
+
host.endsWith(".local") ||
|
|
64
|
+
host.endsWith(".internal")) {
|
|
65
|
+
throw new UnsafeUrlError();
|
|
66
|
+
}
|
|
67
|
+
const addrs = isIP(host) ? [host] : await resolve(host).catch(() => []);
|
|
68
|
+
if (addrs.length === 0)
|
|
69
|
+
throw new UnsafeUrlError("We couldn't find that website");
|
|
70
|
+
if (addrs.some(isPrivateAddress))
|
|
71
|
+
throw new UnsafeUrlError();
|
|
72
|
+
return u;
|
|
73
|
+
}
|
|
74
|
+
export const MAX_REDIRECTS = 8;
|
|
75
|
+
/**
|
|
76
|
+
* Runs `send` for `url` and follows redirects manually, validating every hop
|
|
77
|
+
* with `assertSafeUrl`. `send` must NOT follow redirects itself.
|
|
78
|
+
*/
|
|
79
|
+
export async function followSafely(url, send, resolve) {
|
|
80
|
+
let current = url;
|
|
81
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
82
|
+
await assertSafeUrl(current, resolve);
|
|
83
|
+
const res = await send(current);
|
|
84
|
+
const loc = res.headers["location"];
|
|
85
|
+
if (res.status >= 300 && res.status < 400 && loc) {
|
|
86
|
+
current = new URL(loc, current).toString();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
return { res, finalUrl: current };
|
|
90
|
+
}
|
|
91
|
+
throw new UnsafeUrlError("Too many redirects");
|
|
92
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type RemoteProvider } from "../interact/remote.js";
|
|
2
|
+
export interface StealthDoc {
|
|
3
|
+
url: string;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status?: number | null;
|
|
6
|
+
contentType?: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
finalUrl?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
blocked?: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface StealthResult {
|
|
14
|
+
/** True when the entry page passed the bot check. */
|
|
15
|
+
solved: boolean;
|
|
16
|
+
entry: StealthDoc | null;
|
|
17
|
+
docs: StealthDoc[];
|
|
18
|
+
durationMs: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Set when a remote provider (e.g. Browser Use) hosted the browser. */
|
|
21
|
+
provider?: RemoteProvider;
|
|
22
|
+
/** Provider charge for the session in USD, when it reported one. */
|
|
23
|
+
providerCostUsd?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface StealthItem {
|
|
26
|
+
url: string;
|
|
27
|
+
/** Read as a raw document (robots.txt, sitemap XML) instead of rendering it. */
|
|
28
|
+
raw?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* True when a real browser can run here: our own (sandbox in prod, local
|
|
32
|
+
* Playwright in dev) or a remote provider. A remote browser still needs the
|
|
33
|
+
* sandbox or local Playwright to run the step script against it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function stealthAvailable(): Promise<boolean>;
|
|
36
|
+
/** True when this fetched document is a bot check, not the real content. */
|
|
37
|
+
export declare function isBlockedResponse(status: number | null | undefined, body: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Opens `entryUrl` in a real browser, waits for the bot check to clear, then
|
|
40
|
+
* reads `items` in order through that session. One browser per call.
|
|
41
|
+
*/
|
|
42
|
+
export declare function stealthFetch(entryUrl: string, items: StealthItem[], opts?: {
|
|
43
|
+
budgetMs?: number;
|
|
44
|
+
solveMs?: number;
|
|
45
|
+
followSitemaps?: number;
|
|
46
|
+
/** Allow the paid remote provider. Off for unbilled (anonymous) runs. Default true. */
|
|
47
|
+
remote?: boolean;
|
|
48
|
+
}): Promise<StealthResult>;
|
|
49
|
+
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
50
|
+
export declare function parseSitemapLocs(xml: string): {
|
|
51
|
+
pages: string[];
|
|
52
|
+
sitemaps: string[];
|
|
53
|
+
};
|
|
54
|
+
/** Sitemap URLs declared in robots.txt. */
|
|
55
|
+
export declare function sitemapsFromRobots(robots: string): string[];
|
|
56
|
+
/**
|
|
57
|
+
* Finds a blocked site's pages through one stealth session: robots.txt and the
|
|
58
|
+
* usual sitemap paths, following robots-declared and child sitemaps in the same
|
|
59
|
+
* session. Also returns the rendered entry page so its links count when there
|
|
60
|
+
* is no sitemap.
|
|
61
|
+
*/
|
|
62
|
+
export declare function stealthDiscover(entryUrl: string, opts?: {
|
|
63
|
+
budgetMs?: number;
|
|
64
|
+
sitemapUrls?: string[];
|
|
65
|
+
remote?: boolean;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
solved: boolean;
|
|
68
|
+
sitemapPages: string[];
|
|
69
|
+
sitemapsRead: string[];
|
|
70
|
+
entry: StealthDoc | null;
|
|
71
|
+
durationMs: number;
|
|
72
|
+
error?: string;
|
|
73
|
+
providerCostUsd?: number;
|
|
74
|
+
}>;
|
|
75
|
+
/** Page URLs from every readable sitemap among `docs` (robots.txt and blocked docs are skipped). */
|
|
76
|
+
export declare function collectSitemapPages(docs: StealthDoc[]): {
|
|
77
|
+
pages: string[];
|
|
78
|
+
sitemapsRead: string[];
|
|
79
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth mode: read pages a bot check refuses to plain HTTP clients.
|
|
3
|
+
*
|
|
4
|
+
* One real Chromium session (the interact browser: Vercel Sandbox in prod,
|
|
5
|
+
* local Playwright in dev) opens the entry page, waits until the check clears,
|
|
6
|
+
* then reads every requested URL with that cleared session. Raw documents
|
|
7
|
+
* (robots.txt, sitemaps) are read with fetch() from inside the page so they
|
|
8
|
+
* carry the same cookies, IP and TLS fingerprint as the browser.
|
|
9
|
+
*
|
|
10
|
+
* Never fabricates content: a page still showing a check comes back `blocked`.
|
|
11
|
+
*/
|
|
12
|
+
import { isAntiBotChallenge } from "./fetcher.js";
|
|
13
|
+
import { remoteStealthProvider } from "../interact/remote.js";
|
|
14
|
+
/**
|
|
15
|
+
* True when a real browser can run here: our own (sandbox in prod, local
|
|
16
|
+
* Playwright in dev) or a remote provider. A remote browser still needs the
|
|
17
|
+
* sandbox or local Playwright to run the step script against it.
|
|
18
|
+
*/
|
|
19
|
+
export async function stealthAvailable() {
|
|
20
|
+
if (process.env.WEBCRAWL_STEALTH === "false")
|
|
21
|
+
return false;
|
|
22
|
+
const { interactBrowserAvailable } = await import("../interact/driver.js");
|
|
23
|
+
return interactBrowserAvailable();
|
|
24
|
+
}
|
|
25
|
+
/** True when this fetched document is a bot check, not the real content. */
|
|
26
|
+
export function isBlockedResponse(status, body) {
|
|
27
|
+
return isAntiBotChallenge(body ?? "", status ?? 200);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Opens `entryUrl` in a real browser, waits for the bot check to clear, then
|
|
31
|
+
* reads `items` in order through that session. One browser per call.
|
|
32
|
+
*/
|
|
33
|
+
export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
34
|
+
const t0 = Date.now();
|
|
35
|
+
if (!(await stealthAvailable())) {
|
|
36
|
+
return { solved: false, entry: null, docs: [], durationMs: 0, error: "Stealth browser not configured" };
|
|
37
|
+
}
|
|
38
|
+
const { startBrowser, runBrowserStep, stopBrowser } = await import("../interact/driver.js");
|
|
39
|
+
const provider = opts.remote === false ? null : remoteStealthProvider();
|
|
40
|
+
const tag = provider ? { provider } : {};
|
|
41
|
+
let handle = null;
|
|
42
|
+
// Filled in by `finally` once the provider reports the session cost.
|
|
43
|
+
const out = {};
|
|
44
|
+
try {
|
|
45
|
+
const budgetMs = opts.budgetMs ?? 90_000;
|
|
46
|
+
handle = await startBrowser({ timeoutMs: Math.min(5 * 60_000, budgetMs + 90_000), remote: provider });
|
|
47
|
+
const res = await runBrowserStep(handle, {
|
|
48
|
+
type: "fetchMany",
|
|
49
|
+
entry: entryUrl,
|
|
50
|
+
items: items.slice(0, 40),
|
|
51
|
+
budgetMs,
|
|
52
|
+
solveMs: opts.solveMs ?? 20_000,
|
|
53
|
+
followSitemaps: opts.followSitemaps ?? 0,
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: res.error || "Stealth browser step failed", ...tag };
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const value = (res.data?.value ?? {});
|
|
60
|
+
out.result = {
|
|
61
|
+
solved: Boolean(value.solved),
|
|
62
|
+
entry: value.entry ?? null,
|
|
63
|
+
docs: Array.isArray(value.docs) ? value.docs : [],
|
|
64
|
+
durationMs: 0,
|
|
65
|
+
...tag,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: err instanceof Error ? err.message : String(err), ...tag };
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
const cost = handle ? await stopBrowser(handle).catch(() => null) : null;
|
|
74
|
+
if (out.result && cost)
|
|
75
|
+
out.result.providerCostUsd = cost.costUsd;
|
|
76
|
+
}
|
|
77
|
+
const result = out.result;
|
|
78
|
+
result.durationMs = Date.now() - t0;
|
|
79
|
+
if (provider) {
|
|
80
|
+
console.info("[stealth] remote session", {
|
|
81
|
+
provider,
|
|
82
|
+
solved: result.solved,
|
|
83
|
+
docs: result.docs.length,
|
|
84
|
+
costUsd: result.providerCostUsd,
|
|
85
|
+
durationMs: result.durationMs,
|
|
86
|
+
error: result.error,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
92
|
+
export function parseSitemapLocs(xml) {
|
|
93
|
+
const pages = [];
|
|
94
|
+
const sitemaps = [];
|
|
95
|
+
const isIndex = /<sitemapindex[\s>]/i.test(xml);
|
|
96
|
+
for (const m of xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?([^<\]]+?)(?:\]\]>)?\s*<\/loc>/gi)) {
|
|
97
|
+
const loc = m[1]
|
|
98
|
+
.trim()
|
|
99
|
+
.replace(/&/g, "&")
|
|
100
|
+
.replace(/</g, "<")
|
|
101
|
+
.replace(/>/g, ">")
|
|
102
|
+
.replace(/"/g, '"')
|
|
103
|
+
.replace(/'|'/g, "'");
|
|
104
|
+
if (isIndex || /\.xml(\.gz)?(\?|$)/i.test(loc))
|
|
105
|
+
sitemaps.push(loc);
|
|
106
|
+
else
|
|
107
|
+
pages.push(loc);
|
|
108
|
+
}
|
|
109
|
+
return { pages, sitemaps };
|
|
110
|
+
}
|
|
111
|
+
/** Sitemap URLs declared in robots.txt. */
|
|
112
|
+
export function sitemapsFromRobots(robots) {
|
|
113
|
+
return Array.from(robots.matchAll(/^\s*Sitemap:\s*(\S+)/gim)).map((m) => m[1].trim());
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Finds a blocked site's pages through one stealth session: robots.txt and the
|
|
117
|
+
* usual sitemap paths, following robots-declared and child sitemaps in the same
|
|
118
|
+
* session. Also returns the rendered entry page so its links count when there
|
|
119
|
+
* is no sitemap.
|
|
120
|
+
*/
|
|
121
|
+
export async function stealthDiscover(entryUrl, opts = {}) {
|
|
122
|
+
const origin = new URL(entryUrl).origin;
|
|
123
|
+
const first = await stealthFetch(entryUrl, [
|
|
124
|
+
...(opts.sitemapUrls ?? []).map((url) => ({ url, raw: true })),
|
|
125
|
+
{ url: `${origin}/robots.txt`, raw: true },
|
|
126
|
+
{ url: `${origin}/sitemap.xml`, raw: true },
|
|
127
|
+
{ url: `${origin}/sitemap_index.xml`, raw: true },
|
|
128
|
+
{ url: `${origin}/sitemap-index.xml`, raw: true },
|
|
129
|
+
], { budgetMs: opts.budgetMs ?? 75_000, followSitemaps: 12, remote: opts.remote });
|
|
130
|
+
const { pages, sitemapsRead } = collectSitemapPages(first.docs);
|
|
131
|
+
return {
|
|
132
|
+
solved: first.solved,
|
|
133
|
+
sitemapPages: pages,
|
|
134
|
+
sitemapsRead,
|
|
135
|
+
entry: first.entry,
|
|
136
|
+
durationMs: first.durationMs,
|
|
137
|
+
...(first.error ? { error: first.error } : {}),
|
|
138
|
+
...(first.providerCostUsd !== undefined ? { providerCostUsd: first.providerCostUsd } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Page URLs from every readable sitemap among `docs` (robots.txt and blocked docs are skipped). */
|
|
142
|
+
export function collectSitemapPages(docs) {
|
|
143
|
+
const pages = new Set();
|
|
144
|
+
const sitemapsRead = [];
|
|
145
|
+
for (const doc of docs) {
|
|
146
|
+
if (!doc.ok || !doc.body || doc.blocked || (doc.status ?? 200) >= 400)
|
|
147
|
+
continue;
|
|
148
|
+
if (/\/robots\.txt(\?|$)/i.test(doc.url) || !doc.body.includes("<loc>"))
|
|
149
|
+
continue;
|
|
150
|
+
sitemapsRead.push(doc.url);
|
|
151
|
+
for (const u of parseSitemapLocs(doc.body).pages)
|
|
152
|
+
pages.add(u);
|
|
153
|
+
}
|
|
154
|
+
return { pages: Array.from(pages), sitemapsRead };
|
|
155
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./engine/index.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./engine/index.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface BusinessListing {
|
|
2
|
+
name: string;
|
|
3
|
+
category?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
address?: string;
|
|
6
|
+
city?: string;
|
|
7
|
+
state?: string;
|
|
8
|
+
postcode?: string;
|
|
9
|
+
country?: string;
|
|
10
|
+
phone?: string;
|
|
11
|
+
email?: string;
|
|
12
|
+
website?: string;
|
|
13
|
+
rating?: number;
|
|
14
|
+
reviewCount?: number;
|
|
15
|
+
priceRange?: string;
|
|
16
|
+
openingHours?: string | string[];
|
|
17
|
+
foundedYear?: number;
|
|
18
|
+
employeeCount?: string;
|
|
19
|
+
headquarters?: string;
|
|
20
|
+
latitude?: number;
|
|
21
|
+
longitude?: number;
|
|
22
|
+
url: string;
|
|
23
|
+
sourceDomain: string;
|
|
24
|
+
extractedVia: "json-state" | "rsc-payload" | "json-ld" | "scaledown-extract-1" | "serp-snippet";
|
|
25
|
+
}
|
|
26
|
+
export declare function extractBusinessListingsFromHtml(html: string, pageUrl: string, countryCode?: string): BusinessListing[];
|
|
27
|
+
export declare function extractBusinessListingFromSerpResult(title: string, description: string, url: string, countryCode?: string): BusinessListing | null;
|
|
28
|
+
export declare function dedupeBusinessListings(businesses: BusinessListing[], maxItems?: number): BusinessListing[];
|