atlas-mcp-web 0.1.0
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 +125 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +194 -0
- package/dist/lib/fetch.d.ts +12 -0
- package/dist/lib/fetch.js +39 -0
- package/dist/tools/article.d.ts +23 -0
- package/dist/tools/article.js +260 -0
- package/dist/tools/contact.d.ts +19 -0
- package/dist/tools/contact.js +85 -0
- package/dist/tools/links.d.ts +34 -0
- package/dist/tools/links.js +107 -0
- package/dist/tools/metadata.d.ts +32 -0
- package/dist/tools/metadata.js +112 -0
- package/dist/tools/tables.d.ts +18 -0
- package/dist/tools/tables.js +75 -0
- package/dist/tools/techstack.d.ts +19 -0
- package/dist/tools/techstack.js +138 -0
- package/package.json +48 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract contact information from a page: emails, phones, social handles.
|
|
3
|
+
*/
|
|
4
|
+
import type { CheerioAPI } from 'cheerio';
|
|
5
|
+
export declare function extractContact({ url, $, html, }: {
|
|
6
|
+
url: string;
|
|
7
|
+
$: CheerioAPI;
|
|
8
|
+
html: string;
|
|
9
|
+
}): {
|
|
10
|
+
url: string;
|
|
11
|
+
emails: string[];
|
|
12
|
+
phones: string[];
|
|
13
|
+
socials: Record<string, string[]>;
|
|
14
|
+
counts: {
|
|
15
|
+
emails: number;
|
|
16
|
+
phones: number;
|
|
17
|
+
socialProfiles: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract contact information from a page: emails, phones, social handles.
|
|
3
|
+
*/
|
|
4
|
+
const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
5
|
+
// Simple international phone regex — at least 7 digits, optional + prefix, spaces/dashes/parens allowed
|
|
6
|
+
const PHONE_RE = /(?:\+?\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}(?:[\s.-]?\d{1,4})?/g;
|
|
7
|
+
const SOCIAL_PATTERNS = [
|
|
8
|
+
{ platform: 'twitter', re: /(?:https?:\/\/)?(?:www\.)?(?:twitter|x)\.com\/([A-Za-z0-9_]{1,15})/g },
|
|
9
|
+
{ platform: 'linkedin', re: /(?:https?:\/\/)?(?:www\.)?linkedin\.com\/(?:in|company)\/([A-Za-z0-9-_%]+)/g },
|
|
10
|
+
{ platform: 'instagram', re: /(?:https?:\/\/)?(?:www\.)?instagram\.com\/([A-Za-z0-9_.]+)/g },
|
|
11
|
+
{ platform: 'facebook', re: /(?:https?:\/\/)?(?:www\.)?facebook\.com\/([A-Za-z0-9.-]+)/g },
|
|
12
|
+
{ platform: 'youtube', re: /(?:https?:\/\/)?(?:www\.)?youtube\.com\/(?:@[A-Za-z0-9._-]+|channel\/[A-Za-z0-9_-]+|c\/[A-Za-z0-9_-]+)/g },
|
|
13
|
+
{ platform: 'github', re: /(?:https?:\/\/)?(?:www\.)?github\.com\/([A-Za-z0-9-]+)(?:\/[A-Za-z0-9_.-]+)?/g },
|
|
14
|
+
{ platform: 'tiktok', re: /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@([A-Za-z0-9._]+)/g },
|
|
15
|
+
];
|
|
16
|
+
// Exclude common false positive domains for emails
|
|
17
|
+
const EMAIL_NOISE = [
|
|
18
|
+
'example.com',
|
|
19
|
+
'domain.com',
|
|
20
|
+
'email.com',
|
|
21
|
+
'sentry.io',
|
|
22
|
+
'wixpress.com',
|
|
23
|
+
'w3.org',
|
|
24
|
+
'schema.org',
|
|
25
|
+
];
|
|
26
|
+
export function extractContact({ url, $, html, }) {
|
|
27
|
+
// Strip script/style content for email/phone scanning to avoid tracking noise
|
|
28
|
+
const $clone = $.root().clone();
|
|
29
|
+
$clone.find('script, style, noscript').remove();
|
|
30
|
+
const text = $clone.text();
|
|
31
|
+
// Emails from text
|
|
32
|
+
const emailSet = new Set();
|
|
33
|
+
for (const m of text.matchAll(EMAIL_RE)) {
|
|
34
|
+
const e = m[0].toLowerCase();
|
|
35
|
+
if (!EMAIL_NOISE.some((noise) => e.includes(noise)))
|
|
36
|
+
emailSet.add(e);
|
|
37
|
+
}
|
|
38
|
+
// Emails from mailto: links
|
|
39
|
+
$('a[href^="mailto:"]').each((_, el) => {
|
|
40
|
+
const href = $(el).attr('href') || '';
|
|
41
|
+
const e = href.slice(7).split('?')[0].toLowerCase();
|
|
42
|
+
if (e)
|
|
43
|
+
emailSet.add(e);
|
|
44
|
+
});
|
|
45
|
+
// Phones from text
|
|
46
|
+
const phoneSet = new Set();
|
|
47
|
+
for (const m of text.matchAll(PHONE_RE)) {
|
|
48
|
+
const normalized = m[0].replace(/\s+/g, ' ').trim();
|
|
49
|
+
const digitCount = (normalized.match(/\d/g) || []).length;
|
|
50
|
+
if (digitCount >= 7 && digitCount <= 15)
|
|
51
|
+
phoneSet.add(normalized);
|
|
52
|
+
}
|
|
53
|
+
// Phones from tel: links (more reliable)
|
|
54
|
+
$('a[href^="tel:"]').each((_, el) => {
|
|
55
|
+
const href = $(el).attr('href') || '';
|
|
56
|
+
const p = href.slice(4).trim();
|
|
57
|
+
if (p)
|
|
58
|
+
phoneSet.add(p);
|
|
59
|
+
});
|
|
60
|
+
// Social handles from the full HTML (more reliable source)
|
|
61
|
+
const socials = {};
|
|
62
|
+
for (const { platform, re } of SOCIAL_PATTERNS) {
|
|
63
|
+
const set = new Set();
|
|
64
|
+
for (const m of html.matchAll(re)) {
|
|
65
|
+
set.add(m[0].replace(/^https?:\/\//, '').replace(/^www\./, ''));
|
|
66
|
+
}
|
|
67
|
+
if (set.size > 0)
|
|
68
|
+
socials[platform] = set;
|
|
69
|
+
}
|
|
70
|
+
const socialOut = {};
|
|
71
|
+
for (const [platform, set] of Object.entries(socials)) {
|
|
72
|
+
socialOut[platform] = Array.from(set).slice(0, 20);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
url,
|
|
76
|
+
emails: Array.from(emailSet).slice(0, 50),
|
|
77
|
+
phones: Array.from(phoneSet).slice(0, 20),
|
|
78
|
+
socials: socialOut,
|
|
79
|
+
counts: {
|
|
80
|
+
emails: emailSet.size,
|
|
81
|
+
phones: phoneSet.size,
|
|
82
|
+
socialProfiles: Object.values(socials).reduce((sum, s) => sum + s.size, 0),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract and categorize all links from a page.
|
|
3
|
+
*/
|
|
4
|
+
import type { CheerioAPI } from 'cheerio';
|
|
5
|
+
export declare function extractLinks({ url, $, sameDomainOnly, }: {
|
|
6
|
+
url: string;
|
|
7
|
+
$: CheerioAPI;
|
|
8
|
+
sameDomainOnly: boolean;
|
|
9
|
+
}): {
|
|
10
|
+
url: string;
|
|
11
|
+
sourceDomain: string | null;
|
|
12
|
+
counts: {
|
|
13
|
+
internal: number;
|
|
14
|
+
external: number;
|
|
15
|
+
social: number;
|
|
16
|
+
email: number;
|
|
17
|
+
phone: number;
|
|
18
|
+
};
|
|
19
|
+
internal: {
|
|
20
|
+
href: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}[];
|
|
23
|
+
external: {
|
|
24
|
+
href: string;
|
|
25
|
+
text: string;
|
|
26
|
+
}[];
|
|
27
|
+
social: {
|
|
28
|
+
platform: string;
|
|
29
|
+
href: string;
|
|
30
|
+
text: string;
|
|
31
|
+
}[];
|
|
32
|
+
email: string[];
|
|
33
|
+
phone: string[];
|
|
34
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract and categorize all links from a page.
|
|
3
|
+
*/
|
|
4
|
+
const SOCIAL_HOSTS = {
|
|
5
|
+
'twitter.com': 'twitter',
|
|
6
|
+
'x.com': 'twitter',
|
|
7
|
+
'linkedin.com': 'linkedin',
|
|
8
|
+
'facebook.com': 'facebook',
|
|
9
|
+
'instagram.com': 'instagram',
|
|
10
|
+
'youtube.com': 'youtube',
|
|
11
|
+
'youtu.be': 'youtube',
|
|
12
|
+
'github.com': 'github',
|
|
13
|
+
'tiktok.com': 'tiktok',
|
|
14
|
+
'reddit.com': 'reddit',
|
|
15
|
+
'threads.net': 'threads',
|
|
16
|
+
'bsky.app': 'bluesky',
|
|
17
|
+
'medium.com': 'medium',
|
|
18
|
+
'discord.gg': 'discord',
|
|
19
|
+
'discord.com': 'discord',
|
|
20
|
+
};
|
|
21
|
+
export function extractLinks({ url, $, sameDomainOnly, }) {
|
|
22
|
+
const sourceDomain = safeDomain(url);
|
|
23
|
+
const internal = [];
|
|
24
|
+
const external = [];
|
|
25
|
+
const social = [];
|
|
26
|
+
const email = [];
|
|
27
|
+
const phone = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
$('a[href]').each((_, el) => {
|
|
30
|
+
const rawHref = ($(el).attr('href') || '').trim();
|
|
31
|
+
if (!rawHref)
|
|
32
|
+
return;
|
|
33
|
+
const text = ($(el).text() || '').trim().slice(0, 200);
|
|
34
|
+
// mailto:
|
|
35
|
+
if (rawHref.startsWith('mailto:')) {
|
|
36
|
+
const e = rawHref.slice(7).split('?')[0].toLowerCase();
|
|
37
|
+
if (e && !email.includes(e))
|
|
38
|
+
email.push(e);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// tel:
|
|
42
|
+
if (rawHref.startsWith('tel:')) {
|
|
43
|
+
const p = rawHref.slice(4).replace(/\s+/g, '');
|
|
44
|
+
if (p && !phone.includes(p))
|
|
45
|
+
phone.push(p);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Skip anchors and javascript
|
|
49
|
+
if (rawHref.startsWith('#') || rawHref.startsWith('javascript:'))
|
|
50
|
+
return;
|
|
51
|
+
const resolved = resolve(rawHref, url);
|
|
52
|
+
if (!resolved)
|
|
53
|
+
return;
|
|
54
|
+
if (seen.has(resolved))
|
|
55
|
+
return;
|
|
56
|
+
seen.add(resolved);
|
|
57
|
+
const host = safeDomain(resolved);
|
|
58
|
+
if (!host)
|
|
59
|
+
return;
|
|
60
|
+
// Social?
|
|
61
|
+
for (const [socialHost, platform] of Object.entries(SOCIAL_HOSTS)) {
|
|
62
|
+
if (host === socialHost || host.endsWith('.' + socialHost)) {
|
|
63
|
+
social.push({ platform, href: resolved, text });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const isInternal = sourceDomain && (host === sourceDomain || host.endsWith('.' + sourceDomain));
|
|
68
|
+
if (isInternal) {
|
|
69
|
+
internal.push({ href: resolved, text });
|
|
70
|
+
}
|
|
71
|
+
else if (!sameDomainOnly) {
|
|
72
|
+
external.push({ href: resolved, text });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
url,
|
|
77
|
+
sourceDomain,
|
|
78
|
+
counts: {
|
|
79
|
+
internal: internal.length,
|
|
80
|
+
external: external.length,
|
|
81
|
+
social: social.length,
|
|
82
|
+
email: email.length,
|
|
83
|
+
phone: phone.length,
|
|
84
|
+
},
|
|
85
|
+
internal,
|
|
86
|
+
external,
|
|
87
|
+
social,
|
|
88
|
+
email,
|
|
89
|
+
phone,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function safeDomain(u) {
|
|
93
|
+
try {
|
|
94
|
+
return new URL(u).hostname.replace(/^www\./, '').toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function resolve(href, base) {
|
|
101
|
+
try {
|
|
102
|
+
return new URL(href, base).toString();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Complete URL metadata: Open Graph, Twitter Card, JSON-LD, favicons.
|
|
3
|
+
*/
|
|
4
|
+
import type { CheerioAPI } from 'cheerio';
|
|
5
|
+
export declare function extractMetadata({ url, $, headers, }: {
|
|
6
|
+
url: string;
|
|
7
|
+
$: CheerioAPI;
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
}): {
|
|
10
|
+
url: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string | null;
|
|
13
|
+
image: string | null;
|
|
14
|
+
siteName: string | null;
|
|
15
|
+
type: string | null;
|
|
16
|
+
author: string | null;
|
|
17
|
+
language: string | null;
|
|
18
|
+
canonicalUrl: string;
|
|
19
|
+
keywords: string[];
|
|
20
|
+
themeColor: string | null;
|
|
21
|
+
openGraph: Record<string, string>;
|
|
22
|
+
twitterCard: Record<string, string>;
|
|
23
|
+
favicons: {
|
|
24
|
+
rel: string;
|
|
25
|
+
href: string;
|
|
26
|
+
sizes: string | null;
|
|
27
|
+
type: string | null;
|
|
28
|
+
}[];
|
|
29
|
+
jsonLd: unknown[];
|
|
30
|
+
structuredDataTypes: string[];
|
|
31
|
+
contentType: string | null;
|
|
32
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Complete URL metadata: Open Graph, Twitter Card, JSON-LD, favicons.
|
|
3
|
+
*/
|
|
4
|
+
export function extractMetadata({ url, $, headers, }) {
|
|
5
|
+
const pageTitle = ($('title').first().text() || '').trim();
|
|
6
|
+
const lang = ($('html').attr('lang') || '').trim() || null;
|
|
7
|
+
const canonical = $('link[rel="canonical"]').attr('href') || url;
|
|
8
|
+
const metaName = {};
|
|
9
|
+
$('meta[name]').each((_, el) => {
|
|
10
|
+
const name = ($(el).attr('name') || '').toLowerCase();
|
|
11
|
+
const content = $(el).attr('content');
|
|
12
|
+
if (name && content)
|
|
13
|
+
metaName[name] = content;
|
|
14
|
+
});
|
|
15
|
+
const og = {};
|
|
16
|
+
const twitter = {};
|
|
17
|
+
$('meta[property], meta[name^="twitter:"]').each((_, el) => {
|
|
18
|
+
const key = ($(el).attr('property') || $(el).attr('name') || '').toLowerCase();
|
|
19
|
+
const content = $(el).attr('content');
|
|
20
|
+
if (!key || !content)
|
|
21
|
+
return;
|
|
22
|
+
if (key.startsWith('og:'))
|
|
23
|
+
og[key.slice(3)] = content;
|
|
24
|
+
else if (key.startsWith('twitter:'))
|
|
25
|
+
twitter[key.slice(8)] = content;
|
|
26
|
+
});
|
|
27
|
+
const favicons = [];
|
|
28
|
+
$('link').each((_, el) => {
|
|
29
|
+
const rel = ($(el).attr('rel') || '').toLowerCase();
|
|
30
|
+
const href = $(el).attr('href');
|
|
31
|
+
if (!href)
|
|
32
|
+
return;
|
|
33
|
+
if (rel.includes('icon') || rel === 'apple-touch-icon' || rel === 'mask-icon') {
|
|
34
|
+
favicons.push({
|
|
35
|
+
rel,
|
|
36
|
+
href: resolveUrl(href, url),
|
|
37
|
+
sizes: $(el).attr('sizes') || null,
|
|
38
|
+
type: $(el).attr('type') || null,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const jsonLd = [];
|
|
43
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
44
|
+
const raw = $(el).contents().text();
|
|
45
|
+
if (!raw)
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
if (Array.isArray(parsed))
|
|
50
|
+
jsonLd.push(...parsed);
|
|
51
|
+
else
|
|
52
|
+
jsonLd.push(parsed);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// ignore malformed JSON-LD
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const structuredTypes = new Set();
|
|
59
|
+
for (const item of jsonLd)
|
|
60
|
+
collectTypes(item, structuredTypes);
|
|
61
|
+
return {
|
|
62
|
+
url,
|
|
63
|
+
title: og.title || twitter.title || pageTitle,
|
|
64
|
+
description: og.description || twitter.description || metaName.description || null,
|
|
65
|
+
image: og.image ? resolveUrl(og.image, url) : twitter.image ? resolveUrl(twitter.image, url) : null,
|
|
66
|
+
siteName: og.site_name || null,
|
|
67
|
+
type: og.type || null,
|
|
68
|
+
author: metaName.author || null,
|
|
69
|
+
language: lang,
|
|
70
|
+
canonicalUrl: resolveUrl(canonical, url),
|
|
71
|
+
keywords: metaName.keywords
|
|
72
|
+
? metaName.keywords.split(',').map((k) => k.trim()).filter(Boolean)
|
|
73
|
+
: [],
|
|
74
|
+
themeColor: metaName['theme-color'] || null,
|
|
75
|
+
openGraph: og,
|
|
76
|
+
twitterCard: twitter,
|
|
77
|
+
favicons,
|
|
78
|
+
jsonLd,
|
|
79
|
+
structuredDataTypes: Array.from(structuredTypes),
|
|
80
|
+
contentType: headers['content-type'] || null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function collectTypes(node, set) {
|
|
84
|
+
if (!node || typeof node !== 'object')
|
|
85
|
+
return;
|
|
86
|
+
const obj = node;
|
|
87
|
+
const t = obj['@type'];
|
|
88
|
+
if (typeof t === 'string')
|
|
89
|
+
set.add(t);
|
|
90
|
+
else if (Array.isArray(t))
|
|
91
|
+
for (const v of t)
|
|
92
|
+
if (typeof v === 'string')
|
|
93
|
+
set.add(v);
|
|
94
|
+
for (const key of Object.keys(obj)) {
|
|
95
|
+
if (key === '@type')
|
|
96
|
+
continue;
|
|
97
|
+
const v = obj[key];
|
|
98
|
+
if (Array.isArray(v))
|
|
99
|
+
for (const item of v)
|
|
100
|
+
collectTypes(item, set);
|
|
101
|
+
else if (v && typeof v === 'object')
|
|
102
|
+
collectTypes(v, set);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function resolveUrl(href, base) {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(href, base).toString();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return href;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract all HTML tables as structured rows with headers.
|
|
3
|
+
*/
|
|
4
|
+
import type { CheerioAPI } from 'cheerio';
|
|
5
|
+
export declare function extractTables({ url, $ }: {
|
|
6
|
+
url: string;
|
|
7
|
+
$: CheerioAPI;
|
|
8
|
+
}): {
|
|
9
|
+
url: string;
|
|
10
|
+
tableCount: number;
|
|
11
|
+
tables: {
|
|
12
|
+
index: number;
|
|
13
|
+
caption: string | null;
|
|
14
|
+
headers: string[];
|
|
15
|
+
rows: Record<string, string>[];
|
|
16
|
+
rowCount: number;
|
|
17
|
+
}[];
|
|
18
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract all HTML tables as structured rows with headers.
|
|
3
|
+
*/
|
|
4
|
+
export function extractTables({ url, $ }) {
|
|
5
|
+
const tables = [];
|
|
6
|
+
$('table').each((index, tableEl) => {
|
|
7
|
+
const $table = $(tableEl);
|
|
8
|
+
const caption = ($table.find('caption').first().text() || '').trim() || null;
|
|
9
|
+
const headers = extractHeaders($, $table);
|
|
10
|
+
const rows = extractRows($, $table, headers);
|
|
11
|
+
if (rows.length > 0) {
|
|
12
|
+
tables.push({
|
|
13
|
+
index,
|
|
14
|
+
caption,
|
|
15
|
+
headers,
|
|
16
|
+
rows,
|
|
17
|
+
rowCount: rows.length,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
url,
|
|
23
|
+
tableCount: tables.length,
|
|
24
|
+
tables,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function extractHeaders($, $table) {
|
|
28
|
+
// Try explicit thead first
|
|
29
|
+
const $thead = $table.find('thead').first();
|
|
30
|
+
if ($thead.length) {
|
|
31
|
+
const headers = $thead
|
|
32
|
+
.find('th, td')
|
|
33
|
+
.map((_, el) => $(el).text().trim())
|
|
34
|
+
.get();
|
|
35
|
+
if (headers.length)
|
|
36
|
+
return headers;
|
|
37
|
+
}
|
|
38
|
+
// Fall back to first row with <th>
|
|
39
|
+
const $firstThRow = $table.find('tr').filter((_, tr) => $(tr).find('th').length > 0).first();
|
|
40
|
+
if ($firstThRow.length) {
|
|
41
|
+
return $firstThRow
|
|
42
|
+
.find('th, td')
|
|
43
|
+
.map((_, el) => $(el).text().trim())
|
|
44
|
+
.get();
|
|
45
|
+
}
|
|
46
|
+
// Fall back to first row
|
|
47
|
+
const $firstRow = $table.find('tr').first();
|
|
48
|
+
if ($firstRow.length) {
|
|
49
|
+
return $firstRow
|
|
50
|
+
.find('td, th')
|
|
51
|
+
.map((_, el) => $(el).text().trim())
|
|
52
|
+
.get();
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
function extractRows($, $table, headers) {
|
|
57
|
+
const rows = [];
|
|
58
|
+
const $bodyRows = $table.find('tbody tr');
|
|
59
|
+
const $rows = $bodyRows.length ? $bodyRows : $table.find('tr').slice(headers.length ? 1 : 0);
|
|
60
|
+
$rows.each((_, tr) => {
|
|
61
|
+
const cells = $(tr)
|
|
62
|
+
.find('td, th')
|
|
63
|
+
.map((_, el) => $(el).text().trim())
|
|
64
|
+
.get();
|
|
65
|
+
if (cells.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const row = {};
|
|
68
|
+
for (let i = 0; i < cells.length; i++) {
|
|
69
|
+
const key = headers[i] || `column_${i + 1}`;
|
|
70
|
+
row[key] = cells[i];
|
|
71
|
+
}
|
|
72
|
+
rows.push(row);
|
|
73
|
+
});
|
|
74
|
+
return rows;
|
|
75
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tech stack detection (reuses the rule engine from the Apify actor).
|
|
3
|
+
*/
|
|
4
|
+
import type { CheerioAPI } from 'cheerio';
|
|
5
|
+
export declare function detectTechStack({ url, $, html, headers, }: {
|
|
6
|
+
url: string;
|
|
7
|
+
$: CheerioAPI;
|
|
8
|
+
html: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
}): {
|
|
11
|
+
url: string;
|
|
12
|
+
technologies: {
|
|
13
|
+
name: string;
|
|
14
|
+
category: string;
|
|
15
|
+
confidence: number;
|
|
16
|
+
}[];
|
|
17
|
+
categorized: Record<string, string[]>;
|
|
18
|
+
technologyCount: number;
|
|
19
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tech stack detection (reuses the rule engine from the Apify actor).
|
|
3
|
+
*/
|
|
4
|
+
const SIGNATURES = [
|
|
5
|
+
{ name: 'WordPress', category: 'cms', rules: ['html=/wp-content\\//', 'html=/wp-includes\\//', 'meta:generator=/WordPress/i'] },
|
|
6
|
+
{ name: 'Drupal', category: 'cms', rules: ['header:x-generator=/Drupal/i', 'meta:generator=/Drupal/i'] },
|
|
7
|
+
{ name: 'Ghost', category: 'cms', rules: ['meta:generator=/Ghost/i', 'html=/ghost-sdk/'] },
|
|
8
|
+
{ name: 'Wix', category: 'cms', rules: ['header:x-wix-request-id=/.+/', 'html=/static\\.wixstatic\\.com/'] },
|
|
9
|
+
{ name: 'Squarespace', category: 'cms', rules: ['html=/static\\.squarespace\\.com/', 'header:x-contextid=/.+/'] },
|
|
10
|
+
{ name: 'Webflow', category: 'cms', rules: ['html=/webflow\\.com/', 'meta:generator=/Webflow/i'] },
|
|
11
|
+
{ name: 'Contentful', category: 'cms', rules: ['html=/images\\.ctfassets\\.net/'] },
|
|
12
|
+
{ name: 'Sanity', category: 'cms', rules: ['html=/cdn\\.sanity\\.io/'] },
|
|
13
|
+
{ name: 'Shopify', category: 'ecommerce', rules: ['header:x-shopify-stage=/.+/', 'html=/cdn\\.shopify\\.com/', 'html=/Shopify\\.theme/'] },
|
|
14
|
+
{ name: 'WooCommerce', category: 'ecommerce', rules: ['html=/wp-content\\/plugins\\/woocommerce/', 'html=/woocommerce-/'] },
|
|
15
|
+
{ name: 'Magento', category: 'ecommerce', rules: ['cookie=/Mage-/', 'html=/Magento_Ui/'] },
|
|
16
|
+
{ name: 'BigCommerce', category: 'ecommerce', rules: ['html=/cdn\\d+\\.bigcommerce\\.com/'] },
|
|
17
|
+
{ name: 'PrestaShop', category: 'ecommerce', rules: ['meta:generator=/PrestaShop/i'] },
|
|
18
|
+
{ name: 'Next.js', category: 'frameworks', rules: ['header:x-powered-by=/Next\\.js/i', 'html=/_next\\/static/', 'html=/__NEXT_DATA__/'] },
|
|
19
|
+
{ name: 'Nuxt.js', category: 'frameworks', rules: ['html=/_nuxt\\//', 'html=/__NUXT__/'] },
|
|
20
|
+
{ name: 'React', category: 'frameworks', rules: ['html=/react(?:-dom)?[\\.@][\\d\\.]+/', 'script=/react(?:-dom)?\\..*\\.js/'] },
|
|
21
|
+
{ name: 'Vue.js', category: 'frameworks', rules: ['html=/vue(?:-router)?[\\.@][\\d\\.]+/', 'script=/vue[\\.@][\\d\\.]+(?:\\.min)?\\.js/'] },
|
|
22
|
+
{ name: 'Angular', category: 'frameworks', rules: ['html=/ng-version=/', 'html=/angular\\.io/'] },
|
|
23
|
+
{ name: 'Svelte', category: 'frameworks', rules: ['html=/svelte-[a-z0-9]+/'] },
|
|
24
|
+
{ name: 'Gatsby', category: 'frameworks', rules: ['meta:generator=/Gatsby/i', 'html=/gatsby-/'] },
|
|
25
|
+
{ name: 'Astro', category: 'frameworks', rules: ['meta:generator=/Astro/i', 'html=/astro-island/'] },
|
|
26
|
+
{ name: 'Remix', category: 'frameworks', rules: ['html=/\\/build\\/_shared\\//'] },
|
|
27
|
+
{ name: 'nginx', category: 'hosting', rules: ['header:server=/nginx/i'] },
|
|
28
|
+
{ name: 'Apache', category: 'hosting', rules: ['header:server=/Apache/i'] },
|
|
29
|
+
{ name: 'Microsoft IIS', category: 'hosting', rules: ['header:server=/IIS|Microsoft-IIS/i'] },
|
|
30
|
+
{ name: 'LiteSpeed', category: 'hosting', rules: ['header:server=/LiteSpeed/i'] },
|
|
31
|
+
{ name: 'Caddy', category: 'hosting', rules: ['header:server=/Caddy/i'] },
|
|
32
|
+
{ name: 'Express', category: 'frameworks', rules: ['header:x-powered-by=/Express/i'] },
|
|
33
|
+
{ name: 'PHP', category: 'frameworks', rules: ['header:x-powered-by=/PHP/i'] },
|
|
34
|
+
{ name: 'ASP.NET', category: 'frameworks', rules: ['header:x-powered-by=/ASP\\.NET/i', 'header:x-aspnet-version=/.+/'] },
|
|
35
|
+
{ name: 'Ruby on Rails', category: 'frameworks', rules: ['header:x-powered-by=/Rails/i'] },
|
|
36
|
+
{ name: 'Django', category: 'frameworks', rules: ['cookie=/django/i', 'cookie=/csrftoken/i'] },
|
|
37
|
+
{ name: 'Laravel', category: 'frameworks', rules: ['cookie=/laravel_session/i', 'cookie=/XSRF-TOKEN/i'] },
|
|
38
|
+
{ name: 'Cloudflare', category: 'cdn', rules: ['header:server=/cloudflare/i', 'header:cf-ray=/.+/'] },
|
|
39
|
+
{ name: 'Amazon CloudFront', category: 'cdn', rules: ['header:via=/CloudFront/i', 'header:x-amz-cf-id=/.+/'] },
|
|
40
|
+
{ name: 'Fastly', category: 'cdn', rules: ['header:x-served-by=/cache-.*/', 'header:x-fastly-request-id=/.+/'] },
|
|
41
|
+
{ name: 'Akamai', category: 'cdn', rules: ['header:server=/AkamaiGHost/i', 'header:x-akamai-transformed=/.+/'] },
|
|
42
|
+
{ name: 'Vercel', category: 'hosting', rules: ['header:x-vercel-id=/.+/', 'header:server=/Vercel/i'] },
|
|
43
|
+
{ name: 'Netlify', category: 'hosting', rules: ['header:x-nf-request-id=/.+/', 'header:server=/Netlify/i'] },
|
|
44
|
+
{ name: 'GitHub Pages', category: 'hosting', rules: ['header:server=/GitHub\\.com/i'] },
|
|
45
|
+
{ name: 'Google Analytics', category: 'analytics', rules: ['script=/google-analytics\\.com\\/(ga|analytics)\\.js/', 'html=/www\\.google-analytics\\.com/', 'html=/gtag\\(/'] },
|
|
46
|
+
{ name: 'Google Tag Manager', category: 'analytics', rules: ['script=/googletagmanager\\.com\\/gtm\\.js/', 'html=/GTM-[A-Z0-9]+/'] },
|
|
47
|
+
{ name: 'Plausible', category: 'analytics', rules: ['script=/plausible\\.io\\/js/'] },
|
|
48
|
+
{ name: 'Fathom', category: 'analytics', rules: ['script=/cdn\\.usefathom\\.com/'] },
|
|
49
|
+
{ name: 'Mixpanel', category: 'analytics', rules: ['html=/mixpanel/i', 'script=/cdn\\.mxpnl\\.com/'] },
|
|
50
|
+
{ name: 'Segment', category: 'analytics', rules: ['script=/cdn\\.segment\\.com\\/analytics\\.js/'] },
|
|
51
|
+
{ name: 'Hotjar', category: 'analytics', rules: ['script=/static\\.hotjar\\.com/'] },
|
|
52
|
+
{ name: 'Amplitude', category: 'analytics', rules: ['script=/cdn\\.amplitude\\.com/'] },
|
|
53
|
+
{ name: 'PostHog', category: 'analytics', rules: ['script=/posthog\\.com/', 'html=/posthog\\.init/'] },
|
|
54
|
+
{ name: 'HubSpot', category: 'marketing', rules: ['script=/js\\.hs-scripts\\.com/', 'script=/js\\.hubspot\\.com/'] },
|
|
55
|
+
{ name: 'Intercom', category: 'marketing', rules: ['script=/widget\\.intercom\\.io/', 'html=/intercomSettings/'] },
|
|
56
|
+
{ name: 'Drift', category: 'marketing', rules: ['script=/js\\.driftt\\.com/'] },
|
|
57
|
+
{ name: 'Tailwind CSS', category: 'ui', rules: ['html=/tailwind/i', 'script=/cdn\\.tailwindcss\\.com/'] },
|
|
58
|
+
{ name: 'Bootstrap', category: 'ui', rules: ['script=/bootstrap(?:\\.min)?\\.(?:js|css)/'] },
|
|
59
|
+
{ name: 'jQuery', category: 'frameworks', rules: ['script=/jquery(?:-[\\d\\.]+)?(?:\\.min)?\\.js/'] },
|
|
60
|
+
];
|
|
61
|
+
function compileRule(rule) {
|
|
62
|
+
const eqIdx = rule.indexOf('=');
|
|
63
|
+
const kind = rule.slice(0, eqIdx);
|
|
64
|
+
const body = rule.slice(eqIdx + 1);
|
|
65
|
+
const match = body.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
66
|
+
const pattern = match ? new RegExp(match[1], match[2]) : new RegExp(body);
|
|
67
|
+
return { kind, pattern };
|
|
68
|
+
}
|
|
69
|
+
const COMPILED = SIGNATURES.map((sig) => ({
|
|
70
|
+
...sig,
|
|
71
|
+
compiled: sig.rules.map(compileRule),
|
|
72
|
+
}));
|
|
73
|
+
export function detectTechStack({ url, $, html, headers, }) {
|
|
74
|
+
const normHeaders = {};
|
|
75
|
+
for (const [k, v] of Object.entries(headers || {})) {
|
|
76
|
+
normHeaders[k.toLowerCase()] = String(v ?? '');
|
|
77
|
+
}
|
|
78
|
+
const cookieBlob = normHeaders['set-cookie'] || '';
|
|
79
|
+
const scriptUrls = [];
|
|
80
|
+
$('script[src]').each((_, el) => {
|
|
81
|
+
scriptUrls.push($(el).attr('src') || '');
|
|
82
|
+
});
|
|
83
|
+
$('link[href]').each((_, el) => {
|
|
84
|
+
scriptUrls.push($(el).attr('href') || '');
|
|
85
|
+
});
|
|
86
|
+
const scriptBlob = scriptUrls.join('\n');
|
|
87
|
+
const metaByName = {};
|
|
88
|
+
$('meta[name]').each((_, el) => {
|
|
89
|
+
const name = ($(el).attr('name') || '').toLowerCase();
|
|
90
|
+
metaByName[name] = $(el).attr('content') || '';
|
|
91
|
+
});
|
|
92
|
+
const detected = [];
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const sig of COMPILED) {
|
|
95
|
+
let matched = 0;
|
|
96
|
+
const total = sig.compiled.length;
|
|
97
|
+
for (const rule of sig.compiled) {
|
|
98
|
+
if (rule.kind === 'html' && rule.pattern.test(html))
|
|
99
|
+
matched++;
|
|
100
|
+
else if (rule.kind === 'script' && rule.pattern.test(scriptBlob))
|
|
101
|
+
matched++;
|
|
102
|
+
else if (rule.kind === 'cookie' && rule.pattern.test(cookieBlob))
|
|
103
|
+
matched++;
|
|
104
|
+
else if (rule.kind.startsWith('header:')) {
|
|
105
|
+
const headerName = rule.kind.slice('header:'.length);
|
|
106
|
+
const value = normHeaders[headerName];
|
|
107
|
+
if (value && rule.pattern.test(value))
|
|
108
|
+
matched++;
|
|
109
|
+
}
|
|
110
|
+
else if (rule.kind.startsWith('meta:')) {
|
|
111
|
+
const metaName = rule.kind.slice('meta:'.length);
|
|
112
|
+
const value = metaByName[metaName];
|
|
113
|
+
if (value && rule.pattern.test(value))
|
|
114
|
+
matched++;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (matched > 0 && !seen.has(sig.name)) {
|
|
118
|
+
seen.add(sig.name);
|
|
119
|
+
detected.push({
|
|
120
|
+
name: sig.name,
|
|
121
|
+
category: sig.category,
|
|
122
|
+
confidence: Math.min(1, matched / total),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const categorized = {};
|
|
127
|
+
for (const tech of detected) {
|
|
128
|
+
if (!categorized[tech.category])
|
|
129
|
+
categorized[tech.category] = [];
|
|
130
|
+
categorized[tech.category].push(tech.name);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
url,
|
|
134
|
+
technologies: detected,
|
|
135
|
+
categorized,
|
|
136
|
+
technologyCount: detected.length,
|
|
137
|
+
};
|
|
138
|
+
}
|