linkedin skills

This commit is contained in:
xx254
2026-04-01 21:59:13 +08:00
commit 743a87d9cf
33 changed files with 3372 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { readJson, writeJson, ensureDir } = require("./lib/io");
const ROOT = path.resolve(__dirname, "..");
const settingsPath = path.join(ROOT, "state", "linkedin-settings.json");
const statePath = path.join(ROOT, "state", "linkedin-system-state.json");
const defaultSettingsPath = path.join(ROOT, "config", "default-settings.json");
const contextDir = path.join(ROOT, "context");
const aboutMePath = path.join(contextDir, "about-me.md");
const brandVoicePath = path.join(contextDir, "brand-voice.md");
const defaultSettings = readJson(defaultSettingsPath, {});
const currentSettings = readJson(settingsPath, null);
const currentState = readJson(statePath, null);
if (!currentSettings) {
writeJson(settingsPath, defaultSettings);
}
if (!currentState) {
writeJson(statePath, {
version: "1.0.0",
firstRunCompleted: false,
lastSuccessfulRunAt: null,
lastLeadFilterRunAt: null,
lastDraftRunAt: null,
lastReplyRunAt: null,
leadRegistry: {},
outreachRegistry: {},
weeklyMetrics: {
weekKey: null,
leads_processed: 0,
qualified_count: 0,
drafted_count: 0,
reply_drafts_count: 0,
replied_count: 0,
rejected_count: 0,
score3_conversion_count: 0
}
});
}
ensureDir(path.join(ROOT, "output"));
ensureDir(path.join(ROOT, "reports"));
ensureDir(contextDir);
if (!fs.existsSync(aboutMePath)) {
fs.writeFileSync(
aboutMePath,
"# ICP Profile\n\n## Target Industries\n- Software Development\n- Marketing Services\n\n## Target Seniority\n- Manager\n- Director\n\n## Target Geographies\n- United States\n\n## Offer Summary\nDescribe your offer here.\n",
"utf8"
);
}
if (!fs.existsSync(brandVoicePath)) {
fs.writeFileSync(
brandVoicePath,
"# Brand Voice Profile\n\n## Language\nDefault: English\n\n## Outreach Goal\nStart relevant conversations with qualified leads.\n\n## Formatting Rules\n- Keep first-touch message concise.\n- Max one question.\n- No generic opener.\n",
"utf8"
);
}
console.log(JSON.stringify({
ok: true,
settingsInitialized: !currentSettings,
stateInitialized: !currentState,
root: ROOT
}, null, 2));
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Verifies repo layout, state files, and context after bootstrap.
* Run from repo root: node scripts/check-system.js
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..");
const required = [
"state/linkedin-settings.json",
"state/linkedin-system-state.json",
"config/default-settings.json",
"context/about-me.md",
"context/brand-voice.md",
"scripts/bootstrap-system.js",
"scripts/run-lead-filter.js",
"scripts/run-draft-outreach.js",
"scripts/run-reply-handler.js",
"scripts/record-outcome.js",
"scripts/generate-weekly-report.js"
];
const issues = [];
for (const rel of required) {
const p = path.join(ROOT, rel);
if (!fs.existsSync(p)) {
issues.push(`missing: ${rel}`);
}
}
let settings;
let state;
try {
settings = JSON.parse(fs.readFileSync(path.join(ROOT, "state", "linkedin-settings.json"), "utf8"));
} catch (e) {
issues.push("state/linkedin-settings.json is not valid JSON");
}
try {
state = JSON.parse(fs.readFileSync(path.join(ROOT, "state", "linkedin-system-state.json"), "utf8"));
} catch (e) {
issues.push("state/linkedin-system-state.json is not valid JSON");
}
if (settings && settings.version !== "1.0.0") {
issues.push("linkedin-settings.json version unexpected (expected 1.0.0)");
}
if (state && state.version !== "1.0.0") {
issues.push("linkedin-system-state.json version unexpected (expected 1.0.0)");
}
const out = {
ok: issues.length === 0,
root: ROOT,
node: process.version,
issues
};
console.log(JSON.stringify(out, null, 2));
process.exit(issues.length ? 1 : 0);
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
const path = require("path");
const { readJson, writeText, ensureDir } = require("./lib/io");
const ROOT = path.resolve(__dirname, "..");
const state = readJson(path.join(ROOT, "state", "linkedin-system-state.json"), null);
if (!state) {
console.error("Missing state file.");
process.exit(1);
}
const weekKey = state.weeklyMetrics?.weekKey || "unknown-week";
const metrics = state.weeklyMetrics || {};
const reportDir = path.join(ROOT, "reports");
ensureDir(reportDir);
const conversionRate = metrics.qualified_count
? ((metrics.score3_conversion_count / metrics.qualified_count) * 100).toFixed(2)
: "0.00";
const content = `# Weekly Outreach Report (${weekKey})
## Core Metrics
- leads_processed: ${metrics.leads_processed || 0}
- qualified_count: ${metrics.qualified_count || 0}
- drafted_count: ${metrics.drafted_count || 0}
- reply_drafts_count: ${metrics.reply_drafts_count || 0}
- replied_count: ${metrics.replied_count || 0}
- rejected_count: ${metrics.rejected_count || 0}
- score3_conversion_count: ${metrics.score3_conversion_count || 0}
- score3_conversion_rate: ${conversionRate}%
## System Timestamps
- last_lead_filter_run: ${state.lastLeadFilterRunAt || "n/a"}
- last_draft_run: ${state.lastDraftRunAt || "n/a"}
- last_reply_run: ${state.lastReplyRunAt || "n/a"}
- last_successful_run: ${state.lastSuccessfulRunAt || "n/a"}
## Notes
- Use this report in /campaign-retro for lever diagnosis.
- If drafted_count grows but replied_count stays flat, focus on voice and signal quality.
`;
const reportPath = path.join(reportDir, `weekly-summary_${weekKey}.md`);
writeText(reportPath, content);
console.log(JSON.stringify({ output: reportPath }, null, 2));
+85
View File
@@ -0,0 +1,85 @@
function parseCsv(content) {
const rows = [];
let row = [];
let field = "";
let inQuotes = false;
// Character-level parser to correctly handle commas/newlines inside quotes.
for (let i = 0; i < content.length; i += 1) {
const char = content[i];
const next = content[i + 1];
if (char === "\"") {
if (inQuotes && next === "\"") {
field += "\"";
i += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (char === "," && !inQuotes) {
row.push(field);
field = "";
continue;
}
if ((char === "\n" || char === "\r") && !inQuotes) {
if (char === "\r" && next === "\n") {
i += 1;
}
row.push(field);
field = "";
if (row.length > 1 || (row.length === 1 && row[0] !== "")) {
rows.push(row);
}
row = [];
continue;
}
field += char;
}
if (field.length || row.length) {
row.push(field);
rows.push(row);
}
if (!rows.length) {
return { headers: [], records: [] };
}
const headers = rows[0].map((h) => h.trim());
const records = rows.slice(1).map((r) => {
const obj = {};
headers.forEach((h, idx) => {
obj[h] = (r[idx] || "").trim();
});
return obj;
});
return { headers, records };
}
function escapeCsvValue(value) {
const s = String(value ?? "");
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, "\"\"")}"`;
}
return s;
}
function toCsv(headers, records) {
const lines = [headers.map(escapeCsvValue).join(",")];
records.forEach((record) => {
const row = headers.map((h) => escapeCsvValue(record[h] ?? ""));
lines.push(row.join(","));
});
return `${lines.join("\n")}\n`;
}
module.exports = {
parseCsv,
toCsv
};
+56
View File
@@ -0,0 +1,56 @@
const fs = require("fs");
const path = require("path");
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) {
return fallback;
}
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, data) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
}
function readText(filePath, fallback = "") {
if (!fs.existsSync(filePath)) {
return fallback;
}
return fs.readFileSync(filePath, "utf8");
}
function writeText(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, "utf8");
}
function formatDateKey(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}_${m}_${d}`;
}
function getWeekKey(date = new Date()) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
module.exports = {
ensureDir,
readJson,
writeJson,
readText,
writeText,
formatDateKey,
getWeekKey
};
+57
View File
@@ -0,0 +1,57 @@
/**
* Heuristic classification for prospect replies (matches reply-handler SKILL types).
*/
function normalize(text) {
return String(text || "")
.trim()
.toLowerCase();
}
function classifyProspectReply(text) {
const t = normalize(text);
if (!t) {
return { type: "irrelevant", confidence: "low" };
}
if (
/\b(unsubscribe|remove me|stop|not interested|no thanks|don't contact)\b/.test(t)
) {
return { type: "not_interested", confidence: "high" };
}
if (/\b(we use|using|already have|competitor|vs\.?|compared to)\b/.test(t)) {
return { type: "competitor_mention", confidence: "medium" };
}
if (/\?|how much|pricing|cost|what is|can you|could you|tell me how/.test(t)) {
return { type: "question", confidence: "medium" };
}
if (
/\b(too busy|not the right time|maybe later|not now|circle back|reach out later)\b/.test(
t
)
) {
return { type: "hesitant", confidence: "medium" };
}
if (
/\b(tell me more|sounds good|interested|curious|love to|let's|schedule|book)\b/.test(
t
)
) {
return { type: "interested", confidence: "medium" };
}
if (/\b(interesting|cool|nice|thanks)\b/.test(t) && t.length < 120) {
return { type: "positive_but_vague", confidence: "low" };
}
return { type: "irrelevant", confidence: "low" };
}
module.exports = {
classifyProspectReply,
normalize
};
+44
View File
@@ -0,0 +1,44 @@
const { getWeekKey } = require("./io");
const DEFAULT_KEYS = [
"leads_processed",
"qualified_count",
"drafted_count",
"reply_drafts_count",
"replied_count",
"rejected_count",
"score3_conversion_count"
];
/**
* Ensures weeklyMetrics exists, resets when ISO week changes, backfills missing counters.
*/
function ensureWeekMetrics(state) {
const weekKey = getWeekKey();
const wm = state.weeklyMetrics;
if (!wm || wm.weekKey !== weekKey) {
state.weeklyMetrics = {
weekKey,
leads_processed: 0,
qualified_count: 0,
drafted_count: 0,
reply_drafts_count: 0,
replied_count: 0,
rejected_count: 0,
score3_conversion_count: 0
};
return state;
}
DEFAULT_KEYS.forEach((k) => {
if (typeof state.weeklyMetrics[k] !== "number" || Number.isNaN(state.weeklyMetrics[k])) {
state.weeklyMetrics[k] = 0;
}
});
return state;
}
module.exports = {
ensureWeekMetrics
};
+14
View File
@@ -0,0 +1,14 @@
{
"name": "linkedin-skills-system",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"bootstrap": "node bootstrap-system.js",
"lead-filter": "node run-lead-filter.js ../sample_contacts.csv",
"draft": "node run-draft-outreach.js",
"reply": "node run-reply-handler.js",
"outcome": "node record-outcome.js",
"weekly-report": "node generate-weekly-report.js"
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
const path = require("path");
const { readJson, writeJson, getWeekKey } = require("./lib/io");
const { ensureWeekMetrics } = require("./lib/week-metrics");
const ROOT = path.resolve(__dirname, "..");
function getArgValue(flag) {
const index = process.argv.findIndex((a) => a === flag);
if (index === -1 || index === process.argv.length - 1) {
return "";
}
return process.argv[index + 1];
}
const leadKeyArg = getArgValue("--lead-key");
const outcome = getArgValue("--outcome");
const validOutcomes = ["positive", "rejected", "not_interested", "booked"];
if (!leadKeyArg || !outcome) {
console.error(
"Usage: node scripts/record-outcome.js --lead-key <key> --outcome <positive|rejected|not_interested|booked>"
);
process.exit(1);
}
if (!validOutcomes.includes(outcome)) {
console.error(`Invalid outcome. Use one of: ${validOutcomes.join(", ")}`);
process.exit(1);
}
const state = readJson(path.join(ROOT, "state", "linkedin-system-state.json"), null);
if (!state) {
console.error("Missing state. Run: node scripts/bootstrap-system.js");
process.exit(1);
}
ensureWeekMetrics(state);
const leadMeta = state.leadRegistry && state.leadRegistry[leadKeyArg];
const score = leadMeta && typeof leadMeta.score === "number" ? leadMeta.score : 0;
const now = new Date().toISOString();
state.outreachRegistry = state.outreachRegistry || {};
const prev = state.outreachRegistry[leadKeyArg] || {};
if (outcome === "rejected" || outcome === "not_interested") {
state.weeklyMetrics.rejected_count += 1;
state.outreachRegistry[leadKeyArg] = {
...prev,
lastOutcomeAt: now,
lastOutcome: outcome,
lastDraftStatus: "closed_negative"
};
} else if (outcome === "positive") {
state.weeklyMetrics.replied_count += 1;
state.outreachRegistry[leadKeyArg] = {
...prev,
lastOutcomeAt: now,
lastOutcome: "positive",
lastDraftStatus: "replied_positive"
};
if (score === 3) {
state.weeklyMetrics.score3_conversion_count += 1;
}
} else if (outcome === "booked") {
state.weeklyMetrics.replied_count += 1;
state.outreachRegistry[leadKeyArg] = {
...prev,
lastOutcomeAt: now,
lastOutcome: "booked",
lastDraftStatus: "booked"
};
if (score === 3) {
state.weeklyMetrics.score3_conversion_count += 1;
}
}
state.lastSuccessfulRunAt = now;
writeJson(path.join(ROOT, "state", "linkedin-system-state.json"), state);
console.log(
JSON.stringify(
{
ok: true,
lead_key: leadKeyArg,
outcome,
score_used: score,
weeklyMetrics: state.weeklyMetrics
},
null,
2
)
);
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
const path = require("path");
const {
readJson,
readText,
writeJson,
ensureDir,
formatDateKey,
getWeekKey
} = require("./lib/io");
const ROOT = path.resolve(__dirname, "..");
const args = process.argv.slice(2);
function getArgValue(flag) {
const index = args.findIndex((a) => a === flag);
if (index === -1 || index === args.length - 1) {
return "";
}
return args[index + 1];
}
const registryPath = getArgValue("--registry");
const leadKeyArg = getArgValue("--lead-key");
const nameArg = getArgValue("--name");
const companyArg = getArgValue("--company");
const titleArg = getArgValue("--title");
const linkedinArg = getArgValue("--linkedin");
const signalArg = getArgValue("--signal");
const settings = readJson(path.join(ROOT, "state", "linkedin-settings.json"), null);
const state = readJson(path.join(ROOT, "state", "linkedin-system-state.json"), null);
const voiceProfile = readText(path.join(ROOT, "context", "brand-voice.md"), "");
if (!settings || !state) {
console.error("Missing state files. Run: node scripts/bootstrap-system.js");
process.exit(1);
}
if (!voiceProfile.trim()) {
console.error("Missing context/brand-voice.md. Run calibrate-voice first.");
process.exit(1);
}
function lower(v) {
return String(v || "").trim().toLowerCase();
}
function canonicalLeadKey(name, company, url) {
const urlPart = String(url || "").trim() || "missing_url";
return lower(`${String(name || "").trim()}|${String(company || "").trim()}|${urlPart}`);
}
function selectLeadFromRegistry() {
if (!registryPath) return null;
const fullPath = path.resolve(process.cwd(), registryPath);
const registry = readJson(fullPath, null);
if (!registry || !Array.isArray(registry.leads)) return null;
let lead = null;
if (leadKeyArg) {
lead = registry.leads.find((item) => item.lead_key === leadKeyArg);
}
if (!lead) {
const minScore = settings.qualification.minScoreToPrioritize || 3;
lead = registry.leads.find((item) => item.qualification_status === "qualified" && item.score >= minScore);
}
if (!lead) {
lead = registry.leads.find((item) => item.qualification_status === "qualified");
}
return lead;
}
let lead = selectLeadFromRegistry();
if (!lead) {
const manualKey = canonicalLeadKey(nameArg, companyArg, linkedinArg);
if (nameArg && companyArg && titleArg) {
lead = {
lead_key: manualKey,
name: nameArg,
company: companyArg,
title: titleArg,
linkedin_url: linkedinArg || "",
signal_type: signalArg || "none",
score: 1,
qualification_status: "qualified",
reason: "manual lead input"
};
}
}
if (!lead) {
console.error("No lead found. Provide --registry or manual --name --company --title.");
process.exit(1);
}
if (lead.qualification_status !== "qualified") {
console.error(`Lead is not qualified (${lead.qualification_status}).`);
process.exit(1);
}
const maxChars = settings.outreach.maxChars || 300;
const maxQuestions = settings.outreach.maxQuestions || 1;
const firstMessageNoPitch = settings.outreach.firstMessageNoPitch !== false;
const recentSignal = lead.signal_type && lead.signal_type !== "none"
? lead.signal_type
: "their current role";
// Keep generation deterministic and editable in skill prompts.
function draftFromLead(record, angle = "default") {
const opener = angle === "question"
? `${record.name.split(" ")[0]}, saw your work at ${record.company} around ${recentSignal}.`
: `${record.name.split(" ")[0]}, noticed your work at ${record.company} and your focus on ${record.title}.`;
const bridge = firstMessageNoPitch
? "Curious how your team is approaching priorities this quarter."
: "I think there may be a relevant fit worth comparing.";
const question = maxQuestions > 0
? "Open to a quick exchange?"
: "";
let text = `${opener} ${bridge} ${question}`.replace(/\s+/g, " ").trim();
if (text.length > maxChars) {
text = text.slice(0, maxChars - 1).trimEnd() + ".";
}
return text;
}
const draft = draftFromLead(lead, "default");
const variant = draftFromLead(lead, "question");
const now = new Date().toISOString();
const dateKey = formatDateKey();
const outDir = path.join(ROOT, "output", `outreach_${dateKey}`);
ensureDir(outDir);
const artifact = {
lead_key: lead.lead_key,
generated_at: now,
source_registry: registryPath ? path.resolve(process.cwd(), registryPath) : "manual",
draft,
variant,
signal_used: recentSignal,
char_count: draft.length,
voice_check: "references real role/company context and keeps first-touch tone concise",
watch_for: "replace generic signal with specific post/job change when available",
status: "drafted"
};
const safeLeadKey = lead.lead_key.replace(/[^a-z0-9_|-]/gi, "_");
const artifactPath = path.join(outDir, `draft_${safeLeadKey}_${dateKey}.json`);
writeJson(artifactPath, artifact);
const weekKey = getWeekKey();
if (state.weeklyMetrics.weekKey !== weekKey) {
state.weeklyMetrics = {
weekKey,
leads_processed: 0,
qualified_count: 0,
drafted_count: 0,
reply_drafts_count: 0,
replied_count: 0,
rejected_count: 0,
score3_conversion_count: 0
};
}
state.firstRunCompleted = true;
state.lastDraftRunAt = now;
state.lastSuccessfulRunAt = now;
state.weeklyMetrics.drafted_count += 1;
state.outreachRegistry = state.outreachRegistry || {};
state.outreachRegistry[lead.lead_key] = {
lastDraftAt: now,
lastDraftStatus: "drafted",
source: artifact.source_registry,
lastSignalType: recentSignal
};
writeJson(path.join(ROOT, "state", "linkedin-system-state.json"), state);
console.log(JSON.stringify({
output: artifactPath,
lead_key: lead.lead_key,
draft_chars: draft.length,
score: lead.score
}, null, 2));
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { parseCsv, toCsv } = require("./lib/csv");
const {
readJson,
writeJson,
writeText,
ensureDir,
formatDateKey,
getWeekKey
} = require("./lib/io");
const ROOT = path.resolve(__dirname, "..");
const args = process.argv.slice(2);
const inputFile = args[0] ? path.resolve(process.cwd(), args[0]) : path.join(ROOT, "sample_contacts.csv");
const settings = readJson(path.join(ROOT, "state", "linkedin-settings.json"), null);
const systemState = readJson(path.join(ROOT, "state", "linkedin-system-state.json"), null);
const competitorProfile = readJson(path.join(ROOT, "state", "competitor-profile.json"), null);
if (!settings || !systemState) {
console.error("Missing state files. Run: node scripts/bootstrap-system.js");
process.exit(1);
}
if (!fs.existsSync(inputFile)) {
console.error(`Input file not found: ${inputFile}`);
process.exit(1);
}
const csvRaw = fs.readFileSync(inputFile, "utf8");
const { headers, records } = parseCsv(csvRaw);
if (!headers.length || !records.length) {
console.error("CSV is empty or invalid.");
process.exit(1);
}
const dateKey = formatDateKey();
const runAt = new Date().toISOString();
const outDir = path.join(ROOT, "output", `leads_${dateKey}`);
ensureDir(outDir);
const qualified = [];
const competitors = [];
const nonDecisionMakers = [];
const b2cFocused = [];
const noOutboundIntent = [];
const violations = [];
const dedupeMap = new Map();
const concerns = [];
let duplicateCount = 0;
function pick(record, keys) {
for (const key of keys) {
if (record[key] && record[key].trim()) {
return record[key].trim();
}
}
return "";
}
function normalize(text) {
return String(text || "").trim();
}
function lower(text) {
return normalize(text).toLowerCase();
}
function leadKey(name, company, linkedinUrl) {
const urlPart = normalize(linkedinUrl) || "missing_url";
return lower(`${normalize(name)}|${normalize(company)}|${urlPart}`);
}
function detectSignal(record) {
const src = lower(record["Source Post"] || "");
const imported = normalize(record["Imported At"]);
if (src) {
return "recent_post";
}
if (imported) {
return "recent_import";
}
return "";
}
function scoreFromSignals(signalType, title) {
const t = lower(title);
const hasStrongRole = /(vp|vice president|head|director|chief|founder|owner|co-founder|ceo|cmo)/.test(t);
if (signalType === "recent_post" && hasStrongRole) {
return 3;
}
if (signalType && hasStrongRole) {
return 2;
}
return 1;
}
function disqualifyCategory(record, title, company, industry, about) {
const t = lower(title);
const c = lower(company);
const i = lower(industry);
const a = lower(about);
const roleExcludes = (settings.targetMarket.alwaysExcludeRoles || []).map((v) => lower(v));
const companyExcludes = (settings.targetMarket.alwaysExcludeCompanyTypes || []).map((v) => lower(v));
const competitorCompanyKw = (competitorProfile ? competitorProfile.companyKeywords || [] : []).map((v) => lower(v));
const competitorIndustryKw = (competitorProfile ? competitorProfile.industryKeywords || [] : []).map((v) => lower(v));
const competitorServiceKw = (competitorProfile ? competitorProfile.serviceKeywords || [] : []).map((v) => lower(v));
const allCompetitorCompanyKw = [...new Set([...companyExcludes, ...competitorCompanyKw])];
if (
/student|intern|assistant|junior/.test(t) ||
roleExcludes.some((r) => r && t.includes(r))
) {
return "non_decision_makers";
}
if (
allCompetitorCompanyKw.some((k) => k && c.includes(k)) ||
competitorIndustryKw.some((k) => k && i.includes(k)) ||
competitorServiceKw.some((k) => k && a.includes(k))
) {
return "competitors";
}
if (/consumer|retail|b2c/.test(i) && !/b2b|software|saas|services/.test(i)) {
return "b2c_focused";
}
if (/engineer|developer|support|it admin|back office/.test(t) && !/head|director|vp|chief/.test(t)) {
return "no_outbound_intent";
}
return "";
}
const targetIndustries = settings.targetMarket.industries || [];
records.forEach((record) => {
const first = pick(record, ["First Name", "FirstName", "Name"]);
const last = pick(record, ["Last Name", "LastName"]);
const name = normalize(`${first} ${last}`.trim() || pick(record, ["Name"]));
const title = pick(record, ["Job Title", "Position", "Title"]);
const company = pick(record, ["Company"]);
const industry = pick(record, ["Industry"]);
const location = pick(record, ["Location", "Geography"]);
const linkedinUrl = pick(record, ["LinkedIn URL", "Linkedin URL", "LinkedIn"]);
const about = pick(record, ["About", "Bio", "Summary", "Company Description", "Description"]);
const signalType = detectSignal(record);
const missingRequired = [];
if (!name) missingRequired.push("name");
if (!title) missingRequired.push("title");
if (!company) missingRequired.push("company");
const key = leadKey(name, company, linkedinUrl);
if (missingRequired.length) {
violations.push({
lead_key: key,
Name: name,
Title: title,
Company: company,
"LinkedIn URL": linkedinUrl,
Violation: `missing required: ${missingRequired.join(", ")}`
});
return;
}
const category = disqualifyCategory(record, title, company, industry, about);
const industryOk = !targetIndustries.length || targetIndustries.some((t) => lower(industry).includes(lower(t)));
const geoTargets = settings.targetMarket.geographies || [];
const geoOk = !geoTargets.length || geoTargets.some((g) => lower(location).includes(lower(g)));
const shouldDisqualifyForTargeting = !industryOk || !geoOk;
let qualificationStatus = "qualified";
let reason = "meets ICP";
let score = scoreFromSignals(signalType, title);
if (category) {
qualificationStatus = category;
reason = `disqualified: ${category}`;
score = 0;
} else if (shouldDisqualifyForTargeting) {
qualificationStatus = "no_outbound_intent";
reason = "industry/geography mismatch";
score = 0;
} else if (!signalType) {
reason = "qualified, no clear signal";
score = 1;
}
const leadRecord = {
lead_key: key,
name,
title,
company,
linkedin_url: linkedinUrl,
location,
industry,
qualification_status: qualificationStatus,
score,
signal_type: signalType || "none",
reason,
last_scored_at: runAt,
source_file: inputFile
};
const existing = dedupeMap.get(key);
if (existing) {
duplicateCount += 1;
if (leadRecord.score > existing.score) {
dedupeMap.set(key, leadRecord);
}
} else {
dedupeMap.set(key, leadRecord);
}
});
if (duplicateCount) {
concerns.push(`duplicate lead rows merged: ${duplicateCount}`);
}
for (const leadRecord of dedupeMap.values()) {
const rowBase = {
Name: leadRecord.name,
Title: leadRecord.title,
Company: leadRecord.company,
"LinkedIn URL": leadRecord.linkedin_url,
"Signal Type": leadRecord.signal_type,
Score: leadRecord.score,
"Reason Qualified": leadRecord.reason,
"Reason Disqualified": leadRecord.reason
};
if (leadRecord.qualification_status === "qualified") {
qualified.push(rowBase);
} else if (leadRecord.qualification_status === "competitors") {
competitors.push(rowBase);
} else if (leadRecord.qualification_status === "non_decision_makers") {
nonDecisionMakers.push(rowBase);
} else if (leadRecord.qualification_status === "b2c_focused") {
b2cFocused.push(rowBase);
} else {
noOutboundIntent.push(rowBase);
}
}
const qualifiedHeaders = ["Name", "Title", "Company", "LinkedIn URL", "Signal Type", "Score", "Reason Qualified"];
const disqualHeaders = ["Name", "Title", "Company", "LinkedIn URL", "Reason Disqualified"];
const violationHeaders = ["lead_key", "Name", "Title", "Company", "LinkedIn URL", "Violation"];
writeText(path.join(outDir, `qualified_leads_${dateKey}.csv`), toCsv(qualifiedHeaders, qualified));
writeText(path.join(outDir, `competitors_${dateKey}.csv`), toCsv(disqualHeaders, competitors));
writeText(path.join(outDir, `non_decision_makers_${dateKey}.csv`), toCsv(disqualHeaders, nonDecisionMakers));
writeText(path.join(outDir, `b2c_focused_${dateKey}.csv`), toCsv(disqualHeaders, b2cFocused));
writeText(path.join(outDir, `no_outbound_intent_${dateKey}.csv`), toCsv(disqualHeaders, noOutboundIntent));
if (violations.length) {
writeText(path.join(outDir, `contract_violations_${dateKey}.csv`), toCsv(violationHeaders, violations));
}
const registry = {
generated_at: runAt,
source_file: inputFile,
concerns,
leads: Array.from(dedupeMap.values())
};
writeJson(path.join(outDir, `lead_registry_${dateKey}.json`), registry);
const weekKey = getWeekKey();
if (systemState.weeklyMetrics.weekKey !== weekKey) {
systemState.weeklyMetrics = {
weekKey,
leads_processed: 0,
qualified_count: 0,
drafted_count: 0,
reply_drafts_count: 0,
replied_count: 0,
rejected_count: 0,
score3_conversion_count: 0
};
}
systemState.firstRunCompleted = true;
systemState.lastLeadFilterRunAt = runAt;
systemState.lastSuccessfulRunAt = runAt;
systemState.weeklyMetrics.leads_processed += records.length;
systemState.weeklyMetrics.qualified_count += qualified.length;
systemState.leadRegistry = systemState.leadRegistry || {};
registry.leads.forEach((leadRecord) => {
systemState.leadRegistry[leadRecord.lead_key] = {
score: leadRecord.score,
qualification_status: leadRecord.qualification_status,
reason: leadRecord.reason,
signal_type: leadRecord.signal_type,
last_scored_at: leadRecord.last_scored_at,
company: leadRecord.company,
title: leadRecord.title,
name: leadRecord.name
};
});
writeJson(path.join(ROOT, "state", "linkedin-system-state.json"), systemState);
const summary = {
inputFile,
outputDir: outDir,
total: records.length,
qualified: qualified.length,
disqualified: competitors.length + nonDecisionMakers.length + b2cFocused.length + noOutboundIntent.length,
competitors: competitors.length,
non_decision_makers: nonDecisionMakers.length,
b2c_focused: b2cFocused.length,
no_outbound_intent: noOutboundIntent.length,
contract_violations: violations.length,
concerns
};
console.log(JSON.stringify(summary, null, 2));
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { classifyProspectReply } = require("./lib/reply-classify");
const { readJson, writeJson, ensureDir, formatDateKey } = require("./lib/io");
const { ensureWeekMetrics } = require("./lib/week-metrics");
const ROOT = path.resolve(__dirname, "..");
function getArgValue(flag) {
const index = process.argv.findIndex((a) => a === flag);
if (index === -1 || index === process.argv.length - 1) {
return "";
}
return process.argv[index + 1];
}
const leadKeyArg = getArgValue("--lead-key");
const messageArg = getArgValue("--message");
const messageFile = getArgValue("--message-file");
if (!leadKeyArg) {
console.error("Usage: node scripts/run-reply-handler.js --lead-key <key> (--message \"...\" | --message-file path)");
process.exit(1);
}
let prospectText = messageArg;
if (messageFile) {
const p = path.resolve(process.cwd(), messageFile);
if (!fs.existsSync(p)) {
console.error(`Message file not found: ${p}`);
process.exit(1);
}
prospectText = fs.readFileSync(p, "utf8");
}
if (!prospectText || !String(prospectText).trim()) {
console.error("Prospect message is empty. Use --message or --message-file.");
process.exit(1);
}
const state = readJson(path.join(ROOT, "state", "linkedin-system-state.json"), null);
if (!state) {
console.error("Missing state. Run: node scripts/bootstrap-system.js");
process.exit(1);
}
ensureWeekMetrics(state);
const leadMeta = state.leadRegistry && state.leadRegistry[leadKeyArg];
const firstName =
leadMeta && leadMeta.name ? leadMeta.name.split(" ")[0].replace(/[(),]/g, "") : "";
const { type: replyType, confidence } = classifyProspectReply(prospectText);
function buildDraft(rt, first) {
const prefix = first ? `${first}, ` : "";
switch (rt) {
case "interested":
return `${prefix}Thanks for writing back. What would be most useful to clarify first about how you're thinking about this?`;
case "hesitant":
return `${prefix}Makes sense. What would need to be true for this to feel worth a short look?`;
case "competitor_mention":
return `${prefix}Got it. Is that setup covering the outcome you care about here, or is there still a gap?`;
case "positive_but_vague":
return `${prefix}Happy to go deeper. Want to do a 15-minute pass with a clear agenda so it stays useful?`;
case "not_interested":
return `${prefix}Thanks for letting me know. I'll step back here. If anything changes, you're welcome to reach out.`;
case "question":
return `${prefix}Good question. Here's the direct answer: [fill in from your offer]. If you want, we can also sanity-check fit on a quick call.`;
case "irrelevant":
default:
return `${prefix}Thanks for the note. To stay useful: are you still open to a quick exchange on [topic from your thread]?`;
}
}
const draft = buildDraft(replyType, firstName).trim();
const now = new Date().toISOString();
const dateKey = formatDateKey();
const outDir = path.join(ROOT, "output", `replies_${dateKey}`);
ensureDir(outDir);
const safeKey = leadKeyArg.replace(/[^a-z0-9_|-]/gi, "_").slice(0, 120);
const artifactPath = path.join(outDir, `reply_${safeKey}_${dateKey}.json`);
const artifact = {
lead_key: leadKeyArg,
generated_at: now,
prospect_message_excerpt: String(prospectText).slice(0, 2000),
reply_type: replyType,
classification_confidence: confidence,
draft,
status: "reply_drafted",
note:
"Template draft from run-reply-handler.js. Refine in the agent using context/brand-voice.md."
};
writeJson(artifactPath, artifact);
state.lastReplyRunAt = now;
state.lastSuccessfulRunAt = now;
state.weeklyMetrics.reply_drafts_count = (state.weeklyMetrics.reply_drafts_count || 0) + 1;
state.outreachRegistry = state.outreachRegistry || {};
const prev = state.outreachRegistry[leadKeyArg] || {};
state.outreachRegistry[leadKeyArg] = {
...prev,
lastReplyDraftAt: now,
lastReplyType: replyType,
lastDraftStatus: "reply_drafted",
lastProspectSnippet: String(prospectText).slice(0, 280)
};
writeJson(path.join(ROOT, "state", "linkedin-system-state.json"), state);
console.log(
JSON.stringify(
{
output: artifactPath,
lead_key: leadKeyArg,
reply_type: replyType,
draft_chars: draft.length
},
null,
2
)
);