diff --git a/cli-manifest.json b/cli-manifest.json index fd1a13229..867456cde 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -32221,6 +32221,398 @@ "modulePath": "weixin/search.js", "sourceFile": "weixin/search.js" }, + { + "site": "wellfound", + "name": "apply", + "description": "Inspect or submit a Wellfound-native job application; external company applications are detected and not submitted by default", + "access": "write", + "domain": "wellfound.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "job-url", + "type": "string", + "required": true, + "positional": true, + "help": "Wellfound /jobs/ URL, ?job_listing_slug URL, or raw id-slug" + }, + { + "name": "message", + "type": "string", + "default": "", + "required": false, + "help": "Answer for \"What interests you about working for this company?\"" + }, + { + "name": "expected-title", + "type": "string", + "default": "", + "required": false, + "help": "Guard: refuse if the opened job title differs" + }, + { + "name": "expected-company", + "type": "string", + "default": "", + "required": false, + "help": "Guard: refuse if the opened company differs" + }, + { + "name": "allow-company-website", + "type": "boolean", + "default": false, + "required": false, + "help": "Return external apply URLs as allowed instead of blocked; does not submit external forms" + }, + { + "name": "execute", + "type": "boolean", + "default": false, + "required": false, + "help": "Actually click the Wellfound Apply button. Without it, this is a dry-run inspection." + } + ], + "columns": [ + "status", + "apply_mode", + "title", + "company", + "message", + "message_filled", + "message_length", + "external_apply_url", + "url", + "notes" + ], + "type": "js", + "modulePath": "wellfound/apply.js", + "sourceFile": "wellfound/apply.js", + "navigateBefore": false + }, + { + "site": "wellfound", + "name": "filters", + "description": "Read or update the visible Wellfound Browse filters; updates require --execute", + "access": "write", + "domain": "wellfound.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "default": "https://wellfound.com/jobs", + "required": false, + "help": "Wellfound jobs URL to open; defaults to Browse all jobs" + }, + { + "name": "preset", + "type": "string", + "required": false, + "help": "Optional filter preset, currently: ai-fullstack-remote" + }, + { + "name": "salary-min", + "type": "string", + "default": "", + "required": false, + "help": "Minimum salary filter value when supported by the UI" + }, + { + "name": "salary-max", + "type": "string", + "default": "", + "required": false, + "help": "Maximum salary filter value when supported by the UI" + }, + { + "name": "currency", + "type": "string", + "default": "", + "required": false, + "help": "Salary currency text, e.g. INR or USD" + }, + { + "name": "equity-min", + "type": "string", + "default": "", + "required": false, + "help": "Minimum equity value when supported by the UI" + }, + { + "name": "equity-max", + "type": "string", + "default": "", + "required": false, + "help": "Maximum equity value when supported by the UI" + }, + { + "name": "skills", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated skills to select; autocomplete selections may require manual UI support" + }, + { + "name": "markets", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated markets to select; autocomplete selections may require manual UI support" + }, + { + "name": "job-types", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated job types, e.g. \"Full Time,Contract\"" + }, + { + "name": "include-keywords", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated included keywords" + }, + { + "name": "exclude-keywords", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated excluded keywords" + }, + { + "name": "company-sizes", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated company size labels" + }, + { + "name": "stages", + "type": "string", + "default": "", + "required": false, + "help": "Comma-separated investment stage labels" + }, + { + "name": "mostly-remote", + "type": "boolean", + "required": false, + "help": "Only show companies that are mostly or fully remote" + }, + { + "name": "responsive", + "type": "boolean", + "required": false, + "help": "Only show highly responsive companies" + }, + { + "name": "visa", + "type": "boolean", + "required": false, + "help": "Only show companies that can sponsor a visa" + }, + { + "name": "hide-company-apply", + "type": "boolean", + "required": false, + "help": "Document desired setting for hiding company-website applications; current UI exposes this outside the modal" + }, + { + "name": "execute", + "type": "boolean", + "default": false, + "required": false, + "help": "Actually update filters. Without it, this reads current filters and previews requested changes." + } + ], + "columns": [ + "status", + "results", + "role", + "remote", + "region", + "salary", + "currency", + "equity", + "skills", + "markets", + "job_types", + "experience", + "included_keywords", + "excluded_keywords", + "company_size", + "investment_stage", + "remote_culture", + "responsiveness", + "visa_sponsorship", + "hide_company_apply", + "url", + "notes" + ], + "type": "js", + "modulePath": "wellfound/filters.js", + "sourceFile": "wellfound/filters.js", + "navigateBefore": false + }, + { + "site": "wellfound", + "name": "job-detail", + "description": "Read one Wellfound job detail dialog with description, skills, remote policy, and company metadata", + "access": "read", + "domain": "wellfound.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "job-url", + "type": "string", + "required": true, + "positional": true, + "help": "Wellfound /jobs/ URL, ?job_listing_slug URL, or raw id-slug" + } + ], + "columns": [ + "title", + "company", + "location", + "compensation", + "job_type", + "experience", + "posted", + "recruiter_active", + "remote_policy", + "company_location", + "visa_sponsorship", + "preferred_timezones", + "collaboration_hours", + "relocation", + "company_status", + "skills", + "company_size", + "company_industries", + "description", + "url", + "company_url" + ], + "type": "js", + "modulePath": "wellfound/job-detail.js", + "sourceFile": "wellfound/job-detail.js", + "navigateBefore": false + }, + { + "site": "wellfound", + "name": "jobs", + "description": "Read visible Wellfound Browse jobs from the current saved/filtered search", + "access": "read", + "domain": "wellfound.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "default": "https://wellfound.com/jobs", + "required": false, + "help": "Wellfound jobs URL to open; defaults to Browse all jobs" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of visible job cards to return (1-50)" + } + ], + "columns": [ + "rank", + "score", + "title", + "company", + "location", + "compensation", + "job_type", + "posted", + "recruiter_active", + "apply_on_wellfound", + "company_status", + "company_summary", + "company_size", + "company_tags", + "url", + "company_url" + ], + "type": "js", + "modulePath": "wellfound/jobs.js", + "sourceFile": "wellfound/jobs.js", + "navigateBefore": false + }, + { + "site": "wellfound", + "name": "top-picks", + "aliases": [ + "daily" + ], + "description": "Rank the current Wellfound filtered Browse results and return the best daily application targets", + "access": "read", + "domain": "wellfound.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "url", + "type": "string", + "default": "https://wellfound.com/jobs", + "required": false, + "help": "Wellfound jobs URL to open; defaults to Browse all jobs" + }, + { + "name": "limit", + "type": "int", + "default": 5, + "required": false, + "help": "Number of picks to return (1-20)" + }, + { + "name": "pool", + "type": "int", + "default": 30, + "required": false, + "help": "Visible result pool size to score before ranking (1-50)" + }, + { + "name": "verify-details", + "type": "boolean", + "default": false, + "required": false, + "help": "Open candidate detail pages before final ranking to merge company status and detail-only signals" + } + ], + "columns": [ + "rank", + "score", + "title", + "company", + "location", + "compensation", + "job_type", + "posted", + "recruiter_active", + "apply_on_wellfound", + "company_status", + "company_summary", + "company_size", + "company_tags", + "url", + "company_url" + ], + "type": "js", + "modulePath": "wellfound/top-picks.js", + "sourceFile": "wellfound/top-picks.js", + "navigateBefore": false + }, { "site": "weread", "name": "ai-outline", diff --git a/clis/wellfound/apply.js b/clis/wellfound/apply.js new file mode 100644 index 000000000..853e421e1 --- /dev/null +++ b/clis/wellfound/apply.js @@ -0,0 +1,81 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { + APPLY_COLUMNS, + assertAuthenticated, + assertExpectedApplyTarget, + buildApplyInspectionScript, + buildApplySubmitScript, + buildDetailUrl, + normalizeApplyMessage, + normalizeApplyState, + normalizeJobSlug, + parseBoolean, + unwrapEvaluateResult, +} from './utils.js'; + +cli({ + site: 'wellfound', + name: 'apply', + access: 'write', + description: 'Inspect or submit a Wellfound-native job application; external company applications are detected and not submitted by default', + domain: 'wellfound.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'job-url', type: 'string', required: true, positional: true, help: 'Wellfound /jobs/ URL, ?job_listing_slug URL, or raw id-slug' }, + { name: 'message', type: 'string', default: '', help: 'Answer for "What interests you about working for this company?"' }, + { name: 'expected-title', type: 'string', default: '', help: 'Guard: refuse if the opened job title differs' }, + { name: 'expected-company', type: 'string', default: '', help: 'Guard: refuse if the opened company differs' }, + { name: 'allow-company-website', type: 'boolean', default: false, help: 'Return external apply URLs as allowed instead of blocked; does not submit external forms' }, + { name: 'execute', type: 'boolean', default: false, help: 'Actually click the Wellfound Apply button. Without it, this is a dry-run inspection.' }, + ], + columns: APPLY_COLUMNS, + func: async (page, args) => { + if (!page) throw new CommandExecutionError('Browser session required for wellfound apply'); + const slug = normalizeJobSlug(args['job-url']); + const message = normalizeApplyMessage(args.message); + const execute = parseBoolean(args.execute, false); + const allowCompanyWebsite = parseBoolean(args['allow-company-website'], false); + + await page.goto(buildDetailUrl(slug)); + await page.wait(4); + await assertAuthenticated(page, 'wellfound apply'); + + const state = normalizeApplyState(unwrapEvaluateResult(await page.evaluate(buildApplyInspectionScript(message)))); + assertExpectedApplyTarget(state, args['expected-title'], args['expected-company']); + + if (state.apply_mode === 'company_website') { + return [{ + ...state, + status: allowCompanyWebsite ? 'external_allowed' : 'external_blocked', + notes: allowCompanyWebsite + ? 'External company application detected; OpenCLI did not submit the external form' + : 'External company application detected; pass --allow-company-website to treat the URL as an allowed handoff', + }]; + } + + if (state.apply_mode === 'already_applied') return [state]; + if (state.apply_mode !== 'wellfound') { + return [{ ...state, status: 'not_applicable', notes: 'No Wellfound-native apply form was detected' }]; + } + + if (!execute) { + return [{ ...state, status: 'dry-run', notes: 'Pass --execute to submit the Wellfound-native application' }]; + } + if (!message) { + throw new ArgumentError('wellfound apply requires --message when --execute is used'); + } + + const submit = unwrapEvaluateResult(await page.evaluate(buildApplySubmitScript(message))); + const after = normalizeApplyState(unwrapEvaluateResult(await page.evaluate(buildApplyInspectionScript(message)))); + return [{ + ...after, + status: submit?.status || after.status, + message_filled: submit?.message_filled || after.message_filled, + message_length: submit?.message_length === undefined ? after.message_length : String(submit.message_length), + notes: submit?.notes || after.notes, + }]; + }, +}); diff --git a/clis/wellfound/filters.js b/clis/wellfound/filters.js new file mode 100644 index 000000000..033cfcf7f --- /dev/null +++ b/clis/wellfound/filters.js @@ -0,0 +1,77 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { + FILTER_COLUMNS, + assertAuthenticated, + buildFilterConfig, + buildFilterInspectionScript, + buildFilterUpdateScript, + buildJobsUrl, + buildOpenFiltersScript, + normalizeFilterState, + parseBoolean, + unwrapEvaluateResult, +} from './utils.js'; + +cli({ + site: 'wellfound', + name: 'filters', + access: 'write', + description: 'Read or update the visible Wellfound Browse filters; updates require --execute', + domain: 'wellfound.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'url', type: 'string', default: 'https://wellfound.com/jobs', help: 'Wellfound jobs URL to open; defaults to Browse all jobs' }, + { name: 'preset', type: 'string', help: 'Optional filter preset, currently: ai-fullstack-remote' }, + { name: 'salary-min', type: 'string', default: '', help: 'Minimum salary filter value when supported by the UI' }, + { name: 'salary-max', type: 'string', default: '', help: 'Maximum salary filter value when supported by the UI' }, + { name: 'currency', type: 'string', default: '', help: 'Salary currency text, e.g. INR or USD' }, + { name: 'equity-min', type: 'string', default: '', help: 'Minimum equity value when supported by the UI' }, + { name: 'equity-max', type: 'string', default: '', help: 'Maximum equity value when supported by the UI' }, + { name: 'skills', type: 'string', default: '', help: 'Comma-separated skills to select; autocomplete selections may require manual UI support' }, + { name: 'markets', type: 'string', default: '', help: 'Comma-separated markets to select; autocomplete selections may require manual UI support' }, + { name: 'job-types', type: 'string', default: '', help: 'Comma-separated job types, e.g. "Full Time,Contract"' }, + { name: 'include-keywords', type: 'string', default: '', help: 'Comma-separated included keywords' }, + { name: 'exclude-keywords', type: 'string', default: '', help: 'Comma-separated excluded keywords' }, + { name: 'company-sizes', type: 'string', default: '', help: 'Comma-separated company size labels' }, + { name: 'stages', type: 'string', default: '', help: 'Comma-separated investment stage labels' }, + { name: 'mostly-remote', type: 'boolean', help: 'Only show companies that are mostly or fully remote' }, + { name: 'responsive', type: 'boolean', help: 'Only show highly responsive companies' }, + { name: 'visa', type: 'boolean', help: 'Only show companies that can sponsor a visa' }, + { name: 'hide-company-apply', type: 'boolean', help: 'Document desired setting for hiding company-website applications; current UI exposes this outside the modal' }, + { name: 'execute', type: 'boolean', default: false, help: 'Actually update filters. Without it, this reads current filters and previews requested changes.' }, + ], + columns: FILTER_COLUMNS, + func: async (page, args) => { + if (!page) throw new CommandExecutionError('Browser session required for wellfound filters'); + const execute = parseBoolean(args.execute, false); + const config = buildFilterConfig(args); + + await page.goto(buildJobsUrl(args)); + await page.wait(4); + await assertAuthenticated(page, 'wellfound filters'); + + const opened = unwrapEvaluateResult(await page.evaluate(buildOpenFiltersScript())); + if (!opened?.opened) { + throw new CommandExecutionError(`wellfound filters could not open the filters dialog: ${opened?.reason || 'unknown reason'}`); + } + + if (!execute) { + const notes = config.usePreset + ? 'dry-run; ai-fullstack-remote preset prepared but not applied' + : 'dry-run; pass --execute to update filters'; + return [normalizeFilterState(unwrapEvaluateResult(await page.evaluate(buildFilterInspectionScript('dry-run', notes))))]; + } + + const update = unwrapEvaluateResult(await page.evaluate(buildFilterUpdateScript(config))); + if (!update?.ok) { + throw new CommandExecutionError(`wellfound filters update failed: ${update?.notes || 'unknown reason'}`); + } + const unsupported = Array.isArray(update.unsupported) && update.unsupported.length + ? `Unsupported controls: ${update.unsupported.join(', ')}` + : 'Filters updated'; + return [normalizeFilterState(unwrapEvaluateResult(await page.evaluate(buildFilterInspectionScript('updated', unsupported))))]; + }, +}); diff --git a/clis/wellfound/job-detail.js b/clis/wellfound/job-detail.js new file mode 100644 index 000000000..1e826ac73 --- /dev/null +++ b/clis/wellfound/job-detail.js @@ -0,0 +1,33 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + assertAuthenticated, + buildDetailExtractionScript, + buildDetailUrl, + DETAIL_COLUMNS, + normalizeDetailRow, + normalizeJobSlug, + unwrapEvaluateResult, +} from './utils.js'; + +cli({ + site: 'wellfound', + name: 'job-detail', + access: 'read', + description: 'Read one Wellfound job detail dialog with description, skills, remote policy, and company metadata', + domain: 'wellfound.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'job-url', type: 'string', required: true, positional: true, help: 'Wellfound /jobs/ URL, ?job_listing_slug URL, or raw id-slug' }, + ], + columns: DETAIL_COLUMNS, + func: async (page, args) => { + const slug = normalizeJobSlug(args['job-url']); + await page.goto(buildDetailUrl(slug)); + await page.wait(4); + await assertAuthenticated(page, 'wellfound job-detail'); + const payload = unwrapEvaluateResult(await page.evaluate(buildDetailExtractionScript())); + return [normalizeDetailRow(payload)]; + }, +}); diff --git a/clis/wellfound/jobs.js b/clis/wellfound/jobs.js new file mode 100644 index 000000000..fb281e30a --- /dev/null +++ b/clis/wellfound/jobs.js @@ -0,0 +1,47 @@ +/** + * Wellfound Browse jobs reader. + * + * Strategy note: + * Strategy: UI_SELECTOR + * Contract: visible-ui + * Evidence: + * - observed state: /jobs renders visible company cards and job links; opening a + * job only adds ?job_listing_slug= and keeps the search context. + * - auth source: signed-in browser session; no cookies or tokens are read. + * - replay result: DOM extraction returns the visible card fields users compare. + */ + +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + assertAuthenticated, + buildJobsExtractionScript, + buildJobsUrl, + JOB_COLUMNS, + normalizeJobRows, + parseLimit, + unwrapEvaluateResult, +} from './utils.js'; + +cli({ + site: 'wellfound', + name: 'jobs', + access: 'read', + description: 'Read visible Wellfound Browse jobs from the current saved/filtered search', + domain: 'wellfound.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'url', type: 'string', default: 'https://wellfound.com/jobs', help: 'Wellfound jobs URL to open; defaults to Browse all jobs' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of visible job cards to return (1-50)' }, + ], + columns: JOB_COLUMNS, + func: async (page, args) => { + const limit = parseLimit(args.limit, 20, 50); + await page.goto(buildJobsUrl(args)); + await page.wait(4); + await assertAuthenticated(page, 'wellfound jobs'); + const payload = unwrapEvaluateResult(await page.evaluate(buildJobsExtractionScript())); + return normalizeJobRows(payload, limit, 'wellfound jobs'); + }, +}); diff --git a/clis/wellfound/top-picks.js b/clis/wellfound/top-picks.js new file mode 100644 index 000000000..e09c96d19 --- /dev/null +++ b/clis/wellfound/top-picks.js @@ -0,0 +1,76 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + assertAuthenticated, + buildDetailExtractionScript, + buildDetailUrl, + buildJobsExtractionScript, + buildJobsUrl, + JOB_COLUMNS, + isTopPickHardReject, + normalizeDetailRow, + topPickScoreMultiplier, + normalizeJobRows, + parseBoolean, + parseLimit, + unwrapEvaluateResult, +} from './utils.js'; + +cli({ + site: 'wellfound', + name: 'top-picks', + aliases: ['daily'], + access: 'read', + description: 'Rank the current Wellfound filtered Browse results and return the best daily application targets', + domain: 'wellfound.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'url', type: 'string', default: 'https://wellfound.com/jobs', help: 'Wellfound jobs URL to open; defaults to Browse all jobs' }, + { name: 'limit', type: 'int', default: 5, help: 'Number of picks to return (1-20)' }, + { name: 'pool', type: 'int', default: 30, help: 'Visible result pool size to score before ranking (1-50)' }, + { name: 'verify-details', type: 'boolean', default: false, help: 'Open candidate detail pages before final ranking to merge company status and detail-only signals' }, + ], + columns: JOB_COLUMNS, + func: async (page, args) => { + const limit = parseLimit(args.limit, 5, 20); + const pool = Math.max(limit, parseLimit(args.pool, 30, 50)); + const verifyDetails = parseBoolean(args['verify-details'], false); + await page.goto(buildJobsUrl(args)); + await page.wait(4); + await assertAuthenticated(page, 'wellfound top-picks'); + const payload = unwrapEvaluateResult(await page.evaluate(buildJobsExtractionScript())); + const sourceRows = normalizeJobRows(payload, pool, 'wellfound top-picks'); + const ranked = sourceRows + .filter((row) => verifyDetails || !isTopPickHardReject(row)) + .map((row) => ({ + ...row, + score: Math.round(row.score * topPickScoreMultiplier(row)), + })) + .sort((left, right) => right.score - left.score || left.rank - right.rank); + + const verified = verifyDetails ? [] : ranked; + if (verifyDetails) { + for (const row of ranked) { + if (verified.length >= limit) break; + await page.goto(buildDetailUrl(row.url)); + await page.wait(2); + await assertAuthenticated(page, 'wellfound top-picks detail verification'); + const detail = normalizeDetailRow(unwrapEvaluateResult(await page.evaluate(buildDetailExtractionScript()))); + const merged = { + ...row, + location: detail.location || row.location, + compensation: detail.compensation || row.compensation, + job_type: detail.job_type || row.job_type, + company_status: detail.company_status || row.company_status, + raw: `${row.raw || ''} ${detail.skills || ''} ${detail.description || ''}`, + }; + if (!isTopPickHardReject(merged)) verified.push(merged); + } + } + + return verified + .slice(0, limit) + .map((row, index) => ({ ...row, rank: index + 1 })); + }, +}); diff --git a/clis/wellfound/utils.js b/clis/wellfound/utils.js new file mode 100644 index 000000000..6b6d93d90 --- /dev/null +++ b/clis/wellfound/utils.js @@ -0,0 +1,892 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; + +export const WELLFOUND_ORIGIN = 'https://wellfound.com'; +export const WELLFOUND_DOMAIN = 'wellfound.com'; + +export const JOB_COLUMNS = [ + 'rank', + 'score', + 'title', + 'company', + 'location', + 'compensation', + 'job_type', + 'posted', + 'recruiter_active', + 'apply_on_wellfound', + 'company_status', + 'company_summary', + 'company_size', + 'company_tags', + 'url', + 'company_url', +]; + +export const DETAIL_COLUMNS = [ + 'title', + 'company', + 'location', + 'compensation', + 'job_type', + 'experience', + 'posted', + 'recruiter_active', + 'remote_policy', + 'company_location', + 'visa_sponsorship', + 'preferred_timezones', + 'collaboration_hours', + 'relocation', + 'company_status', + 'skills', + 'company_size', + 'company_industries', + 'description', + 'url', + 'company_url', +]; + +export const APPLY_COLUMNS = [ + 'status', + 'apply_mode', + 'title', + 'company', + 'message', + 'message_filled', + 'message_length', + 'external_apply_url', + 'url', + 'notes', +]; + +export const FILTER_COLUMNS = [ + 'status', + 'results', + 'role', + 'remote', + 'region', + 'salary', + 'currency', + 'equity', + 'skills', + 'markets', + 'job_types', + 'experience', + 'included_keywords', + 'excluded_keywords', + 'company_size', + 'investment_stage', + 'remote_culture', + 'responsiveness', + 'visa_sponsorship', + 'hide_company_apply', + 'url', + 'notes', +]; + +export const AI_FULLSTACK_REMOTE_PRESET = { + skills: ['TypeScript', 'React.js', 'Next.js', 'Node.js'], + markets: ['Artificial Intelligence', 'Developer Tools', 'SaaS'], + jobTypes: ['Full Time', 'Contract'], + includedKeywords: ['AI', 'agentic', 'MCP', 'RAG', 'Gen AI', 'Generative AI', 'Next.js', 'React', 'TypeScript', 'Node.js'], + excludedKeywords: ['Java, PHP, C#, .NET, Ruby, Scala, AWS Bedrock, unpaid'], + companySizes: ['1-10 employees', '11-50 employees', '51-200 employees', '201-500 employees'], + stages: ['Seed Stage', 'Series A', 'Series B', 'Growth'], + mostlyRemote: true, + responsive: true, + visa: false, + hideCompanyApply: true, +}; + +export function unwrapEvaluateResult(payload) { + if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data; + return payload; +} + +const AI_FOCUS_REGEXP = /\b(ai|artificial intelligence|agentic|agent|rag|mcp|llm|gen\s*ai|generative ai|next\.?js|react\.?js|node\.?js|typescript)\b/i; +const CORE_STACK_REGEXP = /\b(node\.?js|react\.?js|next\.?js|typescript|javascript)\b/i; +const DISALLOWED_STACK_REGEXP = /\b(java|c#|\.net|php|ruby|scala|go|salesforce)\b/i; +const PYTHON_ONLY_EXCLUDE_REGEXP = /\b(python)\b/i; +const KEYWORD_REGEXP = /\b(internship|intern|part[- ]?time|equity only|unpaid)\b/i; +const CLOSED_COMPANY_REGEXP = /\bclosed\b/i; + +function toMatchPoolText(row) { + return normalizeWhitespace(`${row?.title || ''} ${row?.location || ''} ${row?.compensation || ''} ${row?.raw || ''} ${row?.company_summary || ''} ${row?.company || ''} ${row?.company_status || ''}`); +} + +function hasPythonOnlySignals(row) { + const text = toMatchPoolText(row); + if (!PYTHON_ONLY_EXCLUDE_REGEXP.test(text)) return false; + return ( + !CORE_STACK_REGEXP.test(text) + && !AI_FOCUS_REGEXP.test(text) + && !/\b(front[- ]?end|full[- ]?stack|platform|product|workflow|automation)\b/i.test(text) + ); +} + +export function isTopPickHardReject(row) { + const text = toMatchPoolText(row); + const hasFitSignals = AI_FOCUS_REGEXP.test(text) || CORE_STACK_REGEXP.test(text); + if (!hasFitSignals) return true; + if (DISALLOWED_STACK_REGEXP.test(text)) return true; + if (KEYWORD_REGEXP.test(text)) return true; + if (CLOSED_COMPANY_REGEXP.test(normalizeWhitespace(row?.company_status))) return true; + if (hasPythonOnlySignals(row)) return true; + return false; +} + +export function topPickScoreMultiplier(row) { + const text = toMatchPoolText(row); + if (AI_FOCUS_REGEXP.test(text) && CORE_STACK_REGEXP.test(text)) return 1.35; + if (AI_FOCUS_REGEXP.test(text) || CORE_STACK_REGEXP.test(text)) return 1.15; + if (/offshore|onsite|onsite only/i.test(text)) return 0.7; + return 1; +} + +export function normalizeWhitespace(value) { + return String(value ?? '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); +} + +export function normalizeWellfoundUrl(value, label = 'url') { + const raw = normalizeWhitespace(value || `${WELLFOUND_ORIGIN}/jobs`); + let parsed; + try { + parsed = new URL(raw, WELLFOUND_ORIGIN); + } catch { + throw new ArgumentError(`${label} must be a Wellfound URL`); + } + const host = parsed.hostname.toLowerCase(); + if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) { + throw new ArgumentError(`${label} must be an https Wellfound URL without credentials or port`); + } + if (host !== WELLFOUND_DOMAIN && host !== `www.${WELLFOUND_DOMAIN}`) { + throw new ArgumentError(`${label} must point to wellfound.com`); + } + parsed.hostname = WELLFOUND_DOMAIN; + return parsed.toString(); +} + +export function parseLimit(value, fallback, max) { + if (value === undefined || value === null || value === '') return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) { + throw new ArgumentError(`--limit must be an integer between 1 and ${max}`); + } + return parsed; +} + +export function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === '') return fallback; + if (value === true || value === 'true' || value === '1' || value === 1) return true; + if (value === false || value === 'false' || value === '0' || value === 0) return false; + throw new ArgumentError(`Expected boolean value, got "${value}"`); +} + +export function parseListArg(value) { + if (value === undefined || value === null || value === '') return []; + if (Array.isArray(value)) return value.map(normalizeWhitespace).filter(Boolean); + return String(value).split(',').map(normalizeWhitespace).filter(Boolean); +} + +export function normalizeJobSlug(value) { + const raw = normalizeWhitespace(value); + if (!raw) throw new ArgumentError('job-url is required'); + if (/^\d+-[a-z0-9-]+$/i.test(raw)) return raw; + const parsed = new URL(normalizeWellfoundUrl(raw, 'job-url')); + const fromParam = parsed.searchParams.get('job_listing_slug'); + if (fromParam && /^\d+-[a-z0-9-]+$/i.test(fromParam)) return fromParam; + const match = parsed.pathname.match(/^\/jobs\/([^/?#]+)/); + if (match) return match[1]; + throw new ArgumentError('job-url must be a Wellfound /jobs/ URL or a job_listing_slug URL'); +} + +export function buildJobsUrl(args = {}) { + const base = normalizeWellfoundUrl(args.url || `${WELLFOUND_ORIGIN}/jobs`); + const parsed = new URL(base); + if (!parsed.pathname.startsWith('/jobs')) parsed.pathname = '/jobs'; + return parsed.toString(); +} + +export function buildDetailUrl(slug) { + return `${WELLFOUND_ORIGIN}/jobs/${normalizeJobSlug(slug)}`; +} + +export function normalizeApplyMessage(value) { + return normalizeMultilineText(value); +} + +export function normalizeMultilineText(value) { + return String(value ?? '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => normalizeWhitespace(line)) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function looksAuthWall(text) { + const normalized = normalizeWhitespace(text).toLowerCase(); + return /log in|sign in|join wellfound|continue with google|captcha|verification/i.test(normalized) + && !/search for jobs|browse all|recommended/i.test(normalized); +} + +export async function assertAuthenticated(page, context) { + const result = unwrapEvaluateResult(await page.evaluate(String.raw`(() => { + const text = [ + location.href || '', + document.title || '', + document.body ? (document.body.innerText || '').slice(0, 3000) : '', + ].join('\n'); + return { + text, + hasJobsNav: /\bBrowse all\b|\bSearch for jobs\b|\bRecommended\b/i.test(text), + hasSignedInShell: /\bReady to interview\b|\bApplied\b|\bMessages\b|\bDiscover\b|\bProfile\b/i.test(text), + }; + })()`)); + if (!result || typeof result !== 'object') { + throw new CommandExecutionError(`${context} returned malformed auth probe payload`); + } + if (looksAuthWall(result.text) || (!result.hasJobsNav && !result.hasSignedInShell)) { + throw new AuthRequiredError(WELLFOUND_DOMAIN, `${context} requires an active signed-in Wellfound browser session.`); + } +} + +export function buildJobsExtractionScript() { + return String.raw`(() => { + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const abs = (href) => { + try { return href ? new URL(href, location.origin).toString() : ''; } catch { return ''; } + }; + const companyLinks = Array.from(document.querySelectorAll('a[href^="/company/"]')) + .filter((link) => link.querySelector('h2') || /company logo/i.test(clean(link.textContent || link.getAttribute('aria-label') || ''))); + const companies = []; + for (const link of companyLinks) { + const container = link.closest('article, section, li, div') || link.parentElement; + const heading = container?.querySelector('h2'); + const company = clean(heading?.innerText || heading?.textContent || link.textContent || ''); + if (!company) continue; + const text = clean(container?.innerText || container?.textContent || ''); + if (companies.some((item) => item.company === company && item.companyUrl === abs(link.getAttribute('href')))) continue; + const size = (text.match(/\b\d+(?:-\d+|\+)?\s+Employees\b/i) || [''])[0]; + const companyStatus = /\bClosed\b/i.test(text) ? 'closed' : (/Actively Hiring/i.test(text) ? 'actively_hiring' : ''); + const summary = clean(text + .replace(company, '') + .replace(/Actively Hiring|Promoted/gi, '') + .replace(/Closed/gi, '') + .replace(size, '')); + companies.push({ + company, + companyUrl: abs(link.getAttribute('href')), + companyStatus, + companySummary: summary, + companySize: size, + tags: [], + y: container?.getBoundingClientRect?.().top ?? 0, + }); + } + const jobLinks = Array.from(document.querySelectorAll('a[href^="/jobs/"]')); + const rows = []; + for (const link of jobLinks) { + const href = link.getAttribute('href') || ''; + const slug = href.match(/^\/jobs\/([^/?#]+)/)?.[1] || ''; + if (!/^\d+-/.test(slug)) continue; + if (!slug || rows.some((row) => row.slug === slug)) continue; + const text = clean(link.innerText || link.textContent || ''); + if (!text) continue; + const parts = text.split(/\s+•\s+/).map(clean).filter(Boolean); + const title = clean(link.querySelector('h1,h2,h3,h4')?.innerText || link.querySelector('h1,h2,h3,h4')?.textContent || parts[0] || ''); + if (!title) continue; + const posted = (text.match(/(?:Posted|Reposted)\s+(?:today|yesterday|\d+\s+(?:day|days|week|weeks|month|months|year|years)\s+ago)/i) || text.match(/\b(?:today|yesterday|\d+\s+(?:day|days|week|weeks|month|months|year|years)\s+ago)\b/i) || [''])[0]; + const recruiterActive = /Recruiter recently active/i.test(text); + const locationParts = parts.filter((part) => /remote|onsite|hybrid|india|everywhere|singapore|united states|europe|asia/i.test(part)); + const compensation = parts.find((part) => /[$₹€£]|\b(?:equity|No equity|L|cr|k)\b|%/.test(part)) || ''; + const company = companies.filter((item) => item.y <= (link.getBoundingClientRect?.().top ?? 0) + 5).pop() || companies[companies.length - 1] || {}; + const actionRoot = link.parentElement?.parentElement || link.parentElement; + rows.push({ + slug, + title, + location: locationParts.join(' • '), + compensation, + posted, + recruiter_active: recruiterActive, + apply_on_wellfound: /Apply on Wellfound/i.test(clean(actionRoot?.innerText || actionRoot?.textContent || '')), + company: company.company || '', + company_status: company.companyStatus || '', + company_summary: company.companySummary || '', + company_size: company.companySize || '', + company_tags: Array.isArray(company.tags) ? company.tags.join('; ') : '', + url: abs(href), + company_url: company.companyUrl || '', + raw: text, + }); + } + const resultHeading = clean(Array.from(document.querySelectorAll('h1,h2,h3,h4')).map((h) => h.innerText || h.textContent || '').find((s) => /\d+\s+results/i.test(s)) || ''); + return { result_heading: resultHeading, rows }; + })()`; +} + +export function normalizeJobRows(payload, limit, context = 'wellfound jobs') { + if (!payload || typeof payload !== 'object' || !Array.isArray(payload.rows)) { + throw new CommandExecutionError(`${context} returned malformed extraction payload`); + } + const rows = payload.rows.map((row, index) => normalizeJobRow(row, index + 1)).filter((row) => row.title && row.url); + if (rows.length === 0) throw new EmptyResultError(context, 'No Wellfound job cards were visible in the current search'); + return rows.slice(0, limit); +} + +export function normalizeJobRow(row, rank = 1) { + const rawText = normalizeWhitespace(row?.raw || row?.title); + const title = cleanJobTitle(row?.title || rawText); + const company = normalizeWhitespace(row?.company); + const location = normalizeWhitespace(extractLocation(row?.location) || extractLocation(rawText)); + const compensation = normalizeWhitespace(extractCompensation(row?.compensation) || extractCompensation(rawText)); + const posted = normalizeWhitespace(row?.posted); + const recruiterActive = Boolean(row?.recruiter_active); + const applyOnWellfound = Boolean(row?.apply_on_wellfound); + return { + rank, + score: scoreJob({ ...row, title, company, location, compensation, posted, recruiter_active: recruiterActive, apply_on_wellfound: applyOnWellfound }), + title, + company, + location, + compensation, + job_type: inferJobType(`${title} ${location} ${row?.raw || ''}`), + posted, + recruiter_active: recruiterActive ? 'yes' : 'no', + apply_on_wellfound: applyOnWellfound ? 'yes' : 'no', + company_status: normalizeCompanyStatus(row?.company_status), + company_summary: normalizeWhitespace(row?.company_summary), + company_size: normalizeWhitespace(row?.company_size), + company_tags: normalizeWhitespace(row?.company_tags), + url: normalizeWellfoundUrl(row?.url || `/jobs/${row?.slug || ''}`), + company_url: row?.company_url ? normalizeWellfoundUrl(row.company_url, 'company_url') : '', + }; +} + +export function cleanJobTitle(text) { + const raw = normalizeWhitespace(text); + if (!raw) return ''; + return normalizeWhitespace(raw + .replace(/\b(?:Remote only|Remote|Onsite or remote|Onsite|Hybrid)\b.*$/i, '') + .replace(/\s+[$₹€£].*$/i, '') + .replace(/\s+\d+(?:\.\d+)?%\s*[–-].*$/i, '')); +} + +export function inferJobType(text) { + const normalized = normalizeWhitespace(text); + if (/\bcontract\b/i.test(normalized)) return 'Contract'; + if (/\bfreelance\b/i.test(normalized)) return 'Freelance'; + if (/\bpart[- ]?time\b/i.test(normalized)) return 'Part Time'; + if (/\bintern(?:ship)?\b/i.test(normalized)) return 'Internship'; + if (/\bco[- ]?founder\b/i.test(normalized)) return 'Cofounder'; + if (/\bfull[- ]?time\b/i.test(normalized)) return 'Full Time'; + return ''; +} + +export function extractLocation(text) { + const raw = normalizeWhitespace(text); + const match = raw.match(/\b(?:Remote only|Remote|Onsite or remote|Onsite|Hybrid)\b(?:\s*•?\s*(?:Remote\s*\([^)]*\)|[A-Z][A-Za-z\s,]+|India|Everywhere|Singapore|United States|Europe|Asia))?/i); + if (!match) return ''; + return normalizeWhitespace(match[0].replace(/(only|remote)(India|Everywhere|Singapore|United States|Europe|Asia)/i, '$1 • $2')); +} + +export function extractCompensation(text) { + const raw = normalizeWhitespace(text); + const match = raw.match(/(?:[$₹€£]\s?[\d,.]+\s*(?:k|K|L|cr|m|M)?(?:\s*[–-]\s*[$₹€£]?[\d,.]+\s*(?:k|K|L|cr|m|M)?)?(?:\s*•\s*(?:No equity|[\d.]+%\s*[–-]\s*[\d.]+%))?|(?:No equity|[\d.]+%\s*[–-]\s*[\d.]+%))/); + return match ? normalizeWhitespace(match[0]) : ''; +} + +export function scoreJob(row) { + const text = `${row.title || ''} ${row.location || ''} ${row.compensation || ''} ${row.posted || ''} ${row.raw || ''}`; + let score = 0; + if (/remote only/i.test(text)) score += 25; + else if (/remote/i.test(text)) score += 15; + if (/today|yesterday/i.test(text)) score += 20; + else if (/\b\d+\s+days?\s+ago\b/i.test(text)) score += 10; + if (row.recruiter_active) score += 15; + if (row.apply_on_wellfound) score += 10; + if (/\b(ai|agent|full[- ]?stack|platform|founding|product engineer|react|node)\b/i.test(text)) score += 15; + if (/\bcontract|freelance|part[- ]?time\b/i.test(text)) score += 8; + if (/₹\s*(?:[3-9]\d|[1-9]\d{2})L|₹.*cr|\$\s*(?:[6-9]\d|[1-9]\d{2})k/i.test(text)) score += 12; + if (/\bintern(?:ship)?\b/i.test(text)) score -= 35; + if (/unpaid|equity only/i.test(text)) score -= 35; + if (/₹\s*\d{1,2},\d{3}\b/i.test(text)) score -= 25; + if (/₹\s*(?:[1-9](?:\.\d+)?L|1\d(?:\.\d+)?L|2\d(?:\.\d+)?L)(?:\s*[–-]\s*₹?\s*(?:[1-9](?:\.\d+)?L|1\d(?:\.\d+)?L|2\d(?:\.\d+)?L))?\b/i.test(text) && !/₹\s*(?:3\d|[4-9]\d|[1-9]\d{2})L|₹.*cr/i.test(text)) score -= 18; + if (/₹\s*[1-9](?:\.\d+)?L\s*[–-]\s*₹?\s*[1-9](?:\.\d+)?L/i.test(text)) score -= 30; + if (/\$\s*(?:[1-2]?\d)k\s*[–-]\s*\$?\s*(?:[1-2]?\d)k/i.test(text)) score -= 18; + if (/\b(Java|PHP|C#|C\+\+|Ruby|Scala|\.NET|AWS Bedrock|Django)\b/i.test(text)) score -= 20; + return score; +} + +export function normalizeCompanyStatus(value) { + const normalized = normalizeWhitespace(value).toLowerCase(); + if (!normalized) return ''; + if (/\bclosed\b/.test(normalized)) return 'closed'; + if (/\bactively[_ -]?hiring\b/.test(normalized)) return 'actively_hiring'; + return normalized; +} + +export function buildDetailExtractionScript() { + return String.raw`(() => { + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const abs = (href) => { + try { return href ? new URL(href, location.origin).toString() : ''; } catch { return ''; } + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')); + const jobDialog = dialogs.reverse().find((node) => { + const content = clean(node.innerText || node.textContent || ''); + return node.querySelector('h1') && /\bApply\b|\bAbout the job\b|\bRemote work policy\b/i.test(content); + }); + const dialog = jobDialog || document.body; + const jobDetail = dialog.querySelector('[data-test="JobDetail"]') || document.querySelector('[data-test="JobDetail"]') || dialog; + const text = clean(dialog?.innerText || dialog?.textContent || ''); + const title = clean(jobDetail?.querySelector('h1')?.innerText || jobDetail?.querySelector('h1')?.textContent || dialog?.querySelector('h1')?.innerText || dialog?.querySelector('h1')?.textContent || '').replace(/\s+at\s+.+$/i, ''); + const companyLink = Array.from(dialog?.querySelectorAll('a[href^="/company/"]') || []).find((a) => clean(a.innerText || a.textContent || '').length > 1); + const company = clean(companyLink?.innerText || companyLink?.textContent || ''); + const companyUrl = abs(companyLink?.getAttribute('href') || ''); + const detailsList = clean(jobDetail?.querySelector('h1')?.parentElement?.innerText || dialog?.querySelector('h1')?.nextElementSibling?.innerText || ''); + const fieldAfter = (label) => { + const dt = Array.from(dialog.querySelectorAll('dt')).find((node) => new RegExp('^' + label + '$', 'i').test(clean(node.innerText || node.textContent || ''))); + const dd = dt?.parentElement?.querySelector('dd'); + if (dd) return clean(dd.innerText || dd.textContent || ''); + const pattern = new RegExp(label + '\\\\s+([^\\\\n]+)', 'i'); + return clean((text.match(pattern) || [])[1] || ''); + }; + const aboutHeading = Array.from(dialog?.querySelectorAll('h2') || []).find((h) => /^About the job$/i.test(clean(h.innerText || h.textContent || ''))); + const descriptionParts = []; + let node = aboutHeading?.nextElementSibling; + while (node) { + const heading = /^H[1-4]$/.test(node.tagName || '') ? clean(node.innerText || node.textContent || '') : ''; + if (/^About the company$/i.test(heading)) break; + const value = clean(node.innerText || node.textContent || ''); + if (value) descriptionParts.push(value); + node = node.nextElementSibling; + } + if (!descriptionParts.length && jobDetail) { + descriptionParts.push(clean(jobDetail.innerText || jobDetail.textContent || '')); + } + const skillStart = text.indexOf('Skills '); + const aboutStart = text.indexOf('About the job'); + const skillsText = skillStart >= 0 && aboutStart > skillStart + ? text.slice(skillStart + 7, aboutStart) + : clean(Array.from(dialog.querySelectorAll('dt')).find((node) => /^Skills$/i.test(clean(node.innerText || node.textContent || '')))?.parentElement?.querySelector('dd')?.innerText || ''); + const industryLinks = Array.from(dialog?.querySelectorAll('a[href*="/startups/industry/"]') || []).map((a) => clean(a.innerText || a.textContent || '')).filter(Boolean); + const size = clean((text.match(/\b\d+(?:-\d+|\+)?\s+Employees\b/i) || text.match(/Company Size\s+(\d+(?:-\d+|\+)?)/i) || [''])[0]).replace(/^Company Size\s+/i, ''); + const companyHeaderText = clean((dialog?.querySelector('[data-testid="startup-header"]') || dialog?.querySelector('section'))?.innerText || ''); + const statusText = companyHeaderText || text.slice(0, 1200); + return { + title, + company, + company_url: companyUrl, + details: detailsList, + text, + compensation: (detailsList.match(/[$₹€£]\s*[\d,.]+\s*(?:k|K|L|cr|m|M)?\s*[–-]\s*[$₹€£]?\s*[\d,.]+\s*(?:k|K|L|cr|m|M)?(?:\s*•\s*(?:No equity|[\d.]+%\s*[–-]\s*[\d.]+%))?|No equity|[\d.]+%\s*[–-]\s*[\d.]+%/) || text.match(/[$₹€£]\s*[\d,.]+\s*(?:k|K|L|cr|m|M)?\s*[–-]\s*[$₹€£]?\s*[\d,.]+\s*(?:k|K|L|cr|m|M)?(?:\s*•\s*(?:No equity|[\d.]+%\s*[–-]\s*[\d.]+%))?/) || [''])[0], + location: (detailsList.match(/Remote[^|]+|Onsite[^|]+|Hybrid[^|]+/) || (fieldAfter('Hires remotely') ? ['Remote (' + fieldAfter('Hires remotely') + ')'] : ['']))[0], + experience: (detailsList.match(/\d+\s+years?\s+of\s+exp/i) || text.match(/\d+\s*\+?\s+years?/i) || [''])[0], + job_type: (detailsList.match(/\bFull Time|Part Time|Contract|Internship|Cofounder|Freelance\b/i) || text.match(/\bFull Time|Part Time|Contract|Internship|Cofounder|Freelance\b/i) || [''])[0], + posted: (text.match(/Reposted:\s*[^•]+|Posted\s+(?:today|yesterday|\d+\s+(?:day|days|week|weeks|month|months|year|years)\s+ago)/i) || [''])[0], + recruiter_active: /Recruiter recently active/i.test(text), + remote_policy: fieldAfter('Remote Work Policy'), + company_location: fieldAfter('Company Location'), + visa_sponsorship: fieldAfter('Visa Sponsorship'), + preferred_timezones: fieldAfter('Preferred Timezones'), + collaboration_hours: fieldAfter('Collaboration Hours'), + relocation: fieldAfter('Relocation'), + company_status: /\bClosed\b/i.test(statusText) ? 'closed' : (/Actively Hiring/i.test(statusText) ? 'actively_hiring' : ''), + skills: skillsText, + company_size: size, + company_industries: Array.from(new Set(industryLinks)).join('; '), + description: descriptionParts.join('\\n\\n'), + url: location.href, + }; + })()`; +} + +export function normalizeDetailRow(row) { + if (!row || typeof row !== 'object') { + throw new CommandExecutionError('wellfound job-detail returned malformed extraction payload'); + } + const title = normalizeWhitespace(row.title); + if (!title) throw new CommandExecutionError('wellfound job-detail could not find a job title'); + return { + title, + company: normalizeWhitespace(row.company), + location: normalizeWhitespace(row.location || extractLocation(row.details)), + compensation: normalizeWhitespace(row.compensation || extractCompensation(row.details)), + job_type: normalizeWhitespace(row.job_type || inferJobType(row.details)), + experience: normalizeWhitespace(row.experience), + posted: normalizeWhitespace(row.posted).replace(/^Reposted:\s*/i, 'Reposted '), + recruiter_active: row.recruiter_active ? 'yes' : 'no', + remote_policy: normalizeWhitespace(row.remote_policy), + company_location: normalizeWhitespace(row.company_location), + visa_sponsorship: normalizeWhitespace(row.visa_sponsorship), + preferred_timezones: normalizeWhitespace(row.preferred_timezones), + collaboration_hours: normalizeWhitespace(row.collaboration_hours), + relocation: normalizeWhitespace(row.relocation), + company_status: normalizeCompanyStatus(row.company_status), + skills: normalizeWhitespace(row.skills).replace(/\s+/g, '; '), + company_size: normalizeWhitespace(row.company_size), + company_industries: normalizeWhitespace(row.company_industries), + description: normalizeWhitespace(row.description), + url: normalizeWellfoundUrl(row.url || WELLFOUND_ORIGIN), + company_url: row.company_url ? normalizeWellfoundUrl(row.company_url, 'company_url') : '', + }; +} + +export function normalizeApplyState(row) { + if (!row || typeof row !== 'object') { + throw new CommandExecutionError('wellfound apply returned malformed extraction payload'); + } + const title = normalizeWhitespace(row.title); + const company = normalizeWhitespace(row.company); + const externalApplyUrl = row.external_apply_url ? normalizeWellfoundOrHttpUrl(row.external_apply_url) : ''; + let applyMode = normalizeWhitespace(row.apply_mode).toLowerCase(); + if (!applyMode) { + applyMode = externalApplyUrl ? 'company_website' : row.can_apply_on_wellfound ? 'wellfound' : 'unknown'; + } + return { + status: normalizeWhitespace(row.status || 'ready'), + apply_mode: applyMode, + title, + company, + message: normalizeMultilineText(row.message), + message_filled: normalizeWhitespace(row.message_filled), + message_length: row.message_length === undefined || row.message_length === null ? '' : String(row.message_length), + external_apply_url: externalApplyUrl, + url: normalizeWellfoundUrl(row.url || WELLFOUND_ORIGIN), + notes: normalizeWhitespace(row.notes), + }; +} + +export function normalizeWellfoundOrHttpUrl(value) { + const raw = normalizeWhitespace(value); + if (!raw) return ''; + try { + const parsed = new URL(raw, WELLFOUND_ORIGIN); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (parsed.username || parsed.password) return ''; + return parsed.toString(); + } catch { + return ''; + } +} + +export function assertExpectedApplyTarget(state, expectedTitle, expectedCompany) { + const title = normalizeWhitespace(expectedTitle); + const company = normalizeWhitespace(expectedCompany); + if (title && state.title !== title) { + throw new ArgumentError(`Refusing to apply: expected title "${title}" but found "${state.title}"`); + } + if (company && state.company !== company) { + throw new ArgumentError(`Refusing to apply: expected company "${company}" but found "${state.company}"`); + } +} + +export function buildApplyInspectionScript(message = '') { + return String.raw`(() => { + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const abs = (href) => { + try { return href ? new URL(href, location.origin).toString() : ''; } catch { return ''; } + }; + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')); + const jobDialog = dialogs.reverse().find((node) => { + const content = clean(node.innerText || node.textContent || ''); + return node.querySelector('h1') && /\bApply\b|\bAbout the job\b|\bRemote work policy\b/i.test(content); + }); + const dialog = jobDialog || document.body; + const jobDetail = dialog.querySelector('[data-test="JobDetail"]') || document.querySelector('[data-test="JobDetail"]') || dialog; + const text = clean(dialog.innerText || dialog.textContent || ''); + const title = clean(jobDetail.querySelector('h1')?.innerText || jobDetail.querySelector('h1')?.textContent || dialog.querySelector('h1')?.innerText || dialog.querySelector('h1')?.textContent || '').replace(/\s+at\s+.+$/i, ''); + const companyLink = Array.from(dialog.querySelectorAll('a[href^="/company/"]')).find((a) => clean(a.innerText || a.textContent || '').length > 1); + const company = clean(companyLink?.innerText || companyLink?.textContent || ''); + const textarea = Array.from(dialog.querySelectorAll('textarea')).find((el) => /interest|working|company|message/i.test(clean(el.placeholder || el.getAttribute('aria-label') || '') + ' ' + text)) || dialog.querySelector('textarea'); + const applyButton = Array.from(dialog.querySelectorAll('button')).find((btn) => /^Apply$/i.test(clean(btn.innerText || btn.textContent || btn.getAttribute('aria-label') || ''))); + const externalLink = Array.from(dialog.querySelectorAll('a[href]')).find((a) => { + const label = clean(a.innerText || a.textContent || a.getAttribute('aria-label') || ''); + const href = abs(a.getAttribute('href') || ''); + return /\bapply\b/i.test(label) && href && !href.startsWith('https://wellfound.com/'); + }); + const alreadyApplied = /\b(applied|application submitted|application sent)\b/i.test(text) && !applyButton; + let applyMode = 'unknown'; + if (textarea || applyButton || /Apply to/i.test(text)) applyMode = 'wellfound'; + if (externalLink) applyMode = 'company_website'; + if (alreadyApplied) applyMode = 'already_applied'; + return { + status: alreadyApplied ? 'already_applied' : 'ready', + apply_mode: applyMode, + can_apply_on_wellfound: Boolean(applyButton), + has_message_box: Boolean(textarea), + title, + company, + message: ${JSON.stringify(message)}, + external_apply_url: externalLink ? abs(externalLink.getAttribute('href') || '') : '', + url: location.href, + notes: alreadyApplied ? 'Already applied or submitted state detected' : (applyMode === 'company_website' ? 'This job appears to require applying on the company website' : ''), + }; + })()`; +} + +export function buildApplySubmitScript(message) { + return String.raw`(async () => { + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const jobRoot = () => { + const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')); + const jobDialog = dialogs.reverse().find((node) => { + const content = clean(node.innerText || node.textContent || ''); + return node.querySelector('h1') && /\bApply\b|\bAbout the job\b|\bRemote work policy\b/i.test(content); + }); + return jobDialog || document.querySelector('[data-test="JobDetail"]') || document.body; + }; + const findTextarea = (root) => { + const text = clean(root.innerText || root.textContent || ''); + return Array.from(root.querySelectorAll('textarea')).find((el) => /interest|working|company|message|question|answer/i.test(clean(el.placeholder || el.getAttribute('aria-label') || el.name || '') + ' ' + text)) || root.querySelector('textarea'); + }; + const findButton = (root, pattern) => Array.from(root.querySelectorAll('button')).find((btn) => pattern.test(clean(btn.innerText || btn.textContent || btn.getAttribute('aria-label') || ''))); + let root = jobRoot(); + let textarea = findTextarea(root); + if (!textarea) { + const revealButton = findButton(root, /^(Apply|Apply now)$/i) || findButton(document.body, /^(Apply|Apply now)$/i); + if (revealButton && !revealButton.disabled && revealButton.getAttribute('aria-disabled') !== 'true') { + revealButton.click(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + root = jobRoot(); + textarea = findTextarea(root); + } + } + if (textarea) { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + setter ? setter.call(textarea, ${JSON.stringify(message)}) : textarea.value = ${JSON.stringify(message)}; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + } + const filledValue = textarea ? textarea.value : ''; + const messageFilled = Boolean(textarea) && filledValue === ${JSON.stringify(message)}; + const submitButton = findButton(root, /^(Send application|Submit application)$/i) + || findButton(document.body, /^(Send application|Submit application)$/i) + || findButton(root, /^(Apply now|Apply)$/i) + || findButton(document.body, /^(Apply now|Apply)$/i); + if (!submitButton) { + return { clicked: false, status: 'not_submitted', message_filled: messageFilled ? 'yes' : 'no', message_length: filledValue.length, notes: 'Application submit button not found' }; + } + if (submitButton.disabled || submitButton.getAttribute('aria-disabled') === 'true') { + return { clicked: false, status: 'not_submitted', message_filled: messageFilled ? 'yes' : 'no', message_length: filledValue.length, notes: 'Application submit button is disabled' }; + } + submitButton.click(); + await new Promise((resolve) => setTimeout(resolve, 2500)); + const afterText = clean((Array.from(document.querySelectorAll('[role="dialog"]')).pop() || document.body).innerText || document.body.textContent || ''); + const submitted = /\b(application submitted|application sent|applied|you applied)\b/i.test(afterText); + return { + clicked: true, + status: submitted ? 'submitted' : 'clicked', + message_filled: messageFilled ? 'yes' : 'no', + message_length: filledValue.length, + notes: submitted ? 'Wellfound reported an applied/submitted state' : 'Clicked application submit; no explicit success text was detected', + }; + })()`; +} + +export function normalizeFilterState(row) { + if (!row || typeof row !== 'object') { + throw new CommandExecutionError('wellfound filters returned malformed extraction payload'); + } + return { + status: normalizeWhitespace(row.status || 'read'), + results: normalizeWhitespace(row.results), + role: normalizeWhitespace(row.role), + remote: normalizeWhitespace(row.remote), + region: normalizeWhitespace(row.region), + salary: normalizeWhitespace(row.salary), + currency: normalizeWhitespace(row.currency), + equity: normalizeWhitespace(row.equity), + skills: normalizeList(row.skills), + markets: normalizeList(row.markets), + job_types: normalizeList(row.job_types), + experience: normalizeWhitespace(row.experience), + included_keywords: normalizeWhitespace(row.included_keywords), + excluded_keywords: normalizeWhitespace(row.excluded_keywords), + company_size: normalizeList(row.company_size), + investment_stage: normalizeList(row.investment_stage), + remote_culture: normalizeWhitespace(row.remote_culture), + responsiveness: normalizeWhitespace(row.responsiveness), + visa_sponsorship: normalizeWhitespace(row.visa_sponsorship), + hide_company_apply: normalizeWhitespace(row.hide_company_apply), + url: normalizeWellfoundUrl(row.url || WELLFOUND_ORIGIN), + notes: normalizeWhitespace(row.notes), + }; +} + +function normalizeList(value) { + if (Array.isArray(value)) return value.map(normalizeWhitespace).filter(Boolean).join('; '); + return normalizeWhitespace(value); +} + +export function buildFilterConfig(args = {}) { + const preset = normalizeWhitespace(args.preset); + if (preset && preset !== 'ai-fullstack-remote') { + throw new ArgumentError(`Unknown wellfound filters preset "${preset}"`); + } + const usePreset = preset === 'ai-fullstack-remote'; + const base = usePreset ? AI_FULLSTACK_REMOTE_PRESET : {}; + return { + salaryMin: normalizeWhitespace(args['salary-min']), + salaryMax: normalizeWhitespace(args['salary-max']), + currency: normalizeWhitespace(args.currency), + equityMin: normalizeWhitespace(args['equity-min']), + equityMax: normalizeWhitespace(args['equity-max']), + skills: parseListArg(args.skills ?? base.skills), + markets: parseListArg(args.markets ?? base.markets), + jobTypes: parseListArg(args['job-types'] ?? base.jobTypes), + includedKeywords: parseListArg(args['include-keywords'] ?? base.includedKeywords), + excludedKeywords: parseListArg(args['exclude-keywords'] ?? base.excludedKeywords), + companySizes: parseListArg(args['company-sizes'] ?? base.companySizes), + stages: parseListArg(args.stages ?? base.stages), + mostlyRemote: args['mostly-remote'] !== undefined ? parseBoolean(args['mostly-remote']) : base.mostlyRemote, + responsive: args.responsive !== undefined ? parseBoolean(args.responsive) : base.responsive, + visa: args.visa !== undefined ? parseBoolean(args.visa) : base.visa, + hideCompanyApply: args['hide-company-apply'] !== undefined ? parseBoolean(args['hide-company-apply']) : base.hideCompanyApply, + usePreset, + }; +} + +export function buildFilterInspectionScript(status = 'read', notes = '') { + return String.raw`(() => { + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const body = clean(document.body?.innerText || document.body?.textContent || ''); + const dialog = Array.from(document.querySelectorAll('[role="dialog"]')).pop(); + const root = dialog || document.body; + const text = clean(root.innerText || root.textContent || ''); + const checkboxLabel = (input) => { + let node = input; + for (let i = 0; node && i < 4; i += 1, node = node.parentElement) { + const text = clean(node.innerText || node.textContent || ''); + if (text && text !== clean(input.value || '')) return text; + } + return clean(input.getAttribute('aria-label') || input.id || input.value || ''); + }; + const checkedLabels = Array.from(root.querySelectorAll('input[type="checkbox"]')) + .filter((input) => input.checked) + .map(checkboxLabel); + const resultHeading = clean(Array.from(document.querySelectorAll('h1,h2,h3,h4')).map((h) => h.innerText || h.textContent || '').find((s) => /\d+\s+results/i.test(s)) || ''); + const topButtons = Array.from(document.querySelectorAll('button')).map((b) => clean(b.innerText || b.textContent || '')).filter(Boolean).slice(0, 20); + const role = topButtons.find((value) => /engineer|developer|designer|manager|ai|full-stack|frontend|backend/i.test(value)) || ''; + const remote = topButtons.find((value) => /remote/i.test(value)) || ''; + const region = topButtons.find((value) => /asia|india|europe|united states|everywhere/i.test(value)) || ''; + const valuesByHeading = (heading) => { + const start = text.search(new RegExp(heading, 'i')); + if (start < 0) return ''; + return text.slice(start, start + 700); + }; + return { + status: ${JSON.stringify(status)}, + results: resultHeading, + role, + remote, + region, + salary: clean((valuesByHeading('Salary').match(/(?:Any salary|[$₹€£]?\\d[^\\n]{0,80})/) || [''])[0]), + currency: clean((valuesByHeading('Salary').match(/All currencies|USD|INR|EUR|GBP|CAD|AUD/i) || [''])[0]), + equity: clean((valuesByHeading('Equity').match(/\\d+(?:\\.\\d+)?%\\s*-\\s*(?:\\d+(?:\\.\\d+)?%|2%\\+)/) || [''])[0]), + skills: checkedLabels.filter((x) => /Python|React|Node|Java|Ruby|TypeScript|Next|AI|LLM|MCP|RAG/i.test(x)), + markets: checkedLabels.filter((x) => /Healthcare|E-Commerce|Education|Enterprise|Marketplaces|Artificial|Developer|SaaS/i.test(x)), + job_types: checkedLabels.filter((x) => /Full Time|Contract|Internship|Cofounder/i.test(x)), + experience: clean(valuesByHeading('Required experience').match(/\\d+\\s*-\\s*\\d+|\\d+\\+?|Any/i)?.[0] || ''), + included_keywords: '', + excluded_keywords: '', + company_size: checkedLabels.filter((x) => /employees/i.test(x)), + investment_stage: checkedLabels.filter((x) => /Seed Stage|Series A|Series B|Growth|IPO|Acquired/i.test(x)), + remote_culture: checkedLabels.some((x) => /mostly or fully remote/i.test(x)) ? 'yes' : 'no', + responsiveness: checkedLabels.some((x) => /highly responsive/i.test(x)) ? 'yes' : 'no', + visa_sponsorship: checkedLabels.some((x) => /sponsor a visa/i.test(x)) ? 'yes' : 'no', + hide_company_apply: /Hide jobs which require me to apply on the company's website/i.test(body) ? 'visible' : 'unknown', + url: location.href, + notes: ${JSON.stringify(notes)}, + }; + })()`; +} + +export function buildOpenFiltersScript() { + return String.raw`(async () => { + const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim(); + const isFilterDialog = () => { + const dialog = Array.from(document.querySelectorAll('[role="dialog"]')).pop(); + const text = clean(dialog?.innerText || dialog?.textContent || ''); + return /Compensation/.test(text) && /Job Types/.test(text) && /View results/.test(text); + }; + if (isFilterDialog()) return { opened: true }; + const existingDialog = Array.from(document.querySelectorAll('[role="dialog"]')).pop(); + if (existingDialog) { + const close = Array.from(existingDialog.querySelectorAll('button')).find((btn) => { + const text = clean(btn.innerText || btn.textContent || btn.getAttribute('aria-label') || ''); + return /close|back|×/i.test(text) || !text; + }); + if (close) close.click(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + const button = Array.from(document.querySelectorAll('button')).find((btn) => /^Filters$/i.test(clean(btn.innerText || btn.textContent || ''))); + if (!button) return { opened: false, reason: 'Filters button not found' }; + button.click(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { opened: isFilterDialog() }; + })()`; +} + +export function buildFilterUpdateScript(config) { + return String.raw`(async () => { + const config = ${JSON.stringify(config)}; + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim(); + const dialog = Array.from(document.querySelectorAll('[role="dialog"]')).pop(); + if (!dialog) return { ok: false, notes: 'Filters dialog is not open', unsupported: [] }; + const unsupported = []; + const setValue = (el, value) => { + if (!el || value === undefined || value === null || value === '') return false; + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + setter ? setter.call(el, String(value)) : el.value = String(value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; + }; + const inputs = Array.from(dialog.querySelectorAll('input')); + const spin = inputs.filter((el) => el.getAttribute('role') === 'spinbutton' || el.type === 'number' || el.inputMode === 'numeric'); + setValue(spin[0], config.salaryMin); + setValue(spin[1], config.salaryMax); + const textInputs = inputs.filter((el) => !['checkbox', 'radio', 'range', 'number'].includes(el.type)); + if (config.currency) setValue(textInputs.find((el) => /currenc/i.test(clean(el.placeholder || el.getAttribute('aria-label') || ''))) || textInputs[0], config.currency); + const checkboxLabel = (input) => { + let node = input; + for (let i = 0; node && i < 4; i += 1, node = node.parentElement) { + const text = clean(node.innerText || node.textContent || ''); + if (text && text !== clean(input.value || '')) return text; + } + return clean(input.getAttribute('aria-label') || input.id || input.value || ''); + }; + const clickCheckbox = (labelText, checked) => { + if (checked === undefined) return; + const expected = String(labelText).toLowerCase(); + const input = Array.from(dialog.querySelectorAll('input[type="checkbox"]')).find((node) => { + const label = checkboxLabel(node).toLowerCase(); + return label === expected || label.includes(expected); + }); + if (!input) { unsupported.push(labelText); return; } + if (input.checked !== checked) input.click(); + }; + for (const value of config.jobTypes || []) clickCheckbox(value, true); + for (const value of config.companySizes || []) clickCheckbox(value, true); + for (const value of config.stages || []) clickCheckbox(value, true); + clickCheckbox('Only show jobs at companies that are mostly or fully remote', config.mostlyRemote); + clickCheckbox('Only show companies highly responsive to incoming applications', config.responsive); + clickCheckbox('Only show companies that can sponsor a visa', config.visa); + const keywordInputs = Array.from(dialog.querySelectorAll('input[placeholder="Enter a keyword"]')); + if (config.includedKeywords?.length && keywordInputs[0]) setValue(keywordInputs[0], config.includedKeywords.join(', ')); + if (config.excludedKeywords?.length && keywordInputs[1]) setValue(keywordInputs[1], config.excludedKeywords.join(', ')); + if ((config.skills || []).length) unsupported.push('skills autocomplete requires visible UI selection'); + if ((config.markets || []).length) unsupported.push('markets autocomplete requires visible UI selection'); + const viewResults = Array.from(dialog.querySelectorAll('button')).find((btn) => /^View results$/i.test(clean(btn.innerText || btn.textContent || ''))); + if (viewResults && !viewResults.disabled) { + viewResults.click(); + await new Promise((resolve) => setTimeout(resolve, 2500)); + } + return { ok: true, unsupported }; + })()`; +} diff --git a/clis/wellfound/wellfound.test.js b/clis/wellfound/wellfound.test.js new file mode 100644 index 000000000..af6b4f02f --- /dev/null +++ b/clis/wellfound/wellfound.test.js @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDetailUrl, + assertExpectedApplyTarget, + buildFilterConfig, + inferJobType, + isTopPickHardReject, + normalizeApplyState, + normalizeCompanyStatus, + normalizeDetailRow, + normalizeFilterState, + normalizeJobRow, + normalizeJobSlug, + normalizeApplyMessage, + parseLimit, + topPickScoreMultiplier, + scoreJob, +} from './utils.js'; + +describe('wellfound url helpers', () => { + it('accepts raw slugs, detail urls, and dialog urls', () => { + expect(normalizeJobSlug('3143348-staff-software-engineer')).toBe('3143348-staff-software-engineer'); + expect(normalizeJobSlug('https://wellfound.com/jobs/3143348-staff-software-engineer')).toBe('3143348-staff-software-engineer'); + expect(normalizeJobSlug('https://wellfound.com/jobs?job_listing_slug=3143348-staff-software-engineer')).toBe('3143348-staff-software-engineer'); + expect(buildDetailUrl('3143348-staff-software-engineer')).toBe('https://wellfound.com/jobs/3143348-staff-software-engineer'); + }); + + it('rejects invalid limits without silently clamping user input', () => { + expect(parseLimit(undefined, 5, 20)).toBe(5); + expect(() => parseLimit(0, 5, 20)).toThrow(/between 1 and 20/); + expect(() => parseLimit(21, 5, 20)).toThrow(/between 1 and 20/); + }); +}); + +describe('wellfound row normalization', () => { + it('normalizes a visible job card and scores useful application signals', () => { + const row = normalizeJobRow({ + title: 'AI Full Stack Engineer, Platform', + company: 'ParallelDots', + location: 'Remote only • India', + compensation: '₹45L – ₹1.2 cr • 0.0% – 0.5%', + posted: 'Posted today', + recruiter_active: true, + apply_on_wellfound: true, + company_status: 'actively_hiring', + company_summary: 'Transforming FMCG industry through computer vision', + company_size: '501-1000 Employees', + url: '/jobs/4215150-ai-full-stack-engineer-platform', + company_url: '/company/paralleldots', + raw: 'AI Full Stack Engineer, Platform Remote only • India • ₹45L – ₹1.2 cr • Recruiter recently active Posted today', + }); + + expect(row).toMatchObject({ + rank: 1, + title: 'AI Full Stack Engineer, Platform', + company: 'ParallelDots', + location: 'Remote only • India', + compensation: '₹45L – ₹1.2 cr • 0.0% – 0.5%', + recruiter_active: 'yes', + apply_on_wellfound: 'yes', + company_status: 'actively_hiring', + url: 'https://wellfound.com/jobs/4215150-ai-full-stack-engineer-platform', + company_url: 'https://wellfound.com/company/paralleldots', + }); + expect(row.score).toBeGreaterThan(70); + }); + + it('splits collapsed Wellfound card text from browser extraction', () => { + const row = normalizeJobRow({ + title: 'AI Full Stack Engineer, Platform Remote onlyIndia₹15L – ₹18L', + company: 'ParallelDots', + location: 'AI Full Stack Engineer, Platform Remote onlyIndia₹15L – ₹18L', + compensation: 'AI Full Stack Engineer, Platform Remote onlyIndia₹15L – ₹18L', + posted: 'POSTED TODAY', + recruiter_active: true, + apply_on_wellfound: true, + url: '/jobs/4215150-ai-full-stack-engineer-platform', + raw: 'AI Full Stack Engineer, Platform Remote onlyIndia₹15L – ₹18L Recruiter recently active POSTED TODAY', + }); + + expect(row.title).toBe('AI Full Stack Engineer, Platform'); + expect(row.location).toBe('Remote only • India'); + expect(row.compensation).toBe('₹15L – ₹18L'); + }); + + it('infers flexible work types from titles and details', () => { + expect(inferJobType('Founding Engineer - Part Time (Equity only)')).toBe('Part Time'); + expect(inferJobType('Senior React Contract Developer')).toBe('Contract'); + expect(inferJobType('Freelance AI Engineer')).toBe('Freelance'); + }); + + it('penalizes unpaid or equity-only roles', () => { + const paid = scoreJob({ title: 'AI Engineer', location: 'Remote only', posted: 'Posted today', recruiter_active: true, raw: '₹45L' }); + const unpaid = scoreJob({ title: 'AI Intern', location: 'Remote only', posted: 'Posted today', recruiter_active: true, raw: 'unpaid equity only' }); + expect(paid).toBeGreaterThan(unpaid); + }); + + it('down-ranks internships and very low compensation for AI/full-stack remote shortlists', () => { + const senior = scoreJob({ title: 'Full-Stack AI Engineer', location: 'Remote only India', posted: 'Posted today', recruiter_active: true, raw: '₹45L – ₹1.2 cr React Node AI' }); + const intern = scoreJob({ title: 'Full Stack AI Engineer Intern', location: 'Remote only India', posted: 'Posted today', recruiter_active: true, raw: '₹8,000 – ₹15,000 Internship' }); + const low = scoreJob({ title: 'Full-Stack Engineer', location: 'Remote only India', posted: 'Posted today', recruiter_active: true, raw: '₹3L – ₹4L' }); + + expect(senior).toBeGreaterThan(intern); + expect(senior).toBeGreaterThan(low); + }); + + it('hard rejects disallowed and Python-only roles in top-picks filtering', () => { + expect(isTopPickHardReject({ + title: 'Principal Backend Engineer (Go)', + raw: 'Go distributed systems · Kubernetes', + company_summary: 'building GTM context graph for revenue teams', + })).toBe(true); + + expect(isTopPickHardReject({ + title: 'Senior Python Backend Developer', + raw: 'Python backend role building APIs for internal reporting', + company_summary: 'Django + PostgreSQL', + })).toBe(true); + + expect(isTopPickHardReject({ + title: 'AI Full Stack Engineer', + raw: 'TypeScript · Node.js · React · MCP', + company_summary: 'AI workflow platform', + })).toBe(false); + }); + + it('hard rejects jobs from closed companies', () => { + expect(isTopPickHardReject({ + title: 'AI Full Stack Engineer', + raw: 'TypeScript · Node.js · React · MCP', + company_summary: 'AI workflow platform', + company_status: 'closed', + })).toBe(true); + }); + + it('boosts top-picks for AI + TypeScript/React/Next alignment', () => { + const aligned = topPickScoreMultiplier({ + title: 'AI Product Engineer', + raw: 'MCP AI platform with React and TypeScript', + company_summary: 'Workflow automation', + location: 'Remote only', + }); + const generic = topPickScoreMultiplier({ + title: 'Backend Engineer', + raw: 'Backend Java APIs', + company_summary: 'General services', + location: 'Remote only', + }); + + expect(aligned).toBeGreaterThan(generic); + }); +}); + +describe('wellfound detail normalization', () => { + it('normalizes detail dialog payload', () => { + const row = normalizeDetailRow({ + title: 'Staff Software Engineer', + company: 'Allminds', + company_url: '/company/allminds-2', + details: '$45k – $80k • 0.005% – 0.05% | Remote (India) | 4 years of exp | Full Time', + compensation: '$45k – $80k • 0.005% – 0.05%', + location: 'Remote (India)', + experience: '4 years of exp', + job_type: 'Full Time', + posted: 'Reposted: 2 weeks ago', + recruiter_active: true, + remote_policy: 'Remote only', + company_location: 'San Francisco', + visa_sponsorship: 'Not Available', + preferred_timezones: 'Eastern Time, Indochina Time', + collaboration_hours: '8:00 AM - 6:00 PM Indochina Time', + relocation: 'Not Allowed', + company_status: 'Closed', + skills: 'Node.js Firebase React.js', + company_size: '11-50', + company_industries: 'Healthcare; Artificial Intelligence', + description: 'Build mental healthcare software.', + url: 'https://wellfound.com/jobs?job_listing_slug=3143348-staff-software-engineer', + }); + + expect(row).toMatchObject({ + title: 'Staff Software Engineer', + company: 'Allminds', + location: 'Remote (India)', + compensation: '$45k – $80k • 0.005% – 0.05%', + job_type: 'Full Time', + experience: '4 years of exp', + posted: 'Reposted 2 weeks ago', + recruiter_active: 'yes', + remote_policy: 'Remote only', + company_status: 'closed', + company_url: 'https://wellfound.com/company/allminds-2', + }); + }); + + it('normalizes company status badges from Wellfound pages', () => { + expect(normalizeCompanyStatus('Closed')).toBe('closed'); + expect(normalizeCompanyStatus('Actively Hiring')).toBe('actively_hiring'); + expect(normalizeCompanyStatus('')).toBe(''); + }); +}); + +describe('wellfound apply helpers', () => { + it('normalizes Wellfound-native and external apply states', () => { + const multilineMessage = [ + 'I build agentic workflow products.', + 'Portfolio: https://example.com', + 'GitHub: https://github.com/example', + ].join('\n'); + + expect(normalizeApplyState({ + status: 'ready', + apply_mode: 'wellfound', + title: 'AI Agents Engineer', + company: 'Memorang', + message: multilineMessage, + message_filled: 'yes', + message_length: multilineMessage.length, + url: 'https://wellfound.com/jobs?job_listing_slug=4287201-ai-agents-engineer', + })).toMatchObject({ + status: 'ready', + apply_mode: 'wellfound', + title: 'AI Agents Engineer', + company: 'Memorang', + message: multilineMessage, + message_filled: 'yes', + message_length: String(multilineMessage.length), + }); + + expect(normalizeApplyState({ + apply_mode: 'company_website', + title: 'External Role', + company: 'Example', + external_apply_url: 'https://example.com/apply', + url: 'https://wellfound.com/jobs?job_listing_slug=1-external-role', + }).external_apply_url).toBe('https://example.com/apply'); + }); + + it('preserves intentional line breaks in apply messages', () => { + expect(normalizeApplyMessage(' Why fit \n\n Portfolio: https://example.com \n GitHub: https://github.com/example ')).toBe([ + 'Why fit', + '', + 'Portfolio: https://example.com', + 'GitHub: https://github.com/example', + ].join('\n')); + }); + + it('guards apply target by exact expected title and company', () => { + const state = normalizeApplyState({ + title: 'AI Agents Engineer', + company: 'Memorang', + url: 'https://wellfound.com/jobs?job_listing_slug=4287201-ai-agents-engineer', + }); + expect(() => assertExpectedApplyTarget(state, 'AI Agents Engineer', 'Memorang')).not.toThrow(); + expect(() => assertExpectedApplyTarget(state, 'Backend Engineer', 'Memorang')).toThrow(/expected title/); + expect(() => assertExpectedApplyTarget(state, 'AI Agents Engineer', 'OtherCo')).toThrow(/expected company/); + }); +}); + +describe('wellfound filters helpers', () => { + it('builds the AI full-stack remote preset without requiring UI mutation', () => { + const config = buildFilterConfig({ preset: 'ai-fullstack-remote' }); + expect(config.jobTypes).toEqual(['Full Time', 'Contract']); + expect(config.skills).toContain('TypeScript'); + expect(config.includedKeywords.join(' ')).toMatch(/AI|agentic|Gen AI/); + expect(config.hideCompanyApply).toBe(true); + }); + + it('normalizes filter readback rows', () => { + const row = normalizeFilterState({ + status: 'dry-run', + results: '111 results', + role: 'Full-Stack Engineer', + remote: 'Remote only', + region: 'Asia', + job_types: ['Full Time', 'Contract'], + company_size: ['1-10 employees', '11-50 employees'], + investment_stage: ['Seed Stage'], + remote_culture: 'yes', + responsiveness: 'yes', + visa_sponsorship: 'no', + hide_company_apply: 'visible', + url: 'https://wellfound.com/jobs', + }); + + expect(row).toMatchObject({ + status: 'dry-run', + results: '111 results', + role: 'Full-Stack Engineer', + remote: 'Remote only', + region: 'Asia', + job_types: 'Full Time; Contract', + hide_company_apply: 'visible', + }); + }); +}); diff --git a/docs/adapters/browser/wellfound.md b/docs/adapters/browser/wellfound.md new file mode 100644 index 000000000..ea19facd1 --- /dev/null +++ b/docs/adapters/browser/wellfound.md @@ -0,0 +1,69 @@ +# Wellfound + +**Mode**: 🔐 Browser · **Domain**: `wellfound.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli wellfound jobs` | Read visible Browse jobs from the current saved/filtered Wellfound search | +| `opencli wellfound top-picks` | Score the current Browse results and return the best daily application targets | +| `opencli wellfound job-detail ` | Read one job detail dialog with description, skills, remote policy, and company metadata | +| `opencli wellfound filters` | Read or update visible Browse filters; updates require `--execute` | +| `opencli wellfound apply ` | Dry-run or submit a Wellfound-native application; company-website applies are detected | + +## Usage Examples + +```bash +# Read the current Browse all search +opencli wellfound jobs --limit 10 -f json + +# Daily shortlist from the current saved filters +opencli wellfound top-picks --limit 5 --pool 30 -f json + +# Alias for top-picks +opencli wellfound daily -f json + +# Read a job returned by jobs/top-picks +opencli wellfound job-detail 4215150-ai-full-stack-engineer-platform -f json +opencli wellfound job-detail "https://wellfound.com/jobs/4215150-ai-full-stack-engineer-platform" -f json + +# Inspect current filters +opencli wellfound filters -f json + +# Preview AI/full-stack remote filters without changing the UI +opencli wellfound filters --preset ai-fullstack-remote -f json + +# Apply supported checkbox/keyword filters; autocomplete fields may still need manual UI selection +opencli wellfound filters --preset ai-fullstack-remote --execute -f json + +# Dry-run an application first +opencli wellfound apply 4215150-ai-full-stack-engineer-platform \ + --expected-title "AI Full Stack Engineer, Platform" \ + --expected-company "ParallelDots" \ + --message "I build full-stack AI workflow tools with TypeScript, React, Node.js, and agentic automation systems." \ + -f json + +# Submit only after the dry-run row is correct +opencli wellfound apply 4215150-ai-full-stack-engineer-platform \ + --expected-title "AI Full Stack Engineer, Platform" \ + --expected-company "ParallelDots" \ + --message "I build full-stack AI workflow tools with TypeScript, React, Node.js, and agentic automation systems." \ + --execute \ + -f json +``` + +## Notes + +- The adapter reads the logged-in Wellfound jobs UI. It does not save jobs or hide jobs. Filter updates and Wellfound-native applications are guarded by explicit `--execute`. +- Mutating commands are guarded. `filters` only changes the UI with `--execute`; `apply` only clicks the final Wellfound-native Apply button with `--execute`. +- Set role, remote-only, region, compensation, skills, markets, job types, experience, keywords, company size, stage, responsiveness, and visa filters in Wellfound once; `jobs` and `top-picks` reuse the resulting Browse search. +- `filters --preset ai-fullstack-remote` encodes a TypeScript/React/Node/full-stack AI preference profile. Wellfound skills and markets use autocomplete controls; the command reports unsupported controls when they cannot be selected safely through the visible UI. +- `top-picks` is a local ranking over visible rows. It prioritizes remote roles, fresh postings, recruiter activity, Wellfound-native apply, and AI/full-stack/agentic alignment. It hard-rejects disallowed stack families (Go, PHP, Ruby, Scala, Salesforce, etc.) and Python-only roles, and it penalizes unpaid or equity-only roles. +- `job-detail` accepts a raw Wellfound id-slug, a `/jobs/` URL, or a `/jobs?job_listing_slug=` URL. +- `apply` distinguishes `wellfound`, `company_website`, `already_applied`, and `unknown` apply modes. It does not submit external company forms; use the returned `external_apply_url` for a separate reviewed workflow. + +## Prerequisites + +- Chrome running and logged into Wellfound. +- [Browser Bridge extension](/guide/browser-bridge) installed and connected. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 999e916cc..96b57600b 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -28,6 +28,7 @@ Run `opencli list` for the live registry. | **[coupang](./browser/coupang.md)** | `search` `product` `add-to-cart` | 🔐 Browser | | **[boss](./browser/boss.md)** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 🔐 Browser | | **[51job](./browser/51job.md)** | `search` `hot` `detail` `company` | 🔐 Browser | +| **[wellfound](./browser/wellfound.md)** | `jobs` `top-picks` `daily` `job-detail` `filters` `apply` | 🔐 Browser | | **[powerchina](./browser/powerchina.md)** | `search` | 🔐 Browser | | **[ctrip](./browser/ctrip.md)** | `search` `hotel-suggest` | 🌐 Public | | **[booking](./browser/booking.md)** | `search` | 🌐 Public |