mirror of
https://github.com/xx254/linkedin_skills.git
synced 2026-06-12 16:34:52 +03:00
linkedin skills
This commit is contained in:
Vendored
+70
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user