feat: add JS Remotely (javascript.jobs) extractor #7
@@ -0,0 +1,31 @@
|
|||||||
|
# javascriptjobs-extractor
|
||||||
|
|
||||||
|
Pulls remote listings from [javascript.jobs](https://javascript.jobs) (the
|
||||||
|
board formerly promoted under the `jsremotely.com` alias — that domain now
|
||||||
|
301s straight here). JS/TS-focused: React, Vue, Node, Angular, TypeScript.
|
||||||
|
|
||||||
|
- No authentication, no public API/RSS — this is a server-rendered HTML
|
||||||
|
scrape of `/remote`, which supports a `keyword` query param for server-side
|
||||||
|
term filtering (`?keyword=react&page=2`), so pipeline search terms are
|
||||||
|
iterated as separate paginated fetches rather than filtered client-side.
|
||||||
|
- Listing cards give title/employer/job-type badge/salary text/logo. Detail
|
||||||
|
pages (`/job/<slug>`) additionally embed a `JobPosting` (schema.org)
|
||||||
|
`<script type="application/ld+json">` block with description, exact
|
||||||
|
`datePosted`, `employmentType`, and structured `baseSalary`.
|
||||||
|
- That embedded block is **not valid JSON** (unescaped newlines inside
|
||||||
|
`description`, and at least one observed missing comma between sibling
|
||||||
|
keys), so we extract fields with targeted regexes instead of
|
||||||
|
`JSON.parse`. If a detail fetch fails or a field is missing, we fall back
|
||||||
|
to the listing-card values.
|
||||||
|
- `baseSalary` in that block is unreliable on the source side: postings
|
||||||
|
with no salary entered still emit `minValue`/`maxValue: 0` (we treat 0 as
|
||||||
|
"not specified"), and at least one observed posting had `minValue` equal
|
||||||
|
to `maxValue` (both set to the range's upper bound) despite the
|
||||||
|
human-readable listing-card text showing a real range. We keep the raw
|
||||||
|
listing-card salary string in `salary` as a trustworthy fallback alongside
|
||||||
|
the (possibly wrong) structured `salaryMinAmount`/`salaryMaxAmount`.
|
||||||
|
- Caps via `javascriptjobsMaxJobsPerTerm` (default 50) and
|
||||||
|
`javascriptjobsMaxPages` (default 3, pages per term).
|
||||||
|
- Every matched job triggers one extra HTTP GET (detail page) for
|
||||||
|
enrichment — keep the caps conservative given this is a low-volume niche
|
||||||
|
board.
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* JS Remotely / javascript.jobs — JS/TS-focused remote board (server-rendered
|
||||||
|
* HTML, no public API or RSS).
|
||||||
|
*
|
||||||
|
* https://javascript.jobs/remote?keyword=<term>&page=<n>
|
||||||
|
*
|
||||||
|
* `jsremotely.com` now 301s straight to `javascript.jobs`, so we hit the
|
||||||
|
* canonical domain directly. The `/remote` listing supports a `keyword`
|
||||||
|
* query param that filters server-side, so we iterate pipeline search terms
|
||||||
|
* as separate paginated fetches rather than filtering client-side.
|
||||||
|
*
|
||||||
|
* Listing cards give us title/employer/badges/salary text; detail pages
|
||||||
|
* additionally embed a `JobPosting` (schema.org) block for description,
|
||||||
|
* exact date posted, employment type, and structured salary. That block is
|
||||||
|
* emitted with unescaped newlines and occasional missing commas, so it is
|
||||||
|
* not valid JSON — we pull fields out with targeted regexes instead of
|
||||||
|
* `JSON.parse`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExtractorManifest,
|
||||||
|
ExtractorRunResult,
|
||||||
|
} from "@shared/types/extractors";
|
||||||
|
import type { CreateJobInput } from "@shared/types/jobs";
|
||||||
|
|
||||||
|
const ORIGIN = "https://javascript.jobs";
|
||||||
|
const USER_AGENT = "JobOps/1.0 (+https://github.com/) job-search pipeline";
|
||||||
|
/** One job card's markup is well under this; keeps field regexes from
|
||||||
|
* bleeding into the next card (or page footer for the last card). */
|
||||||
|
const CARD_WINDOW = 2500;
|
||||||
|
|
||||||
|
interface ListingJob {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
employer: string;
|
||||||
|
jobType?: string;
|
||||||
|
isRemoteBadge: boolean;
|
||||||
|
salaryText?: string;
|
||||||
|
companyLogo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseWhitespace(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListingJobs(html: string): ListingJob[] {
|
||||||
|
const chunks = html.split('<a href="https://javascript.jobs/job/').slice(1);
|
||||||
|
const jobs: ListingJob[] = [];
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const slug = chunk.split('"')[0]?.trim();
|
||||||
|
if (!slug) continue;
|
||||||
|
const card = chunk.slice(0, CARD_WINDOW);
|
||||||
|
|
||||||
|
const titleMatch = card.match(
|
||||||
|
/tw-text-lg tw-font-medium">\s*([^<]+?)\s*<\/div>/,
|
||||||
|
);
|
||||||
|
const employerMatch = card.match(/tw-card-title">\s*([^<]+?)\s*<\/span>/);
|
||||||
|
if (!titleMatch || !employerMatch) continue;
|
||||||
|
|
||||||
|
const badges = [
|
||||||
|
...card.matchAll(/tw-rounded-\[3px\]">\s*([^<]+?)\s*<\/span>/g),
|
||||||
|
].map((m) => collapseWhitespace(m[1]));
|
||||||
|
const isRemoteBadge = badges.some((b) => /^remote$/i.test(b));
|
||||||
|
const jobType = badges.find((b) => !/^remote$/i.test(b));
|
||||||
|
|
||||||
|
const salaryMatch = card.match(/Salary:\s*([\s\S]*?)<\/span>/);
|
||||||
|
const logoMatch = card.match(/<img[^>]*src="([^"]+)"/);
|
||||||
|
|
||||||
|
jobs.push({
|
||||||
|
slug,
|
||||||
|
title: decodeHtmlEntities(titleMatch[1].trim()),
|
||||||
|
employer: decodeHtmlEntities(employerMatch[1].trim()),
|
||||||
|
jobType,
|
||||||
|
isRemoteBadge,
|
||||||
|
salaryText: salaryMatch
|
||||||
|
? collapseWhitespace(decodeHtmlEntities(salaryMatch[1]))
|
||||||
|
: undefined,
|
||||||
|
companyLogo: logoMatch?.[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailFields {
|
||||||
|
description?: string;
|
||||||
|
datePosted?: string;
|
||||||
|
employmentType?: string;
|
||||||
|
organizationName?: string;
|
||||||
|
organizationLogo?: string;
|
||||||
|
salaryCurrency?: string;
|
||||||
|
salaryMin?: number;
|
||||||
|
salaryMax?: number;
|
||||||
|
salaryUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort string field extraction over the (invalid-JSON) JobPosting
|
||||||
|
* block: stops at the next quoted key or a closing brace rather than
|
||||||
|
* assuming a specific neighboring key or consistent `key: value` spacing. */
|
||||||
|
function extractStringField(raw: string, key: string): string | undefined {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`"${key}"\\s*:\\s*"([\\s\\S]*?)"\\s*(?=,\\s*\\n?\\s*"[A-Za-z]+"\\s*:|\\n?\\s*})`,
|
||||||
|
);
|
||||||
|
const match = raw.match(pattern);
|
||||||
|
const value = match?.[1]?.trim();
|
||||||
|
return value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** javascript.jobs emits `minValue`/`maxValue: 0` placeholders even when no
|
||||||
|
* salary was entered, so treat 0 the same as "not specified". */
|
||||||
|
function extractNumberField(raw: string, key: string): number | undefined {
|
||||||
|
const match = raw.match(new RegExp(`"${key}"\\s*:\\s*([0-9.]+)`));
|
||||||
|
const value = match ? Number.parseFloat(match[1]) : undefined;
|
||||||
|
return value != null && Number.isFinite(value) && value > 0
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractObjectBlock(raw: string, key: string): string | undefined {
|
||||||
|
const match = raw.match(new RegExp(`"${key}"\\s*:\\s*{([\\s\\S]*?)}`));
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJobPosting(html: string): DetailFields | null {
|
||||||
|
const match = html.match(
|
||||||
|
/<script type="application\/ld\+json">([\s\S]*?)<\/script>/,
|
||||||
|
);
|
||||||
|
const raw = match?.[1];
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const orgBlock = extractObjectBlock(raw, "hiringOrganization");
|
||||||
|
// baseSalary nests a `value` object inside it; matching keys over the
|
||||||
|
// whole ld+json block is simpler than balancing the extra brace pair and
|
||||||
|
// these key names don't collide with anything else in this schema.
|
||||||
|
const salaryUnit = extractStringField(raw, "unitText");
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: extractStringField(raw, "description"),
|
||||||
|
datePosted: extractStringField(raw, "datePosted"),
|
||||||
|
employmentType: extractStringField(raw, "employmentType"),
|
||||||
|
organizationName: orgBlock
|
||||||
|
? extractStringField(orgBlock, "name")
|
||||||
|
: undefined,
|
||||||
|
organizationLogo: orgBlock
|
||||||
|
? extractStringField(orgBlock, "logo")
|
||||||
|
: undefined,
|
||||||
|
salaryCurrency: extractStringField(raw, "currency"),
|
||||||
|
salaryMin: extractNumberField(raw, "minValue"),
|
||||||
|
salaryMax: extractNumberField(raw, "maxValue"),
|
||||||
|
salaryUnit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJobType(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
return raw.replace(/_/g, " ").trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function salaryIntervalFromUnit(unit: string | undefined): string | undefined {
|
||||||
|
if (!unit) return undefined;
|
||||||
|
const lower = unit.toLowerCase();
|
||||||
|
if (lower.startsWith("year")) return "yearly";
|
||||||
|
if (lower.startsWith("month")) return "monthly";
|
||||||
|
if (lower.startsWith("week")) return "weekly";
|
||||||
|
if (lower.startsWith("day")) return "daily";
|
||||||
|
if (lower.startsWith("hour")) return "hourly";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: "text/html", "User-Agent": USER_AGENT },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`javascript.jobs request failed (${response.status}) for ${url}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listingUrl(term: string, page: number): string {
|
||||||
|
const url = new URL(`${ORIGIN}/remote`);
|
||||||
|
if (term) url.searchParams.set("keyword", term);
|
||||||
|
if (page > 1) url.searchParams.set("page", String(page));
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCappedInt(
|
||||||
|
raw: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
||||||
|
if (!Number.isFinite(parsed)) return fallback;
|
||||||
|
return Math.min(Math.max(parsed, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const manifest: ExtractorManifest = {
|
||||||
|
id: "javascriptjobs",
|
||||||
|
displayName: "JS Remotely (javascript.jobs)",
|
||||||
|
providesSources: ["javascriptjobs"],
|
||||||
|
async run(context): Promise<ExtractorRunResult> {
|
||||||
|
if (context.shouldCancel?.()) return { success: true, jobs: [] };
|
||||||
|
|
||||||
|
const cap = readCappedInt(
|
||||||
|
context.settings.javascriptjobsMaxJobsPerTerm,
|
||||||
|
50,
|
||||||
|
1,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
const maxPages = readCappedInt(
|
||||||
|
context.settings.javascriptjobsMaxPages,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
const terms = context.searchTerms.length > 0 ? context.searchTerms : [""];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: CreateJobInput[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let t = 0; t < terms.length; t += 1) {
|
||||||
|
if (context.shouldCancel?.()) break;
|
||||||
|
const term = terms[t].trim();
|
||||||
|
let collectedForTerm = 0;
|
||||||
|
|
||||||
|
for (let page = 1; page <= maxPages; page += 1) {
|
||||||
|
if (context.shouldCancel?.()) break;
|
||||||
|
if (collectedForTerm >= cap) break;
|
||||||
|
|
||||||
|
const url = listingUrl(term, page);
|
||||||
|
context.onProgress?.({
|
||||||
|
phase: "list",
|
||||||
|
termsProcessed: t,
|
||||||
|
termsTotal: terms.length,
|
||||||
|
currentUrl: url,
|
||||||
|
detail: `JS Remotely: term ${t + 1}/${terms.length}, page ${page}/${maxPages}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await fetchText(url);
|
||||||
|
const listings = parseListingJobs(html);
|
||||||
|
if (listings.length === 0) break;
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
if (collectedForTerm >= cap) break;
|
||||||
|
if (seen.has(listing.slug)) continue;
|
||||||
|
|
||||||
|
const jobUrl = `${ORIGIN}/job/${listing.slug}`;
|
||||||
|
let jobDescription: string | undefined;
|
||||||
|
let datePosted: string | undefined;
|
||||||
|
let jobType = listing.jobType;
|
||||||
|
let employer = listing.employer;
|
||||||
|
let companyLogo = listing.companyLogo;
|
||||||
|
let salaryCurrency: string | undefined;
|
||||||
|
let salaryMinAmount: number | undefined;
|
||||||
|
let salaryMaxAmount: number | undefined;
|
||||||
|
let salaryInterval: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detailHtml = await fetchText(jobUrl);
|
||||||
|
const detail = parseJobPosting(detailHtml);
|
||||||
|
if (detail) {
|
||||||
|
jobDescription = detail.description;
|
||||||
|
datePosted = detail.datePosted;
|
||||||
|
jobType = normalizeJobType(detail.employmentType) ?? jobType;
|
||||||
|
employer = detail.organizationName ?? employer;
|
||||||
|
companyLogo = detail.organizationLogo ?? companyLogo;
|
||||||
|
if (detail.salaryMin != null || detail.salaryMax != null) {
|
||||||
|
salaryCurrency = detail.salaryCurrency;
|
||||||
|
salaryMinAmount = detail.salaryMin;
|
||||||
|
salaryMaxAmount = detail.salaryMax;
|
||||||
|
salaryInterval = salaryIntervalFromUnit(detail.salaryUnit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to listing-card fields when the detail fetch fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(listing.slug);
|
||||||
|
collectedForTerm += 1;
|
||||||
|
out.push({
|
||||||
|
source: "javascriptjobs",
|
||||||
|
sourceJobId: listing.slug,
|
||||||
|
title: listing.title,
|
||||||
|
employer,
|
||||||
|
jobUrl,
|
||||||
|
applicationLink: jobUrl,
|
||||||
|
location: "Remote",
|
||||||
|
isRemote: true,
|
||||||
|
jobType,
|
||||||
|
datePosted,
|
||||||
|
jobDescription,
|
||||||
|
companyLogo,
|
||||||
|
salary: listing.salaryText,
|
||||||
|
salaryCurrency,
|
||||||
|
salaryMinAmount,
|
||||||
|
salaryMaxAmount,
|
||||||
|
salaryInterval,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.onProgress?.({
|
||||||
|
phase: "list",
|
||||||
|
termsProcessed: t + 1,
|
||||||
|
termsTotal: terms.length,
|
||||||
|
jobPagesProcessed: out.length,
|
||||||
|
detail: `JS Remotely: completed term ${t + 1}/${terms.length} (${collectedForTerm} found)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, jobs: out };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return { success: false, jobs: out, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "javascriptjobs-extractor",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"description": "JS Remotely / javascript.jobs remote job-board extractor",
|
||||||
|
"main": "manifest.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"job-ops-shared": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"typescript": "~5.9.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"check:types": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": ["node"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["../../shared/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["./manifest.ts", "./src/**/*"]
|
||||||
|
}
|
||||||
@@ -193,6 +193,8 @@ export function buildMaxCoverageDiscoveryLimits(args: {
|
|||||||
himalayasMaxJobsPerTerm: boardCap,
|
himalayasMaxJobsPerTerm: boardCap,
|
||||||
weworkremotelyMaxJobsPerTerm: boardCap,
|
weworkremotelyMaxJobsPerTerm: boardCap,
|
||||||
workingnomadsMaxJobsPerTerm: boardCap,
|
workingnomadsMaxJobsPerTerm: boardCap,
|
||||||
|
javascriptjobsMaxJobsPerTerm: Math.min(500, boardCap),
|
||||||
|
javascriptjobsMaxPages: 10,
|
||||||
fourdayweekMaxJobsPerTerm: boardCap,
|
fourdayweekMaxJobsPerTerm: boardCap,
|
||||||
testdevjobsMaxJobsPerTerm: boardCap,
|
testdevjobsMaxJobsPerTerm: boardCap,
|
||||||
testdevjobsMaxPages: 10,
|
testdevjobsMaxPages: 10,
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export const DEMO_SOURCE_BASE_URLS: Record<JobSource, string> = {
|
|||||||
himalayas: "https://himalayas.app",
|
himalayas: "https://himalayas.app",
|
||||||
weworkremotely: "https://weworkremotely.com",
|
weworkremotely: "https://weworkremotely.com",
|
||||||
workingnomads: "https://www.workingnomads.com",
|
workingnomads: "https://www.workingnomads.com",
|
||||||
|
javascriptjobs: "https://javascript.jobs",
|
||||||
testdevjobs: "https://testdevjobs.com",
|
testdevjobs: "https://testdevjobs.com",
|
||||||
wellfound: "https://wellfound.com",
|
wellfound: "https://wellfound.com",
|
||||||
builtin: "https://builtin.com",
|
builtin: "https://builtin.com",
|
||||||
|
|||||||
Generated
+32
@@ -536,6 +536,34 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"extractors/javascriptjobs": {
|
||||||
|
"name": "javascriptjobs-extractor",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"job-ops-shared": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"typescript": "~5.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extractors/javascriptjobs/node_modules/@types/node": {
|
||||||
|
"version": "24.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extractors/javascriptjobs/node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"extractors/jobicy": {
|
"extractors/jobicy": {
|
||||||
"name": "jobicy-extractor",
|
"name": "jobicy-extractor",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -15979,6 +16007,10 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/javascriptjobs-extractor": {
|
||||||
|
"resolved": "extractors/javascriptjobs",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/jest-util": {
|
"node_modules/jest-util": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
|
||||||
|
|||||||
@@ -186,6 +186,14 @@ const ALL_TARGETS: Target[] = [
|
|||||||
icimsMaxPagesPerSearch: "2",
|
icimsMaxPagesPerSearch: "2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "javascriptjobs",
|
||||||
|
importPath: "../extractors/javascriptjobs/manifest",
|
||||||
|
settings: {
|
||||||
|
javascriptjobsMaxJobsPerTerm: "5",
|
||||||
|
javascriptjobsMaxPages: "1",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "jobicy",
|
id: "jobicy",
|
||||||
importPath: "../extractors/jobicy/manifest",
|
importPath: "../extractors/jobicy/manifest",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const EXTRACTOR_SOURCE_IDS = [
|
|||||||
"himalayas",
|
"himalayas",
|
||||||
"weworkremotely",
|
"weworkremotely",
|
||||||
"workingnomads",
|
"workingnomads",
|
||||||
|
"javascriptjobs",
|
||||||
"fourdayweek",
|
"fourdayweek",
|
||||||
"testdevjobs",
|
"testdevjobs",
|
||||||
"wellfound",
|
"wellfound",
|
||||||
@@ -194,6 +195,12 @@ export const EXTRACTOR_SOURCE_METADATA: Record<
|
|||||||
category: "pipeline",
|
category: "pipeline",
|
||||||
region: "remote",
|
region: "remote",
|
||||||
},
|
},
|
||||||
|
javascriptjobs: {
|
||||||
|
label: "JS Remotely (javascript.jobs)",
|
||||||
|
order: 192,
|
||||||
|
category: "pipeline",
|
||||||
|
region: "remote",
|
||||||
|
},
|
||||||
fourdayweek: {
|
fourdayweek: {
|
||||||
label: "4 Day Week",
|
label: "4 Day Week",
|
||||||
order: 195,
|
order: 195,
|
||||||
|
|||||||
@@ -445,6 +445,20 @@ export const settingsRegistry = {
|
|||||||
parse: parseIntOrNull,
|
parse: parseIntOrNull,
|
||||||
serialize: serializeNullableNumber,
|
serialize: serializeNullableNumber,
|
||||||
},
|
},
|
||||||
|
javascriptjobsMaxJobsPerTerm: {
|
||||||
|
kind: "typed" as const,
|
||||||
|
schema: z.number().int().min(1).max(500),
|
||||||
|
default: (): number => 50,
|
||||||
|
parse: parseIntOrNull,
|
||||||
|
serialize: serializeNullableNumber,
|
||||||
|
},
|
||||||
|
javascriptjobsMaxPages: {
|
||||||
|
kind: "typed" as const,
|
||||||
|
schema: z.number().int().min(1).max(20),
|
||||||
|
default: (): number => 3,
|
||||||
|
parse: parseIntOrNull,
|
||||||
|
serialize: serializeNullableNumber,
|
||||||
|
},
|
||||||
testdevjobsMaxJobsPerTerm: {
|
testdevjobsMaxJobsPerTerm: {
|
||||||
kind: "typed" as const,
|
kind: "typed" as const,
|
||||||
schema: z.number().int().min(1).max(1000),
|
schema: z.number().int().min(1).max(1000),
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ export const createAppSettings = (
|
|||||||
himalayasMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
himalayasMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||||
weworkremotelyMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
weworkremotelyMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||||
workingnomadsMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
workingnomadsMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||||
|
javascriptjobsMaxJobsPerTerm: { value: 50, default: 50, override: null },
|
||||||
|
javascriptjobsMaxPages: { value: 3, default: 3, override: null },
|
||||||
testdevjobsMaxJobsPerTerm: { value: 100, default: 100, override: null },
|
testdevjobsMaxJobsPerTerm: { value: 100, default: 100, override: null },
|
||||||
testdevjobsMaxPages: { value: 3, default: 3, override: null },
|
testdevjobsMaxPages: { value: 3, default: 3, override: null },
|
||||||
builtinMaxJobsPerTerm: { value: 100, default: 100, override: null },
|
builtinMaxJobsPerTerm: { value: 100, default: 100, override: null },
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ export interface AppSettings {
|
|||||||
himalayasMaxJobsPerTerm: Resolved<number>;
|
himalayasMaxJobsPerTerm: Resolved<number>;
|
||||||
weworkremotelyMaxJobsPerTerm: Resolved<number>;
|
weworkremotelyMaxJobsPerTerm: Resolved<number>;
|
||||||
workingnomadsMaxJobsPerTerm: Resolved<number>;
|
workingnomadsMaxJobsPerTerm: Resolved<number>;
|
||||||
|
javascriptjobsMaxJobsPerTerm: Resolved<number>;
|
||||||
|
javascriptjobsMaxPages: Resolved<number>;
|
||||||
testdevjobsMaxJobsPerTerm: Resolved<number>;
|
testdevjobsMaxJobsPerTerm: Resolved<number>;
|
||||||
testdevjobsMaxPages: Resolved<number>;
|
testdevjobsMaxPages: Resolved<number>;
|
||||||
builtinMaxJobsPerTerm: Resolved<number>;
|
builtinMaxJobsPerTerm: Resolved<number>;
|
||||||
|
|||||||
Reference in New Issue
Block a user