causly-server 0.0.0-stage → 1.5.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/.env.example +120 -0
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +205 -3
- package/bin/causly.js +192 -0
- package/index.js +229 -0
- package/package.json +59 -6
- package/packs/calendar.js +141 -0
- package/packs/command.js +22 -0
- package/packs/database.js +95 -0
- package/packs/directory.js +63 -0
- package/packs/discord.js +63 -0
- package/packs/docker.js +202 -0
- package/packs/files.js +106 -0
- package/packs/git.js +211 -0
- package/packs/github.js +321 -0
- package/packs/gmail.js +98 -0
- package/packs/leads.js +46 -0
- package/packs/linear.js +104 -0
- package/packs/loader.js +247 -0
- package/packs/manifest.json +1152 -0
- package/packs/meetings.js +141 -0
- package/packs/notion.js +185 -0
- package/packs/pipeline.js +151 -0
- package/packs/project.js +70 -0
- package/packs/registry.js +149 -0
- package/packs/reminders.js +57 -0
- package/packs/research.js +34 -0
- package/packs/secrets.js +77 -0
- package/packs/sentry.js +97 -0
- package/packs/seo.js +70 -0
- package/packs/slack.js +95 -0
- package/packs/stripe.js +127 -0
- package/packs/supabase.js +73 -0
- package/packs/telegram.js +70 -0
- package/packs/terraform.js +247 -0
- package/packs/vercel.js +114 -0
- package/packs/workflow.js +84 -0
- package/prompts/cofounder.js +66 -0
- package/prompts/playbooks.js +75 -0
- package/tools/backupOps.js +87 -0
- package/tools/boundaryAttest.js +184 -0
- package/tools/calendarOps.js +563 -0
- package/tools/cliOps.js +233 -0
- package/tools/commandOps.js +198 -0
- package/tools/dbOps.js +117 -0
- package/tools/directoryOps.js +96 -0
- package/tools/discordOps.js +133 -0
- package/tools/dockerOps.js +259 -0
- package/tools/envFile.js +89 -0
- package/tools/envLoader.js +85 -0
- package/tools/fileOps.js +125 -0
- package/tools/gitOps.js +170 -0
- package/tools/githubOps.js +357 -0
- package/tools/gmailOps.js +221 -0
- package/tools/googleAuth.js +51 -0
- package/tools/googleLogin.js +230 -0
- package/tools/leadOps.js +447 -0
- package/tools/linearOps.js +304 -0
- package/tools/logger.js +47 -0
- package/tools/meetingOps.js +697 -0
- package/tools/notionOps.js +215 -0
- package/tools/paths.js +36 -0
- package/tools/pipelineOps.js +511 -0
- package/tools/projectOps.js +192 -0
- package/tools/reminderAlerts.js +48 -0
- package/tools/reminderOps.js +129 -0
- package/tools/scheduler.js +46 -0
- package/tools/secretsOps.js +133 -0
- package/tools/security.js +488 -0
- package/tools/sentryOps.js +130 -0
- package/tools/seoOps.js +561 -0
- package/tools/services.js +295 -0
- package/tools/setupOps.js +245 -0
- package/tools/slackOps.js +151 -0
- package/tools/statusOps.js +104 -0
- package/tools/store.js +111 -0
- package/tools/stripeOps.js +299 -0
- package/tools/supabaseOps.js +94 -0
- package/tools/telegramOps.js +153 -0
- package/tools/terminal.js +124 -0
- package/tools/terraformOps.js +171 -0
- package/tools/toolWrapper.js +60 -0
- package/tools/toolsetOps.js +123 -0
- package/tools/transcribeOps.js +221 -0
- package/tools/updateCheck.js +58 -0
- package/tools/vercelOps.js +198 -0
- package/tools/verifyBoundaryAttest.js +25 -0
- package/tools/webOps.js +474 -0
- package/tools/workflowOps.js +371 -0
- package/tools/workspace.js +85 -0
package/tools/leadOps.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Lead discovery from free, keyless public sources. No new dependencies.
|
|
2
|
+
//
|
|
3
|
+
// lead_hn_hiring companies hiring in Hacker News "Who is hiring?" threads
|
|
4
|
+
// lead_remote_jobs remote jobs from RemoteOK and We Work Remotely
|
|
5
|
+
// lead_company_contacts public contact details from a company's own website
|
|
6
|
+
//
|
|
7
|
+
// All requests go through webOps.fetchRaw, so they share its SSRF guard, size
|
|
8
|
+
// limits and timeouts. Everything returned is untrusted third-party content.
|
|
9
|
+
import { fetchRaw, htmlToText, decodeEntities, parseHttpUrl, metaContent, cleanInline, UNTRUSTED_NOTICE } from "./webOps.js";
|
|
10
|
+
|
|
11
|
+
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
|
|
12
|
+
const GUIDANCE =
|
|
13
|
+
"Public business contact details only. Check local rules (for example GDPR, CAN-SPAM) before cold outreach.";
|
|
14
|
+
|
|
15
|
+
async function getJson(get, url) {
|
|
16
|
+
const res = await get(url);
|
|
17
|
+
if (res.status !== 200 || res.kind === "binary") throw new Error(`${new URL(url).hostname} answered HTTP ${res.status}.`);
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(res.raw);
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error(`${new URL(url).hostname} did not return valid JSON.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------- extraction helpers
|
|
26
|
+
|
|
27
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g;
|
|
28
|
+
const NOT_EMAIL_TLDS = new Set(["png", "jpg", "jpeg", "gif", "svg", "webp", "css", "js", "ico", "woff", "woff2"]);
|
|
29
|
+
const PLACEHOLDER_DOMAINS = new Set(["example.com", "example.org", "domain.com", "email.com", "yourdomain.com", "yoursite.com", "sentry.io"]);
|
|
30
|
+
const ROLE_NAMES = new Set([
|
|
31
|
+
"info", "hello", "hi", "contact", "sales", "support", "team", "office", "admin", "careers", "jobs", "hr",
|
|
32
|
+
"press", "media", "help", "enquiries", "inquiries", "mail", "marketing", "billing", "accounts", "partnerships", "business",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** Email addresses found in text or HTML: [{ email, role }]. role = shared mailbox such as info@. */
|
|
36
|
+
export function extractEmails(text) {
|
|
37
|
+
const found = new Map();
|
|
38
|
+
for (const m of String(text).matchAll(EMAIL_RE)) {
|
|
39
|
+
const email = m[0].toLowerCase();
|
|
40
|
+
const [local, domain] = email.split("@");
|
|
41
|
+
const blockedDomain = [...PLACEHOLDER_DOMAINS].some((d) => domain === d || domain.endsWith(`.${d}`));
|
|
42
|
+
// Long hex local parts are tracking keys (for example Sentry DSNs), not mailboxes.
|
|
43
|
+
if (NOT_EMAIL_TLDS.has(domain.split(".").pop()) || blockedDomain || /^[a-f0-9]{24,}$/.test(local)) continue;
|
|
44
|
+
if (!found.has(email)) found.set(email, { email, role: ROLE_NAMES.has(local) });
|
|
45
|
+
}
|
|
46
|
+
return [...found.values()];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Keeps one entry per number: "+1 415 555 0100" and "(415) 555-0100" are the same phone,
|
|
50
|
+
// so a number that is the tail of another is a duplicate (the fuller form wins).
|
|
51
|
+
function addPhone(out, raw) {
|
|
52
|
+
const digits = raw.replace(/\D/g, "");
|
|
53
|
+
if (digits.length < 8 || digits.length > 15) return;
|
|
54
|
+
for (const key of out.keys()) {
|
|
55
|
+
if (key.endsWith(digits)) return;
|
|
56
|
+
if (digits.endsWith(key)) {
|
|
57
|
+
out.delete(key);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
out.set(digits, raw.replace(/\s+/g, " ").trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Phone numbers from tel: links and international (+...) or US-style numbers in text. */
|
|
65
|
+
export function extractPhones(html, text) {
|
|
66
|
+
const out = new Map();
|
|
67
|
+
for (const m of String(html).matchAll(/href\s*=\s*["']tel:([^"']+)["']/gi)) {
|
|
68
|
+
try {
|
|
69
|
+
addPhone(out, decodeURIComponent(m[1]));
|
|
70
|
+
} catch {
|
|
71
|
+
addPhone(out, m[1]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const m of String(text).matchAll(/(?<![\w.])\+\d{1,3}[\s().-]*\d[\d\s().-]{6,16}\d/g)) addPhone(out, m[0]);
|
|
75
|
+
for (const m of String(text).matchAll(/(?<![\w.])\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?!\d)/g)) addPhone(out, m[0]);
|
|
76
|
+
return [...out.values()].slice(0, 5);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SOCIAL_RULES = [
|
|
80
|
+
["linkedin", (u) => /(^|\.)linkedin\.com$/.test(u.hostname) && /^\/(company|in|school)\/[^/]+/.test(u.pathname)],
|
|
81
|
+
[
|
|
82
|
+
"twitter",
|
|
83
|
+
(u) =>
|
|
84
|
+
/^(www\.)?(twitter|x)\.com$/.test(u.hostname) &&
|
|
85
|
+
/^\/[A-Za-z0-9_]{1,15}\/?$/.test(u.pathname) &&
|
|
86
|
+
!/^\/(share|intent|home|search)\/?$/i.test(u.pathname),
|
|
87
|
+
],
|
|
88
|
+
["github", (u) => /^(www\.)?github\.com$/.test(u.hostname) && /^\/[A-Za-z0-9-]+\/?$/.test(u.pathname)],
|
|
89
|
+
["facebook", (u) => /(^|\.)facebook\.com$/.test(u.hostname) && /^\/[^/]+/.test(u.pathname) && !/^\/(sharer|share|dialog|tr)\b/i.test(u.pathname)],
|
|
90
|
+
["instagram", (u) => /(^|\.)instagram\.com$/.test(u.hostname) && /^\/[A-Za-z0-9._]+\/?$/.test(u.pathname)],
|
|
91
|
+
["youtube", (u) => /(^|\.)youtube\.com$/.test(u.hostname) && /^\/(@|channel\/|c\/|user\/)/.test(u.pathname)],
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function classifySocial(url) {
|
|
95
|
+
for (const [name, test] of SOCIAL_RULES) {
|
|
96
|
+
if (test(url)) return [name, url.origin + url.pathname.replace(/\/$/, "")];
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** First profile link per platform found in the page's hrefs. */
|
|
102
|
+
export function extractSocial(html, baseUrl) {
|
|
103
|
+
const social = {};
|
|
104
|
+
for (const m of String(html).matchAll(/href\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(decodeEntities(m[1] ?? m[2] ?? ""), baseUrl);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const hit = classifySocial(url);
|
|
112
|
+
if (hit && !social[hit[0]]) social[hit[0]] = hit[1];
|
|
113
|
+
}
|
|
114
|
+
return social;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
118
|
+
|
|
119
|
+
/** Organization details from schema.org JSON-LD blocks. */
|
|
120
|
+
export function parseJsonLd(html) {
|
|
121
|
+
const org = {};
|
|
122
|
+
for (const m of String(html).matchAll(/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
123
|
+
let data;
|
|
124
|
+
try {
|
|
125
|
+
data = JSON.parse(m[1]);
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const nodes = [];
|
|
130
|
+
const walk = (n) => {
|
|
131
|
+
if (Array.isArray(n)) n.forEach(walk);
|
|
132
|
+
else if (n && typeof n === "object") {
|
|
133
|
+
nodes.push(n);
|
|
134
|
+
if (n["@graph"]) walk(n["@graph"]);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
walk(data);
|
|
138
|
+
for (const n of nodes) {
|
|
139
|
+
const types = [].concat(n["@type"] ?? []).map(String);
|
|
140
|
+
if (!types.some((t) => /Organization|LocalBusiness|Corporation|ProfessionalService|Company/i.test(t))) continue;
|
|
141
|
+
org.name ??= str(n.name);
|
|
142
|
+
org.email ??= str(n.email);
|
|
143
|
+
org.telephone ??= str(n.telephone);
|
|
144
|
+
org.description ??= str(n.description);
|
|
145
|
+
if (!org.address && n.address) {
|
|
146
|
+
const a = n.address;
|
|
147
|
+
org.address =
|
|
148
|
+
typeof a === "string"
|
|
149
|
+
? a
|
|
150
|
+
: [a.streetAddress, a.addressLocality, a.addressRegion, a.postalCode, a.addressCountry].filter((x) => typeof x === "string").join(", ") ||
|
|
151
|
+
undefined;
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(n.sameAs)) org.sameAs = [...(org.sameAs ?? []), ...n.sameAs.filter((s) => typeof s === "string")];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return org;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- lead_hn_hiring
|
|
160
|
+
|
|
161
|
+
const hnLink = (id) => `https://news.ycombinator.com/item?id=${id}`;
|
|
162
|
+
|
|
163
|
+
/** Companies hiring in Hacker News "Who is hiring?" posts, searched by keyword. */
|
|
164
|
+
export async function hnHiring({ query = "", limit = 10, thread_id } = {}, deps = {}) {
|
|
165
|
+
const get = deps.fetchRaw ?? fetchRaw;
|
|
166
|
+
const n = clamp(Math.trunc(limit) || 10, 1, 30);
|
|
167
|
+
|
|
168
|
+
let thread;
|
|
169
|
+
if (thread_id !== undefined) {
|
|
170
|
+
if (!/^\d+$/.test(String(thread_id))) throw new Error("thread_id must be a number.");
|
|
171
|
+
thread = { id: String(thread_id) };
|
|
172
|
+
} else {
|
|
173
|
+
const list = await getJson(
|
|
174
|
+
get,
|
|
175
|
+
`https://hn.algolia.com/api/v1/search_by_date?query=${encodeURIComponent('"Ask HN: Who is hiring?"')}&tags=story,author_whoishiring&hitsPerPage=5`
|
|
176
|
+
);
|
|
177
|
+
const hit = (list.hits || []).find((h) => /who is hiring\?/i.test(h.title || ""));
|
|
178
|
+
if (!hit) throw new Error('Could not find the latest "Who is hiring?" thread.');
|
|
179
|
+
thread = { id: String(hit.objectID), title: hit.title, posted_at: hit.created_at };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Replies to job posts match too, so ask for extra hits and keep only top-level posts.
|
|
183
|
+
const data = await getJson(
|
|
184
|
+
get,
|
|
185
|
+
`https://hn.algolia.com/api/v1/search?tags=comment,story_${thread.id}&query=${encodeURIComponent(String(query).trim())}&hitsPerPage=${Math.min(n * 4, 100)}`
|
|
186
|
+
);
|
|
187
|
+
const posts = [];
|
|
188
|
+
for (const hit of data.hits || []) {
|
|
189
|
+
if (String(hit.parent_id) !== thread.id) continue;
|
|
190
|
+
const html = String(hit.comment_text || "").replace(/<p>/gi, "<br><br>");
|
|
191
|
+
const page = htmlToText(html, "https://news.ycombinator.com/", { includeLinks: true });
|
|
192
|
+
const firstLine = page.text.split("\n").find((l) => l.trim()) || "";
|
|
193
|
+
posts.push({
|
|
194
|
+
id: hit.objectID,
|
|
195
|
+
url: hnLink(hit.objectID),
|
|
196
|
+
author: hit.author,
|
|
197
|
+
posted_at: hit.created_at,
|
|
198
|
+
company: firstLine.split("|")[0].trim().slice(0, 80),
|
|
199
|
+
headline: firstLine.slice(0, 200),
|
|
200
|
+
text: page.text.slice(0, 700),
|
|
201
|
+
emails: extractEmails(`${page.text} ${page.links.map((l) => l.url).join(" ")}`).map((e) => e.email),
|
|
202
|
+
links: page.links.map((l) => l.url).slice(0, 5),
|
|
203
|
+
});
|
|
204
|
+
if (posts.length >= n) break;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
thread: { ...thread, url: hnLink(thread.id) },
|
|
208
|
+
query: String(query).trim(),
|
|
209
|
+
count: posts.length,
|
|
210
|
+
posts,
|
|
211
|
+
notice: UNTRUSTED_NOTICE,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------- lead_remote_jobs
|
|
216
|
+
|
|
217
|
+
const tokens = (query) => String(query ?? "").toLowerCase().split(/[^\p{L}\p{N}+#.]+/u).filter(Boolean);
|
|
218
|
+
const matchesAll = (words, haystack) => words.every((w) => haystack.includes(w));
|
|
219
|
+
|
|
220
|
+
function toIso(value) {
|
|
221
|
+
const ms = Date.parse(value);
|
|
222
|
+
return Number.isNaN(ms) ? undefined : new Date(ms).toISOString();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const unCdata = (s) => s.replace(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/, "$1");
|
|
226
|
+
|
|
227
|
+
/** Parse an RSS feed into [{ title, link, published, region, category, description }]. */
|
|
228
|
+
export function parseRss(xml) {
|
|
229
|
+
const items = [];
|
|
230
|
+
for (const m of String(xml).matchAll(/<item>([\s\S]*?)<\/item>/g)) {
|
|
231
|
+
const block = m[1];
|
|
232
|
+
const field = (name) => {
|
|
233
|
+
const t = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i").exec(block);
|
|
234
|
+
return t ? t[1] : "";
|
|
235
|
+
};
|
|
236
|
+
const rawDescription = field("description");
|
|
237
|
+
const descriptionHtml = /<!\[CDATA\[/.test(rawDescription) ? unCdata(rawDescription) : decodeEntities(rawDescription);
|
|
238
|
+
const link = (unCdata(field("link")) || unCdata(field("guid"))).trim();
|
|
239
|
+
items.push({
|
|
240
|
+
title: decodeEntities(unCdata(field("title"))).trim(),
|
|
241
|
+
link,
|
|
242
|
+
published: toIso(field("pubDate")),
|
|
243
|
+
region: decodeEntities(unCdata(field("region"))).trim(),
|
|
244
|
+
category: decodeEntities(unCdata(field("category"))).trim(),
|
|
245
|
+
description: htmlToText(descriptionHtml, link || "https://weworkremotely.com/").text,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return items;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// RemoteOK's API double-encodes non-ASCII text (UTF-8 read as Latin-1, then encoded again),
|
|
252
|
+
// which turns "México" into "México". Undo that when the text looks affected.
|
|
253
|
+
export function fixMojibake(value) {
|
|
254
|
+
const s = typeof value === "string" ? value : "";
|
|
255
|
+
if (!/[\u00c2-\u00f4][\u0080-\u00bf]/.test(s) || [...s].some((c) => c.codePointAt(0) > 0xff)) return s;
|
|
256
|
+
const repaired = Buffer.from(s, "latin1").toString("utf8");
|
|
257
|
+
return repaired.includes("\ufffd") ? s : repaired;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function remoteOk(get, words) {
|
|
261
|
+
const data = await getJson(get, "https://remoteok.com/api");
|
|
262
|
+
const jobs = (Array.isArray(data) ? data : []).filter((j) => j && j.position);
|
|
263
|
+
const terms = Array.isArray(data) && data[0]?.legal ? String(data[0].legal) : undefined;
|
|
264
|
+
const items = [];
|
|
265
|
+
for (const raw of jobs) {
|
|
266
|
+
const j = {
|
|
267
|
+
...raw,
|
|
268
|
+
company: fixMojibake(raw.company),
|
|
269
|
+
position: fixMojibake(raw.position),
|
|
270
|
+
location: fixMojibake(raw.location),
|
|
271
|
+
description: fixMojibake(raw.description),
|
|
272
|
+
tags: Array.isArray(raw.tags) ? raw.tags.map(fixMojibake) : [],
|
|
273
|
+
};
|
|
274
|
+
const description = htmlToText(String(j.description || ""), "https://remoteok.com/").text;
|
|
275
|
+
const tags = Array.isArray(j.tags) ? j.tags : [];
|
|
276
|
+
const hay = `${j.company} ${j.position} ${tags.join(" ")} ${j.location || ""} ${description}`.toLowerCase();
|
|
277
|
+
if (!matchesAll(words, hay)) continue;
|
|
278
|
+
items.push({
|
|
279
|
+
source: "RemoteOK",
|
|
280
|
+
company: j.company || "",
|
|
281
|
+
position: j.position,
|
|
282
|
+
tags: tags.slice(0, 8),
|
|
283
|
+
location: j.location || "",
|
|
284
|
+
salary: j.salary_min || j.salary_max ? { min: j.salary_min || undefined, max: j.salary_max || undefined } : undefined,
|
|
285
|
+
published: toIso(j.date),
|
|
286
|
+
url: j.url,
|
|
287
|
+
apply_url: j.apply_url || undefined,
|
|
288
|
+
summary: description.slice(0, 400),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return { items, terms };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function weWorkRemotely(get, words) {
|
|
295
|
+
const res = await get("https://weworkremotely.com/categories/remote-programming-jobs.rss");
|
|
296
|
+
if (res.status !== 200 || res.kind === "binary") throw new Error(`weworkremotely.com answered HTTP ${res.status}.`);
|
|
297
|
+
const items = [];
|
|
298
|
+
for (const item of parseRss(res.raw)) {
|
|
299
|
+
const hay = `${item.title} ${item.region} ${item.category} ${item.description}`.toLowerCase();
|
|
300
|
+
if (!matchesAll(words, hay)) continue;
|
|
301
|
+
const sep = item.title.indexOf(": ");
|
|
302
|
+
items.push({
|
|
303
|
+
source: "We Work Remotely",
|
|
304
|
+
company: sep > 0 ? item.title.slice(0, sep) : "",
|
|
305
|
+
position: sep > 0 ? item.title.slice(sep + 2) : item.title,
|
|
306
|
+
tags: item.category ? [item.category] : [],
|
|
307
|
+
location: item.region,
|
|
308
|
+
published: item.published,
|
|
309
|
+
url: item.link,
|
|
310
|
+
summary: item.description.slice(0, 400),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return { items };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Remote jobs from RemoteOK and We Work Remotely (programming), filtered by keywords. */
|
|
317
|
+
export async function remoteJobs({ query = "", limit = 10 } = {}, deps = {}) {
|
|
318
|
+
const get = deps.fetchRaw ?? fetchRaw;
|
|
319
|
+
const n = clamp(Math.trunc(limit) || 10, 1, 30);
|
|
320
|
+
const words = tokens(query);
|
|
321
|
+
|
|
322
|
+
const [ok, wwr] = await Promise.allSettled([remoteOk(get, words), weWorkRemotely(get, words)]);
|
|
323
|
+
const errors = [];
|
|
324
|
+
const items = [];
|
|
325
|
+
let terms;
|
|
326
|
+
if (ok.status === "fulfilled") {
|
|
327
|
+
items.push(...ok.value.items);
|
|
328
|
+
terms = ok.value.terms;
|
|
329
|
+
} else errors.push({ source: "RemoteOK", error: ok.reason.message });
|
|
330
|
+
if (wwr.status === "fulfilled") items.push(...wwr.value.items);
|
|
331
|
+
else errors.push({ source: "We Work Remotely", error: wwr.reason.message });
|
|
332
|
+
if (errors.length === 2) throw new Error(`Both job sources failed: ${errors.map((e) => `${e.source}: ${e.error}`).join("; ")}`);
|
|
333
|
+
|
|
334
|
+
items.sort((a, b) => (b.published || "").localeCompare(a.published || ""));
|
|
335
|
+
return {
|
|
336
|
+
query: String(query).trim(),
|
|
337
|
+
count: Math.min(items.length, n),
|
|
338
|
+
jobs: items.slice(0, n),
|
|
339
|
+
...(errors.length ? { errors } : {}),
|
|
340
|
+
attribution: "Jobs come from RemoteOK and We Work Remotely. Link back to the job URL when you share them.",
|
|
341
|
+
...(terms ? { remoteok_terms: terms } : {}),
|
|
342
|
+
notice: UNTRUSTED_NOTICE,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------- lead_company_contacts
|
|
347
|
+
|
|
348
|
+
const CONTACT_HINT = /contact|get[-_ ]in[-_ ]touch|reach[-_ ]us|about|team|company|impressum|imprint|support/i;
|
|
349
|
+
const STRONG_HINT = /contact|get[-_ ]in[-_ ]touch|reach[-_ ]us/i;
|
|
350
|
+
const SKIP_EXT = /\.(pdf|jpe?g|png|gif|svg|webp|zip|mp4|css|js)$/i;
|
|
351
|
+
const stripWww = (host) => host.replace(/^www\./, "");
|
|
352
|
+
|
|
353
|
+
function pickContactPages(html, baseUrl) {
|
|
354
|
+
const baseHost = stripWww(baseUrl.hostname);
|
|
355
|
+
const found = new Map();
|
|
356
|
+
for (const m of html.matchAll(/<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi)) {
|
|
357
|
+
const href = decodeEntities(m[1] ?? m[2] ?? "").trim();
|
|
358
|
+
if (!href || href.startsWith("#") || /^(mailto|tel|javascript):/i.test(href)) continue;
|
|
359
|
+
let url;
|
|
360
|
+
try {
|
|
361
|
+
url = new URL(href, baseUrl);
|
|
362
|
+
} catch {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (!/^https?:$/.test(url.protocol) || stripWww(url.hostname) !== baseHost || SKIP_EXT.test(url.pathname)) continue;
|
|
366
|
+
if (url.pathname === baseUrl.pathname) continue;
|
|
367
|
+
url.hash = "";
|
|
368
|
+
const hay = `${url.pathname} ${cleanInline(m[3])}`;
|
|
369
|
+
if (!CONTACT_HINT.test(hay)) continue;
|
|
370
|
+
const score = STRONG_HINT.test(hay) ? 3 : /about|team|company/i.test(hay) ? 2 : 1;
|
|
371
|
+
const key = url.origin + url.pathname;
|
|
372
|
+
if (!found.has(key) || found.get(key).score < score) found.set(key, { url: url.toString(), score });
|
|
373
|
+
}
|
|
374
|
+
return [...found.values()].sort((a, b) => b.score - a.score).map((x) => x.url);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Public contact details for a company, read from its homepage and contact/about pages. */
|
|
378
|
+
export async function companyContacts({ url: input, max_pages = 3 } = {}, deps = {}) {
|
|
379
|
+
const get = deps.fetchRaw ?? fetchRaw;
|
|
380
|
+
const raw = String(input ?? "").trim();
|
|
381
|
+
if (!raw) throw new Error("url is required.");
|
|
382
|
+
const start = parseHttpUrl(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
383
|
+
const extraPages = Number.isFinite(max_pages) ? clamp(Math.trunc(max_pages), 0, 5) : 3;
|
|
384
|
+
|
|
385
|
+
const home = await get(start.toString());
|
|
386
|
+
if (home.kind !== "html") throw new Error(`${start.hostname} did not return a web page (HTTP ${home.status}).`);
|
|
387
|
+
const homeUrl = new URL(home.final_url);
|
|
388
|
+
const pages = [{ url: home.final_url, html: home.raw }];
|
|
389
|
+
const fetched = [{ url: home.final_url, status: home.status }];
|
|
390
|
+
|
|
391
|
+
const found = pickContactPages(home.raw, homeUrl);
|
|
392
|
+
const queue = found.length ? found : ["/contact", "/about"].map((p) => new URL(p, homeUrl).toString());
|
|
393
|
+
for (const pageUrl of queue.slice(0, extraPages)) {
|
|
394
|
+
try {
|
|
395
|
+
const page = await get(pageUrl);
|
|
396
|
+
fetched.push({ url: page.final_url, status: page.status });
|
|
397
|
+
const sameSite = stripWww(new URL(page.final_url).hostname) === stripWww(homeUrl.hostname);
|
|
398
|
+
if (page.kind === "html" && page.status < 400 && sameSite) pages.push({ url: page.final_url, html: page.raw });
|
|
399
|
+
} catch (err) {
|
|
400
|
+
fetched.push({ url: pageUrl, error: err.message });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const emails = new Map();
|
|
405
|
+
const phones = new Map();
|
|
406
|
+
const social = {};
|
|
407
|
+
let org = {};
|
|
408
|
+
let siteName = "";
|
|
409
|
+
let metaDescription = "";
|
|
410
|
+
let title = "";
|
|
411
|
+
for (const page of pages) {
|
|
412
|
+
const body = decodeEntities(page.html.replace(/<(script|style|noscript|svg|template)\b[\s\S]*?<\/\1>/gi, " "));
|
|
413
|
+
for (const e of extractEmails(body)) if (!emails.has(e.email)) emails.set(e.email, { ...e, source_page: page.url });
|
|
414
|
+
const parsed = htmlToText(page.html, page.url);
|
|
415
|
+
for (const phone of extractPhones(page.html, parsed.text)) addPhone(phones, phone);
|
|
416
|
+
for (const [name, link] of Object.entries(extractSocial(page.html, page.url))) social[name] ??= link;
|
|
417
|
+
org = { ...parseJsonLd(page.html), ...org };
|
|
418
|
+
siteName ||= metaContent(page.html, "og:site_name");
|
|
419
|
+
metaDescription ||= parsed.description;
|
|
420
|
+
title ||= parsed.title;
|
|
421
|
+
}
|
|
422
|
+
if (org.email) for (const e of extractEmails(org.email)) if (!emails.has(e.email)) emails.set(e.email, { ...e, source_page: "JSON-LD" });
|
|
423
|
+
if (org.telephone) addPhone(phones, org.telephone);
|
|
424
|
+
for (const same of org.sameAs ?? []) {
|
|
425
|
+
try {
|
|
426
|
+
const hit = classifySocial(new URL(same));
|
|
427
|
+
if (hit) social[hit[0]] ??= hit[1];
|
|
428
|
+
} catch {
|
|
429
|
+
// ignore malformed sameAs entries
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
website: homeUrl.origin,
|
|
435
|
+
company: {
|
|
436
|
+
name: org.name || siteName || title,
|
|
437
|
+
description: org.description || metaDescription || undefined,
|
|
438
|
+
address: org.address,
|
|
439
|
+
},
|
|
440
|
+
emails: [...emails.values()],
|
|
441
|
+
phones: [...phones.values()].slice(0, 5),
|
|
442
|
+
social,
|
|
443
|
+
pages_fetched: fetched,
|
|
444
|
+
guidance: GUIDANCE,
|
|
445
|
+
notice: UNTRUSTED_NOTICE,
|
|
446
|
+
};
|
|
447
|
+
}
|