diff --git a/skills/linkedin-growth/.gitignore b/skills/linkedin-growth/.gitignore new file mode 100644 index 0000000..0f988f3 --- /dev/null +++ b/skills/linkedin-growth/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +*.log +tmp/ diff --git a/skills/linkedin-growth/README.md b/skills/linkedin-growth/README.md new file mode 100644 index 0000000..6df7f81 --- /dev/null +++ b/skills/linkedin-growth/README.md @@ -0,0 +1,112 @@ +# linkedin-growth + +A LinkedIn lead pipeline skill for AI agents (Claude Code, Codex, Cursor, etc.). +Drops a single SQLite DB and a set of Node scripts that wrap +[`linkedin-cli`](https://www.npmjs.com/package/@linkedapi/linkedin-cli) into a +local, queryable lead pipeline. + +## What it does + +**Phase A — Import.** Triggered by the user. Searches LinkedIn (Sales Navigator or +regular) via `linkedin-cli`, hands candidates to your AI agent for qualification +against a configurable ICP prompt, dedupes, and inserts qualified leads with a +round-robin owner assignment across all registered accounts. + +**Phase B — Network maintenance.** Runs on a recurring schedule per account. +Sends connection invites up to that account's daily limit, then checks pending +requests that are older than `max_pending_days` and either marks them connected +or withdraws them. + +All state lives in a local SQLite database. No external services beyond +`linkedin-cli` and what it talks to. + +## Install (humans) + +1. Install Node.js ≥ 20. +2. Install `linkedin-cli`: + ```bash + npm install -g @linkedapi/linkedin-cli + linkedin setup --linked-api-token= --identification-token= + ``` + Repeat the second command per LinkedIn account you want to connect. +3. Place this `linkedin-growth/` folder into your AI agent's skills directory: + - Claude Code (project): `/.claude/skills/linkedin-growth/` + - Claude Code (user): `~/.claude/skills/linkedin-growth/` + - Codex / other: per your tool's skill discovery convention +4. Install local dependencies: + ```bash + cd + npm install --omit=dev + ``` +5. Verify: + ```bash + node scripts/doctor.mjs + ``` +6. Register each linkedin-cli account in this skill's DB (pick any short name you like; + it maps to the exact name shown by `linkedin account list`): + ```bash + linkedin account list + node scripts/account.mjs add --name --cli-account "" + ``` +7. Turn on the background scheduler: + ```bash + node scripts/schedule.mjs install + ``` + This runs the pipeline in the background and sends invites during each account's + active hours, paced across the day. + +After this, ask your agent: "import leads from this search URL: ..." or "how +many pending invites does <account> have?" — see `SKILL.md` for the full agent +playbook. + +## Slash commands (optional, Claude Code) + +Four guided entry points ship in `commands/`. Install them by copying or +symlinking into your commands directory: + +```bash +# project-level +ln -s /linkedin-growth/commands/linkedin-growth-*.md /.claude/commands/ +# or user-level +ln -s /linkedin-growth/commands/linkedin-growth-*.md ~/.claude/commands/ +``` + +| Command | What it does | +|---------|--------------| +| `/linkedin-growth-setup` | First-run onboarding: prerequisites, connect accounts, install scheduler | +| `/linkedin-growth-import` | Guided Phase A import (search → qualify → store) | +| `/linkedin-growth-status` | Dashboard + answers arbitrary status questions | +| `/linkedin-growth-config` | Change per-account limits/schedule/pause, or edit the ICP prompt | + +These are conveniences — you can equally just talk to the agent in plain language. + +## Data location + +| Platform | DB / data dir | +|----------|------------------------| +| macOS / Linux | `~/.local/share/linkedapi-linkedin-growth/` | +| Windows | `%APPDATA%\linkedapi-linkedin-growth\` | + +## Manual operations cheat sheet + +```bash +node scripts/doctor.mjs --json # health check +node scripts/status.mjs --json # dashboard for all accounts +node scripts/status.mjs --account --since 7d +node scripts/account.mjs list +node scripts/account.mjs pause --name +node scripts/account.mjs update --name --daily-invite-limit 20 +node scripts/lead.mjs list --account --status pending +node scripts/lead.mjs show "" +node scripts/lead.mjs reset # clears error +node scripts/query.mjs --sql "SELECT ... FROM leads ..." # read-only SQL +node scripts/schema.mjs --examples # schema + example queries +node scripts/export.mjs --format csv --output leads.csv # full dump +node scripts/schedule.mjs status +node scripts/schedule.mjs install +node scripts/schedule.mjs uninstall +``` + +## License + +MIT — see [LICENSE](../LICENSE). diff --git a/skills/linkedin-growth/SKILL.md b/skills/linkedin-growth/SKILL.md new file mode 100644 index 0000000..8eef0d7 --- /dev/null +++ b/skills/linkedin-growth/SKILL.md @@ -0,0 +1,480 @@ +--- +name: linkedin-growth +description: Two-phase LinkedIn lead pipeline driven by linkedin-cli. Phase A imports leads from a search URL or filters, qualifies them against a configurable ICP via sub-agent, and stores them in a local SQLite database with round-robin assignment across one or more LinkedIn accounts. Phase B runs on a schedule per account — sends connection invites up to a daily limit and withdraws stale pending requests. Use when the user wants to grow their network from LinkedIn searches, manage outgoing invites at scale, ask status questions (counts, conversion, pending older than N days, last imports), pause/resume an account, change ICP, or install the recurring scheduler. +--- + +# LinkedIn Growth Skill + +This skill turns a Sales Navigator (or regular) search into a managed pipeline: +**search → qualify (you, via sub-agent) → store → invite on schedule → check pending → withdraw stale**. + +All state lives in a local SQLite database. Every LinkedIn action goes through +`linkedin-cli` (the `linkedin` binary). You orchestrate via the Node scripts +under `scripts/`. + +## Vocabulary + +| Term | Meaning | +|------|---------| +| **Account** | A LinkedIn account registered in `linkedin-cli` AND added to this skill's DB. The DB stores per-account policy (`daily_invite_limit`, `active_start`/`active_end` hours, `max_pending_days`, `paused`). | +| **Lead** | A qualified person row in `leads`. PK is `hashed_url` (Sales Nav hashed URL for `nv` imports, or `publicUrl` for `st` imports). Has exactly one `owner_account`. | +| **List** | A free-text `list_name` attached at import time (e.g. "VP of Sales TOP 100"). Used for filtering and conversion analytics. | +| **Batch** | A row in `import_batches`. Created by `import.mjs prepare`; transitions `pending_qualification → committed | aborted`. | +| **Status** | A lead's lifecycle state: `not_connected → pending → (connected | exhausted | error)`. `not_connected` means ready for an invite from `owner_account` (could be the 1st attempt or a retry under a different account). `connected` = success (terminal). `exhausted` = tried up to `max_connect_attempts` accounts, none accepted (terminal). `error` = an invite failed technically (manual reset). | +| **Retry policy** | Global `max_connect_attempts` setting. When an account's attempt fails (we withdrew a stale pending, OR the person declined/expired), the lead is reassigned to another untried account and set back to `not_connected` — until `max_connect_attempts` distinct accounts have tried, then it becomes `exhausted`. `1` = no retry (default); `all` = try every active account. | +| **Phase A** | Import — runs only when the user triggers it. Includes the LLM step (you, via sub-agent). | +| **Phase B** | Network maintenance — `network-invite` + `network-pending`. Runs on a schedule. NEVER calls an LLM. | + +## First-run setup + +**1. Verify Node ≥ 20:** `node --version`. +If missing — print the OS-specific install command and stop: +- macOS: `brew install node` +- Linux: `apt install nodejs` / `dnf install nodejs` / etc., or nvm +- Windows: `winget install OpenJS.NodeJS.LTS` + +**2. From this skill's directory, run:** + +```bash +node scripts/doctor.mjs --json +``` + +If the output is `Cannot find module 'better-sqlite3'`: + +```bash +npm install --omit=dev +``` + +Then re-run doctor. (Alternative: `node scripts/doctor.mjs --fix` does this automatically.) + +**3. For each FAIL in the doctor output, apply the remediation:** + +| Check name | Remediation | +|------------|-------------| +| `linkedin-cli` | `npm install -g @linkedapi/linkedin-cli` | +| `cli-accounts` | Ask the user for their Linked API Token and Identification Token (link: https://app.linkedapi.io), then `linkedin setup --linked-api-token= --identification-token=`. Repeat per LinkedIn account they want connected. | +| `db` | Auto-fixed by any script on first invocation, or explicitly: `node scripts/db.mjs init` | +| `db-accounts` | Run `linkedin account list` (prints a table; the `*` marks the active account) and register each one here: `node scripts/account.mjs add --name --cli-account ""`. The short name is what every other command takes; the cli-account is the mapping. | +| `scheduler` | Should pass automatically. On headless Linux without systemd-user, doctor falls back to `cron`. | + +**4. Re-run `node scripts/doctor.mjs --json` until `"ok": true`.** + +**5. Set the connection pace — ask once, apply to all accounts.** Ask the user a single +question (not per account): "By default each account sends at most one connection request +every 15 minutes — keep 15, or change it?". Apply their answer to every account via +`--min-invite-interval ` (either pass it on each `account.mjs add`, or +`account.mjs update --name --min-invite-interval ` for all afterward). Default is +15. Let the user know they can fine-tune it per account later just by asking (e.g. "make +kiril one every 30 minutes") — it is a per-account setting, this question just sets a common +value for everyone. + +**6. Set the retry policy.** Ask the user: "If someone doesn't accept the request, should +we try connecting from another account? (no / a specific number of accounts / all of them)". +Then: + +```bash +node scripts/settings.mjs set max_connect_attempts 1 # no retry (default) +node scripts/settings.mjs set max_connect_attempts 2 # original + 1 more +node scripts/settings.mjs set max_connect_attempts all # every account +``` + +**7. Enable the background scheduler (only after at least one account is registered):** + +```bash +node scripts/schedule.mjs install +``` + +This installs one platform-native background task that keeps the pipeline running +on its own. When talking to the user, describe it as "the pipeline now runs in the +background and sends invites during each account's active hours" — do not expose the +scheduler's internal wake-up frequency (the tick) or other plumbing. (The invite *pace* +from step 5 — "one connect every N minutes" — is a real user-facing setting and fine to +discuss; it's the tick's 5-minute heartbeat that stays hidden.) See the **Phase B** and +**Scheduler** sections below for how it actually works. + +**8. Tell the user the next step and offer to do it.** Setup alone sends nothing — the +pipeline is empty until leads are imported. End onboarding with a concrete call to action, +e.g.: "You're all set. To start, give me a LinkedIn or Sales Navigator search URL (or +search filters) and a name for the list, and I'll import and qualify your first batch of +leads." If the user provides one, proceed straight into **Phase A** below. Do not end the +setup conversation without this prompt. + +## Phase A — Importing leads (interactive) + +Triggered by the user via wording like: +- "import leads from this search ..." +- "add leads from this Sales Navigator URL" +- "add a list called 'X' from this search" + +### Step 1 — Prepare + +**Always ask the user for a limit first.** Before running `prepare`, ask "how many of the +found leads should I take?" — the user gives a number, or says "max" for the maximum. The +maximum depends on the search type (these are the Linked API / LinkedIn caps): +- Sales Navigator (`nv`): **2500** +- standard search (`st`): **1000** + +`--limit` is **required**; `prepare` errors if it is missing. Pass `--limit max` (or `all`) +for the cap, or a number (clamped to the cap, with `limit_capped_to_max: true` reported). + +```bash +node scripts/import.mjs prepare \ + --searcher \ + --list "" \ + --limit \ # REQUIRED — ask the user; 'max' = 2500 (nv) / 1000 (st) + [--type nv|st] # default nv + [--search-url ""] # Sales Nav or LinkedIn search URL + [--term ... --position ... --locations ... --industries ...] +``` + +Auto-detect: if the URL contains `/sales/`, pass `--type nv`; otherwise `--type st`. +Either `--search-url` or filter flags must be provided. + +The script: +1. Runs the LinkedIn search via `linkedin-cli` (workflow run for URL-based, native CLI for filters) +2. Normalizes results +3. Dedupes against existing rows in `leads` (skipped count is reported) +4. Writes the new candidates to `/tmp/qualify-.candidates.json` +5. Creates an `import_batches` row in state `pending_qualification` +6. Returns the batch id + the candidate file path + the expected result file path + the path to the qualification prompt + +### Step 2 — Qualify (YOU, against the user's ICP) + +Qualification is filtering candidates against the user's **ICP** (Ideal Customer Profile) — +their definition of who is a good lead and who to filter out. The ICP is user-owned and +**must come from the user**, never from a hardcoded list. It is stored in the +`icp_definition` setting and persists across imports. + +**a. Make sure there is an ICP.** `prepare`'s output includes `icp_configured` and the +current `icp_definition`. +- If `icp_configured` is **false**: interview the user before qualifying. Ask concrete + questions — which roles/seniority to target, which industries/company types fit, company + size/stage if relevant, locations to include or exclude, and any hard exclusions + (competitors, students, specific titles). Summarize what you heard back to them, then save + it straight into the database via stdin (no stray files): + ```bash + node scripts/settings.mjs set icp_definition --stdin <<'ICP' + + ICP + ``` +- If `icp_configured` is **true**: show the user the current ICP in plain language and ask + whether to use it as-is or tweak it for this list. If they tweak it, re-save it the same way. + +**b. Qualify each candidate.** Read the candidate file (JSON array of +`{hashed_url, public_url, full_name, position, location}`) and the qualification contract at +`config/qualification-prompt.md`. Judge every candidate against the ICP. For more than ~25 +candidates, chunk the work and delegate each chunk to a sub-agent (Task tool in Claude Code, +or the equivalent in other hosts), passing the ICP + the contract + the chunk. Each must +return `[{hashed_url, suitable, reasoning}]` covering EVERY lead, preserving `hashed_url`, +where `reasoning` cites the actual ICP criterion that drove the decision. + +**Use a cheap, fast model for the qualification sub-agents.** This is a bounded +classification task (role + location vs ICP → boolean + one-line reason), not deep +reasoning — so the heaviest model is a waste of money at lead volume. In Claude Code, spawn +the qualification sub-agents with `model: "haiku"` (the Task tool's `model` parameter); in +other hosts pick their equivalent small/fast model. Keep the orchestration, the ICP +interview, and the final report on the main model — only the per-chunk classification goes to +the cheap tier. If the ICP is unusually nuanced and you see many borderline calls, raise the +tier for that import. The stored per-lead `reasoning` lets you spot-check cheaply. + +Concatenate all results and write them to the expected result file path from `prepare`. + +(Non-agentic context: the user can write the result file by hand or with any model; nothing +in the skill enforces a specific provider.) + +### Step 3 — Commit + +```bash +node scripts/import.mjs commit --batch --results +``` + +The script: +1. Reads results, looks up candidates by `hashed_url` +2. For each `suitable: true`: round-robin assigns `owner_account` from active (non-paused) accounts in alphabetical order, starting after the last-assigned account (cursor persists across imports). Inserts the lead with `status='not_connected'`. +3. Updates the batch to `state='committed'` with stats +4. Returns counts: `suitable`, `unsuitable`, `assigned`, `skippedExisting`, `skippedMissing` + +**After committing, report the decision transparently** so the user understands the filter: +state how many were kept vs filtered, and give a few concrete sample reasons from both sides +(e.g. "kept: Head of Sales at a B2B SaaS — matches target role; filtered: Software Engineer — +not a targeted role"). The per-lead `reasoning` is stored on each lead and is also queryable +later via `node scripts/lead.mjs show ` or `query.mjs`. + +### Other batch commands + +```bash +node scripts/import.mjs list [--state pending_qualification|committed|aborted] +node scripts/import.mjs show --batch +node scripts/import.mjs abort --batch # cancel a pending_qualification batch +``` + +## Phase B — Network maintenance (scheduled, distributed) + +Phase B is **not** a single daily batch. The background scheduler does small, +resumable units of work spread across each account's active hours. Invites and +pending checks are **decoupled** — they run on their own cadence. + +On each wake-up, for every active account that is **within its active window** +(`active_start`–`active_end`, local time): + +1. **Invites** (write, rate-sensitive): send **one** invite if both + - the daily quota (`daily_invite_limit`) is not yet reached, and + - at least `min_invite_interval_minutes` have passed since the last invite. + + `min_invite_interval_minutes` is the explicit "no more than one connect every N + minutes" control (default 15). The effective daily ceiling is the tighter of the + daily limit and what the interval allows inside the window. + +2. **Pending checks** (mostly reads, low-risk): process up to `pending_batch_size` + due pending leads (status check, and withdraw if still pending past + `max_pending_days`). This runs **independently of the invite decision** and is + **not** throttled by the invite interval, so a backlog of stale pending requests + drains quickly instead of one-per-wake-up. + +Both can happen in the same wake-up. + +Why this shape matters (and what to tell the user if they ask): +- Each LinkedIn operation is written to the DB immediately. If the machine sleeps or + a run is killed mid-operation, the next wake-up just continues from the current DB + state — **there is no batch to resume and nothing to roll back**. +- Daily quota is recomputed from the `runs` table every time (bounded to the local + calendar day), so it stays correct across interruptions and restarts. +- Invites are paced by an explicit interval; pending checks are not — a read is cheap, + a write is rate-limited. + +You normally never run Phase B by hand. For testing or a deliberate one-off "drain +now" (ignores pacing, respects the daily quota and active-window checks inside the +scripts only loosely — use with care): + +```bash +node scripts/network-invite.mjs --account --limit 1 # exactly one invite +node scripts/network-pending.mjs --account --limit 1 # exactly one pending check +node scripts/network-run.mjs --account # full invite + pending sweep now +``` + +### Invite outcomes + +For each `not_connected` lead within the day's remaining budget, runs the workflow: + +```json +{ + "actionType": "st.openPersonPage", + "personUrl": "...", + "basicInfo": true, + "then": { "actionType": "st.sendConnectionRequest" } +} +``` + +Result classification: +- `data.then.success === true` → `status='pending'`, `sent_at=now`, `basic_info_json` stored +- `data.then.error.type` includes `alreadyPending` → `status='pending'` +- `data.then.error.type` includes `alreadyConnected` → `status='connected'` +- `data.then.error.type` signals an account-level limit on the action category (e.g. + `limitExceeded`, a rate limit) → the lead stays `not_connected` (NOT a per-lead error) and + this account's run backs off (stops for the cycle); the lead is retried on a later wake-up. +- `data.then.error.type` is `noteLimitExceeded` → the account has reached LinkedIn's + personalized invitation-note limit. Treat it as account-level gating, not a per-lead error: + leave the lead `not_connected`, back off, and retry later or send future invites without notes. +- `data.then.error.type` is `requestNotAllowed` ("LinkedIn has restricted sending a connection + request") → ambiguous, disambiguated by pattern: a **streak** (2+ in a row with no successful + invite between) = the account's weekly invite limit → leave `not_connected` and back off (not + the lead's fault); an **isolated** hit = the person restricts invites → counted against the + lead, and after `RESTRICTED_LEAD_ATTEMPTS` (2) isolated hits the lead is closed as `exhausted` + so it never hangs. Never burns a whole queue on a weekly-limit burst. +- anything else → `status='error'`, `error_type`/`error_message` stored + +`linkedin-cli` exit code 4 (account issue) or 6 (rate limit) aborts the whole +run immediately — no further leads touched. Other non-zero exits mark the lead +as error and continue. + +### Pending outcomes (with cross-account retry) + +For each `pending` lead where `sent_at` is older than `max_pending_days`: + +1. `linkedin connection status ` +2. Branch: + - `connected` → `status='connected'` (terminal success) + - `notConnected` (declined / expired) → **failed attempt** → apply retry policy + - `pending` (still) → `linkedin connection withdraw ` → on success this is a + **failed attempt** → apply retry policy + - other → leave as `pending`, log run as error + +**Retry policy (`resolveFailedAttempt`).** On a failed attempt, look up how many distinct +accounts have already invited this lead (from the `runs` table). If that count is below +`max_connect_attempts` (global setting) AND there is an active account that has NOT tried +this lead yet, reassign the lead to the least-loaded such account and set it back to +`not_connected` (it re-enters the invite flow under the new account). Otherwise mark it +`exhausted` (terminal). With the default `max_connect_attempts = 1`, every failed attempt +goes straight to `exhausted` (no retry). + +## Answering arbitrary status questions + +The user will ask things like "how many pending on ?", "imports last 7 days?", +"which lists convert best?", "why is lead X in error?". Substitute the user's real +account/list names. Use this decision tree: + +1. **Try the high-level dashboard first:** + + ```bash + node scripts/status.mjs --json # all accounts + node scripts/status.mjs --account --json + node scripts/status.mjs --since 7d --json # adds imported_since per account + ``` + + Output covers: paused, daily_limit, sent_today, remaining_today, status counts, + recent_errors, imported_since. + +2. **For lead-level lookups:** + + ```bash + node scripts/lead.mjs list --account --status pending --limit 100 + node scripts/lead.mjs list --list "" + node scripts/lead.mjs show + ``` + + `lead.mjs show` returns the lead + the last 25 runs (action, started_at, success, error_message). + +3. **For anything else — write SQL via `query.mjs`:** + + First refresh your schema knowledge if you don't have it cached: + + ```bash + node scripts/schema.mjs --examples --json + ``` + + Then run: + + ```bash + node scripts/query.mjs --sql "SELECT ... FROM leads WHERE ..." --json + ``` + + `query.mjs` opens the DB read-only and rejects any non-SELECT statement. + For sanity-checking a complex query, append `--explain`. + +### Schema quick-reference + +``` +accounts(name PK, cli_account, paused, daily_invite_limit, min_invite_interval_minutes, + active_start, active_end, max_pending_days, pending_batch_size, + last_action_at, created_at) + +leads(hashed_url PK, public_url, full_name, position, location, list_name, + reasoning, owner_account FK accounts.name, basic_info_json, + status [not_connected|pending|connected|exhausted|error], + sent_at, status_updated_at, error_type, error_message, created_at) + +runs(id PK, lead_hashed_url FK leads.hashed_url, account, action + [invite|check_status|withdraw], started_at, finished_at, success, + raw_response_json, error_message) + +import_batches(id PK, list_name, searcher_account, search_url, search_type, + candidate_count, qualified_count, committed_count, + skipped_existing_count, state, created_at, committed_at) + +import_state(id=1 singleton, last_assigned_account) + +settings(key PK, value) -- global config, e.g. max_connect_attempts ('1' | 'N' | 'all') +``` + +All timestamps are SQLite `datetime('now')` strings in UTC. + +## Retry policy (global settings) + +```bash +node scripts/settings.mjs list +node scripts/settings.mjs get max_connect_attempts +node scripts/settings.mjs set max_connect_attempts 2 # try original + 1 more account +node scripts/settings.mjs set max_connect_attempts all # try every active account +node scripts/settings.mjs set max_connect_attempts 1 # no retry (default) +``` + +`max_connect_attempts` is the number of DISTINCT accounts that may attempt one lead. When +a request goes unaccepted (withdrawn stale pending, or declined/expired), the lead is +handed to the least-loaded untried account until this many accounts have tried, then it is +`exhausted`. Frame it to the user as "if someone doesn't accept, try from N other accounts". + +## Account management + +`` below is a placeholder — the user picks their own short name; it maps to +a real `linkedin-cli` account name (from `linkedin account list`). The skill ships +with no accounts and no predefined names. + +```bash +node scripts/account.mjs list +node scripts/account.mjs add --name --cli-account "" \ + [--daily-invite-limit 35] [--min-invite-interval 15] \ + [--active-start 09:00] [--active-end 18:00] \ + [--max-pending-days 10] [--pending-batch-size 5] +node scripts/account.mjs update --name --daily-invite-limit 25 +node scripts/account.mjs update --name --min-invite-interval 20 # one connect / 20 min +node scripts/account.mjs update --name --active-start 10:00 --active-end 16:00 +node scripts/account.mjs pause --name # scheduler + import skip this account +node scripts/account.mjs resume --name +node scripts/account.mjs rename --name --new-name +node scripts/account.mjs remove --name [--force] # --force needed if leads exist +``` + +Per-account invite controls (all independent): +- `active_start`/`active_end` — **when** (daily hours, local time) invites go out. +- `min_invite_interval_minutes` — **how fast** (minimum gap between two invites, + default 15). This is the direct "no more than one connect every N minutes" knob. +- `daily_invite_limit` — **how many** per day (hard cap). + +`status.mjs` reports `effective_max_per_day` = the tighter of the daily limit and what +the interval allows inside the window. `pending_batch_size` controls how many stale +pending requests are checked per wake-up (independent of invites). When discussing with +the user, frame these as plain-language behavior ("invites go out 9am–6pm, at most one +every 15 minutes, up to 35 a day") — never in terms of the scheduler's wake-up frequency. + +## ICP and the qualification contract + +Two separate things: +- **The ICP** (who to keep / filter) is user-owned data. It lives in the local **database** + (the `settings` table, key `icp_definition`) — NOT in any file. View it with + `node scripts/settings.mjs get icp_definition`. Change it by piping the text via stdin: + ```bash + node scripts/settings.mjs set icp_definition --stdin <<'ICP' + + ICP + ``` + (For a short one-liner, `settings.mjs set icp_definition ''` also works.) Do NOT write + the ICP to a stray file in the repo or some tmp folder and load it from there — the file is + not where it lives, and it litters the workspace. Capture the ICP by asking the user; never + hardcode one. Changes take effect on the next import. +- **The qualification contract** at `config/qualification-prompt.md` is the product-agnostic + scaffolding (how to judge + the JSON output format). You normally don't change it; it + references the user's ICP rather than containing one. + +## Scheduler + +The scheduler is one OS-native background task (launchd / systemd-user / cron / +schtasks depending on platform) that wakes the pipeline periodically. The wake-up +frequency is an internal detail — the user-facing behavior is set by each account's +active hours and daily limit. Do not surface intervals to the user. + +```bash +node scripts/schedule.mjs detect # reports launchd | systemd-user | cron | schtasks +node scripts/schedule.mjs status +node scripts/schedule.mjs install [--interval-minutes 5] # interval is internal; default is fine +node scripts/schedule.mjs uninstall +``` + +To inspect what the background runs are doing, read `/logs/-.log`. + +## Common pitfalls (read before acting) + +- **Two LinkedIn accounts in the same DB with the same `cli_account` mapping** — undefined behavior; reject if the user tries it. Use `account.mjs list` to verify. +- **Lead PK across search types** — Sales Nav (`nv`) returns hashed URLs; regular (`st`) returns public URLs. The same person from both search types becomes two rows. If users mix, mention it explicitly. +- **Interrupted mid-operation** — each scheduler wake-up does at most one invite/check, persisted immediately. A sleep/kill loses at most that one in-flight operation; the next wake-up continues from DB state. There is no batch to resume. Leads not yet processed stay `not_connected`/`pending` and are picked up later. +- **The `connected` status on invite** — only set when LinkedIn reports `alreadyConnected` at invite time (the person was already a 1st-degree connection). Real new connections appear via `network-pending` (where `connection status` returns `connected`). +- **`error` status is terminal until reset** — `lead.mjs reset ` moves it back to `not_connected`. The auto-pipeline does not retry errored leads on its own. +- **Renaming an account** — leads' `owner_account` is ON UPDATE CASCADE; `rename` is safe. `remove` without `--force` refuses when leads exist; with `--force` they are orphaned (status queries will still include them but no scheduled run touches them). +- **`schedule.mjs install` without registered accounts** — harmless, but nothing happens until accounts exist. Install order: doctor → add accounts → install scheduler. +- **Daily quota boundary** — the per-day invite count resets at **local** midnight, not UTC. Quotas and `sent_today` are computed against the local calendar day. + +## Idempotency + +- `db.mjs init`, `doctor.mjs`, `schema.mjs`, `status.mjs`, `lead.mjs show/list`, `query.mjs`, `schedule.mjs status` — all safe to call multiple times. +- `import.mjs prepare` creates a new batch every time — call once per intended import. +- `import.mjs commit` refuses to run twice on the same batch. +- `schedule.mjs install` overwrites any existing installation of the same service id. diff --git a/skills/linkedin-growth/commands/linkedin-growth-config.md b/skills/linkedin-growth/commands/linkedin-growth-config.md new file mode 100644 index 0000000..5baaf20 --- /dev/null +++ b/skills/linkedin-growth/commands/linkedin-growth-config.md @@ -0,0 +1,29 @@ +--- +description: Adjust linkedin-growth account settings — daily invite limit, schedule time, pending threshold, pause/resume — or edit the ICP qualification prompt. +argument-hint: "[account name] [what to change]" +--- + +Use the **linkedin-growth** skill to change configuration. Arguments: $ARGUMENTS + +First show current state with `node scripts/account.mjs list --json`. Then apply what I +asked, confirming the exact change back to me: + +- Daily invite limit (how many/day) → `node scripts/account.mjs update --name --daily-invite-limit ` +- Invite pace (no more than one connect every N min) → `node scripts/account.mjs update --name --min-invite-interval ` +- Active hours (when invites go out) → `node scripts/account.mjs update --name --active-start --active-end ` +- Pending threshold (check after N days) → `node scripts/account.mjs update --name --max-pending-days ` +- Pending check batch per run → `node scripts/account.mjs update --name --pending-batch-size ` +- Retry policy — if someone doesn't accept, try from other accounts (global, not per-account) + → `node scripts/settings.mjs set max_connect_attempts <1|N|all>` (1 = no retry) +- Temporarily stop an account → `node scripts/account.mjs pause --name ` + (and `resume` to re-enable). A paused account is skipped by both import round-robin and + the scheduler. +- Rename / remove an account → `account.mjs rename` / `account.mjs remove` +- Change who we target (the ICP) → show me the current ICP with + `node scripts/settings.mjs get icp_definition`, discuss the change, then save the new + version with `node scripts/settings.mjs set icp_definition --stdin` (pipe via heredoc). It + is stored in the database (not a file) and takes effect on the next import. The ICP is my + own data, not a hardcoded list — don't leave stray ICP files around. + +If I am vague, ask which account and which setting before changing anything. Never change +limits or schedules without telling me the before/after values. diff --git a/skills/linkedin-growth/commands/linkedin-growth-import.md b/skills/linkedin-growth/commands/linkedin-growth-import.md new file mode 100644 index 0000000..9e485ff --- /dev/null +++ b/skills/linkedin-growth/commands/linkedin-growth-import.md @@ -0,0 +1,42 @@ +--- +description: Import leads from a LinkedIn / Sales Navigator search into the pipeline — search, qualify against the ICP, dedupe, and store with round-robin assignment. +argument-hint: "[search URL or filters] [list name]" +--- + +Use the **linkedin-growth** skill to run a Phase A import. Arguments: $ARGUMENTS + +Drive the full import flow: + +1. Determine the inputs. If I gave a search URL, detect the type (URL containing + `/sales/` → `nv`, otherwise `st`). If I gave filters instead, use them. Ask me for + anything missing: which account should run the search (`--searcher`), and a list name + (`--list`). If I have more than one account, confirm the searcher with me. +2. **Always ask me the limit** — how many of the found leads to take. I can give a number or + say "max" for the maximum (Sales Navigator caps at 2500, standard search at 1000). Do not + assume a default; this question is required every time. +4. Run `node scripts/import.mjs prepare --searcher --list "" --type + --limit ` with either `--search-url ""` or the filter flags. (`--limit` is + required; a number above the cap is clamped and reported as `limit_capped_to_max`.) +5. **Settle the ICP before qualifying** (this is the filtering step — make it explicit, not + silent). The `prepare` output tells you whether an ICP is configured: + - If none is configured, interview me: which roles/seniority to target, which + industries/company types fit, company size/stage if relevant, locations to include or + exclude, and any hard exclusions. Summarize what you heard, then save it with + `node scripts/settings.mjs set icp_definition --stdin` (pipe the text via a heredoc — it + is stored in the database, not a file; do not leave stray ICP files in the repo or tmp). + - If one is configured, show it to me in plain language and ask whether to use it as-is or + adjust it for this list (re-save if I change it). +6. Read the returned candidate file and qualify every candidate **against my ICP**, using the + output contract in `config/qualification-prompt.md`. For more than ~25 candidates, chunk + the work and delegate each chunk to a sub-agent. **Use a cheap/fast model for these + qualification sub-agents** — it's simple classification, not deep reasoning (in Claude + Code, spawn them with `model: "haiku"`; in other hosts use their small/fast model). Keep + orchestration on the main model. Produce a JSON array + `[{hashed_url, suitable, reasoning}]` covering every candidate (reasoning must cite the ICP + criterion that drove the decision), and write it to the expected result file path. +7. Run `node scripts/import.mjs commit --batch --results `. +8. Report transparently: how many were found, skipped as duplicates, kept vs filtered, a few + concrete sample reasons from both sides, and how the new leads were distributed across + accounts. + +Never send invites here — import only stores leads as `not_connected`. diff --git a/skills/linkedin-growth/commands/linkedin-growth-setup.md b/skills/linkedin-growth/commands/linkedin-growth-setup.md new file mode 100644 index 0000000..71c9248 --- /dev/null +++ b/skills/linkedin-growth/commands/linkedin-growth-setup.md @@ -0,0 +1,52 @@ +--- +description: Onboard the linkedin-growth pipeline — check prerequisites, connect LinkedIn accounts, register them, and install the scheduler. +--- + +Use the **linkedin-growth** skill. Walk me through first-run setup, doing as much as +possible yourself and only asking me for things you genuinely cannot obtain +(LinkedIn tokens, account names, preferred invite limits/times). + +Follow the skill's "First-run setup" section end to end: + +1. Verify Node ≥ 20. If missing, give me the exact install command for my OS and stop. +2. From the skill directory run `node scripts/doctor.mjs --json`. If dependencies are + missing, run `npm install --omit=dev` and re-check. +3. For each failing check, apply the documented remediation: + - install `linkedin-cli` if absent + - if no LinkedIn accounts are connected, ask me for my Linked API Token and + Identification Token (from app.linkedapi.io) and run `linkedin setup ...` per account + - register each connected account into the DB with `node scripts/account.mjs add`, + asking me for a short name, a daily invite limit, and the daily hours during which + invites should go out (`--active-start` / `--active-end`, e.g. 09:00–18:00). If I + have more than one account, suggest slightly different hours so they don't act at the + exact same time. +4. Ask me ONCE about the pace between connection requests (not per account): "By default + each account sends at most one connection request every 15 minutes — keep 15, or change + it?". Apply my answer to ALL accounts via `--min-invite-interval ` (pass it on each + `account.mjs add`, or `account.mjs update` them afterward). Tell me I can fine-tune this + per account later just by asking — e.g. "make kiril send one every 30 minutes". +5. Re-run the doctor until `"ok": true`. +6. Ask me about the retry policy: "If someone doesn't accept the request, should we try + connecting from another account?" — then set it with + `node scripts/settings.mjs set max_connect_attempts <1|N|all>` (default 1 = no retry). +7. Once at least one account is registered, turn on the background scheduler with + `node scripts/schedule.mjs install` and confirm with `node scripts/schedule.mjs status`. + +Then summarize in plain language: which accounts are connected, how many invites per day +each sends, during which hours, and at what pace (one every N minutes), the retry policy, and +that the pipeline now runs in the background on its own. Do NOT mention internal scheduling +details (wake-up intervals, ticks). Do not send any invites during setup. + +8. Optionally capture my ICP now so the first import is smooth. Ask who my ideal leads are + and who to filter out — roles/seniority, industries/company types, company size/stage, + locations to include/exclude, and any hard exclusions. Summarize it back, then save with + `node scripts/settings.mjs set icp_definition --stdin` (pipe the agreed text in via a + heredoc — it is stored in the database, not a file; do not leave stray ICP files around). + If I'd rather decide later, skip this — the import flow will ask before qualifying. + +**Finally — and this is required — give me the next step and offer to do it now.** Setup +alone imports nothing; the pipeline stays empty until leads are added. Say something like: +"To start filling the pipeline, send me a LinkedIn or Sales Navigator search URL (or search +filters) plus a name for the list, and I'll import and qualify your first batch." If I give +you one, continue straight into the import (the `/linkedin-growth-import` flow). Never end the setup +without offering this next step. diff --git a/skills/linkedin-growth/commands/linkedin-growth-status.md b/skills/linkedin-growth/commands/linkedin-growth-status.md new file mode 100644 index 0000000..541fd24 --- /dev/null +++ b/skills/linkedin-growth/commands/linkedin-growth-status.md @@ -0,0 +1,21 @@ +--- +description: Show the linkedin-growth dashboard — lead counts by status, invites left today, pending older than the threshold, recent errors, and recent imports. +argument-hint: "[account name] [question]" +--- + +Use the **linkedin-growth** skill to answer status questions. Arguments: $ARGUMENTS + +1. Start with the dashboard: `node scripts/status.mjs --json` (add `--account ` if I + named one, and `--since 7d`/`30d` if I asked about a time window). +2. Present it clearly per account: paused?, daily limit, invites sent today and remaining, + counts by status (not_connected / pending / connected / withdrawn / error), and any + recent errors. +3. If I asked something the dashboard does not directly answer (conversion rates, best + lists, pending older than N days, per-list breakdowns), consult + `node scripts/schema.mjs --examples --json` and then run a read-only query with + `node scripts/query.mjs --sql "..." --json`. +4. For a specific lead, use `node scripts/lead.mjs show "" --json` and explain + its status and recent run history. + +These reads hit the local database (fast, free). Only if I explicitly ask for the *live* +LinkedIn state should you call linkedin-cli directly. diff --git a/skills/linkedin-growth/config/defaults.json b/skills/linkedin-growth/config/defaults.json new file mode 100644 index 0000000..dcbd488 --- /dev/null +++ b/skills/linkedin-growth/config/defaults.json @@ -0,0 +1,16 @@ +{ + "daily_invite_limit": 35, + "min_invite_interval_minutes": 15, + "max_pending_days": 10, + "pending_batch_size": 5, + "pending_drain_batch": 2, + "pending_tick_budget_seconds": 180, + "active_start": "09:00", + "active_end": "18:00", + "invite_delay_seconds": 60, + "qualification_batch_size": 25, + "tick_interval_minutes": 5, + "log_retention_days": 30, + "search_max_limit_nv": 2500, + "search_max_limit_st": 1000 +} diff --git a/skills/linkedin-growth/config/qualification-prompt.md b/skills/linkedin-growth/config/qualification-prompt.md new file mode 100644 index 0000000..68450e2 --- /dev/null +++ b/skills/linkedin-growth/config/qualification-prompt.md @@ -0,0 +1,73 @@ +# Lead Qualification Scaffolding + +This file defines **how** to qualify a batch of leads and the **output format**. It is +deliberately product-agnostic. The actual filtering rules — who counts as a good lead and +who should be filtered out — come from the user's own ICP, stored in the `icp_definition` +setting and supplied to you at qualification time. + +> If no ICP has been provided yet, do NOT guess. Stop and ask the user who they want to +> reach and who to exclude (see "Capturing the ICP" below), save it, then qualify. + +## The task + +You are given: +1. The user's **ICP definition** (their description of ideal leads and exclusions). +2. A **batch of candidate leads**, each with at minimum: `hashed_url`, `full_name`, + `position`, `location` (some fields may be `null`). + +For every candidate, decide whether it matches the ICP (`suitable: true`) or should be +filtered out (`suitable: false`), and give a short, honest reason grounded in the ICP. + +## Capturing the ICP (when it is missing or the user wants to change it) + +Interview the user with concrete questions, then save their answers. Cover at least: +- **Roles / seniority** to target (and any roles to exclude). +- **Industries / company types** that fit (and ones that don't). +- **Company size / stage**, if relevant. +- **Locations** to include or exclude (countries, regions). +- Any **hard exclusions** (e.g. competitors, students, specific titles). + +Save the resulting definition straight into the database (it lives in the `settings` table, +not in a file) by piping it via stdin: + +```bash +node scripts/settings.mjs set icp_definition --stdin <<'ICP' +Target: ... +Exclude: ... +ICP +# or, for a short one-liner: +node scripts/settings.mjs set icp_definition 'Target: ... | Exclude: ...' +``` + +## How to judge each lead + +1. **Hard exclusions first.** If the lead hits an explicit exclusion (role, industry, + location, etc.), it is `suitable: false` regardless of anything else. +2. **Role / use-case fit.** Does the person's position match a targeted role or buying + context described in the ICP? +3. **Decision-making / seniority**, if the ICP cares about it. +4. **Industry / company context**, if the ICP specifies it. +5. When the ICP is silent on a dimension, do not invent a rule — judge on the dimensions + the user actually gave you, and lean toward keeping borderline leads unless an + exclusion applies. + +Be consistent: the same kind of lead should get the same verdict across the batch. + +## Output contract + +Return a single JSON array. One element per input lead, matched by `hashed_url`: + +```json +{ + "hashed_url": "", + "suitable": true, + "reasoning": "1-2 sentences grounded in the ICP — why kept or filtered." +} +``` + +Rules: +- Output one element for **every** input lead. Do not skip any. +- Preserve each `hashed_url` exactly. +- No markdown fences, no commentary outside the JSON array. +- `reasoning` must reference the actual ICP criterion that drove the decision, so the user + can see *why* each lead was kept or filtered. diff --git a/skills/linkedin-growth/package.json b/skills/linkedin-growth/package.json new file mode 100644 index 0000000..77bf250 --- /dev/null +++ b/skills/linkedin-growth/package.json @@ -0,0 +1,19 @@ +{ + "name": "@linkedapi/linkedin-growth-skill", + "version": "0.1.0", + "private": true, + "description": "Skill scripts for the linkedin-growth pipeline (lead import + connection management via linkedin-cli).", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "doctor": "node scripts/doctor.mjs", + "tick": "node scripts/tick.mjs" + }, + "dependencies": { + "better-sqlite3": "^11.5.0", + "croner": "^9.0.0" + }, + "license": "MIT" +} diff --git a/skills/linkedin-growth/scripts/account.mjs b/skills/linkedin-growth/scripts/account.mjs new file mode 100644 index 0000000..ad8ff42 --- /dev/null +++ b/skills/linkedin-growth/scripts/account.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +import { parseArgs, requireFlag, intFlag, boolFlag } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail, info } from './lib/output.mjs'; +import { defaults } from './lib/config.mjs'; + +const { positional, flags } = parseArgs(); +const cmd = positional[0]; + +const CRON_TIME_RE = /^\d{2}:\d{2}$/; + +try { + switch (cmd) { + case 'list': + list(); + break; + case 'add': + add(); + break; + case 'update': + update(); + break; + case 'pause': + setPaused(true); + break; + case 'resume': + setPaused(false); + break; + case 'rename': + rename(); + break; + case 'remove': + remove(); + break; + default: + fail( + 'Usage:\n' + + ' account.mjs list\n' + + ' account.mjs add --name --cli-account ""\n' + + ' [--daily-invite-limit 35] [--min-invite-interval 15]\n' + + ' [--active-start 09:00] [--active-end 18:00]\n' + + ' [--max-pending-days 10] [--pending-batch-size 5]\n' + + ' account.mjs update --name [--daily-invite-limit N] [--min-invite-interval N]\n' + + ' [--active-start HH:MM] [--active-end HH:MM] [--max-pending-days N]\n' + + ' [--pending-batch-size N] [--cli-account ""]\n' + + ' account.mjs pause --name \n' + + ' account.mjs resume --name \n' + + ' account.mjs rename --name --new-name \n' + + ' account.mjs remove --name [--force]', + 5, + ); + } +} catch (err) { + fail(err.message); +} + +function list() { + withDb( + (db) => { + const rows = db + .prepare( + `SELECT name, cli_account, paused, daily_invite_limit, min_invite_interval_minutes, + active_start, active_end, max_pending_days, pending_batch_size, + last_action_at, created_at + FROM accounts ORDER BY name`, + ) + .all() + .map((r) => ({ ...r, paused: !!r.paused })); + ok(rows); + }, + { readonly: true }, + ); +} + +function add() { + const name = requireFlag(flags, 'name'); + const cliAccount = requireFlag(flags, 'cli-account'); + const d = defaults(); + const dailyLimit = intFlag(flags, 'daily-invite-limit', d.daily_invite_limit); + const inviteInterval = intFlag(flags, 'min-invite-interval', d.min_invite_interval_minutes); + const activeStart = String(flags['active-start'] ?? d.active_start); + const activeEnd = String(flags['active-end'] ?? d.active_end); + const maxPending = intFlag(flags, 'max-pending-days', d.max_pending_days); + const pendingBatch = intFlag(flags, 'pending-batch-size', d.pending_batch_size); + + validateWindow(activeStart, activeEnd); + if (dailyLimit < 1 || dailyLimit > 200) throw new Error('--daily-invite-limit out of range (1-200)'); + if (inviteInterval < 1 || inviteInterval > 1440) throw new Error('--min-invite-interval out of range (1-1440)'); + if (maxPending < 1 || maxPending > 365) throw new Error('--max-pending-days out of range (1-365)'); + if (pendingBatch < 1 || pendingBatch > 100) throw new Error('--pending-batch-size out of range (1-100)'); + + withDb((db) => { + try { + db.prepare( + `INSERT INTO accounts + (name, cli_account, daily_invite_limit, min_invite_interval_minutes, + active_start, active_end, max_pending_days, pending_batch_size) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(name, cliAccount, dailyLimit, inviteInterval, activeStart, activeEnd, maxPending, pendingBatch); + } catch (e) { + if (String(e).includes('UNIQUE')) throw new Error(`Account '${name}' already exists`); + throw e; + } + ok({ + name, + cli_account: cliAccount, + paused: false, + daily_invite_limit: dailyLimit, + min_invite_interval_minutes: inviteInterval, + active_start: activeStart, + active_end: activeEnd, + max_pending_days: maxPending, + pending_batch_size: pendingBatch, + }); + }); +} + +function validateWindow(start, end) { + if (!CRON_TIME_RE.test(start)) throw new Error('--active-start must be HH:MM (24h)'); + if (!CRON_TIME_RE.test(end)) throw new Error('--active-end must be HH:MM (24h)'); + if (start >= end) throw new Error('--active-start must be earlier than --active-end'); +} + +function update() { + const name = requireFlag(flags, 'name'); + const sets = []; + const vals = []; + if (flags['cli-account'] !== undefined) { + sets.push('cli_account = ?'); + vals.push(String(flags['cli-account'])); + } + if (flags['daily-invite-limit'] !== undefined) { + const n = intFlag(flags, 'daily-invite-limit'); + if (n < 1 || n > 200) throw new Error('--daily-invite-limit out of range'); + sets.push('daily_invite_limit = ?'); + vals.push(n); + } + if (flags['min-invite-interval'] !== undefined) { + const n = intFlag(flags, 'min-invite-interval'); + if (n < 1 || n > 1440) throw new Error('--min-invite-interval out of range (1-1440)'); + sets.push('min_invite_interval_minutes = ?'); + vals.push(n); + } + if (flags['pending-batch-size'] !== undefined) { + const n = intFlag(flags, 'pending-batch-size'); + if (n < 1 || n > 100) throw new Error('--pending-batch-size out of range (1-100)'); + sets.push('pending_batch_size = ?'); + vals.push(n); + } + if (flags['active-start'] !== undefined) { + const v = String(flags['active-start']); + if (!CRON_TIME_RE.test(v)) throw new Error('--active-start must be HH:MM'); + sets.push('active_start = ?'); + vals.push(v); + } + if (flags['active-end'] !== undefined) { + const v = String(flags['active-end']); + if (!CRON_TIME_RE.test(v)) throw new Error('--active-end must be HH:MM'); + sets.push('active_end = ?'); + vals.push(v); + } + if (flags['max-pending-days'] !== undefined) { + const n = intFlag(flags, 'max-pending-days'); + if (n < 1 || n > 365) throw new Error('--max-pending-days out of range'); + sets.push('max_pending_days = ?'); + vals.push(n); + } + if (sets.length === 0) throw new Error('No fields to update'); + + withDb((db) => { + const current = db.prepare('SELECT active_start, active_end FROM accounts WHERE name = ?').get(name); + if (!current) throw new Error(`Account '${name}' not found`); + const start = flags['active-start'] !== undefined ? String(flags['active-start']) : current.active_start; + const end = flags['active-end'] !== undefined ? String(flags['active-end']) : current.active_end; + if (start >= end) throw new Error('active_start must be earlier than active_end'); + + vals.push(name); + db.prepare(`UPDATE accounts SET ${sets.join(', ')} WHERE name = ?`).run(...vals); + const updated = db.prepare('SELECT * FROM accounts WHERE name = ?').get(name); + updated.paused = !!updated.paused; + ok(updated); + }); +} + +function setPaused(paused) { + const name = requireFlag(flags, 'name'); + withDb((db) => { + const r = db.prepare('UPDATE accounts SET paused = ? WHERE name = ?').run(paused ? 1 : 0, name); + if (r.changes === 0) throw new Error(`Account '${name}' not found`); + ok({ name, paused }); + }); +} + +function rename() { + const oldName = requireFlag(flags, 'name'); + const newName = requireFlag(flags, 'new-name'); + withDb((db) => { + const tx = db.transaction(() => { + const r = db.prepare('UPDATE accounts SET name = ? WHERE name = ?').run(newName, oldName); + if (r.changes === 0) throw new Error(`Account '${oldName}' not found`); + }); + try { + tx(); + } catch (e) { + if (String(e).includes('UNIQUE')) throw new Error(`Account '${newName}' already exists`); + throw e; + } + ok({ renamed_from: oldName, renamed_to: newName }); + }); +} + +function remove() { + const name = requireFlag(flags, 'name'); + const force = boolFlag(flags, 'force'); + withDb((db) => { + const n = db + .prepare('SELECT COUNT(*) AS c FROM leads WHERE owner_account = ?') + .get(name).c; + if (n > 0 && !force) { + throw new Error(`Account '${name}' owns ${n} leads — pass --force to delete the account and reassign-to-nothing (leads remain but become orphaned).`); + } + if (n > 0) info(`warning: ${n} leads remain with owner_account='${name}' (orphaned).`); + const r = db.prepare('DELETE FROM accounts WHERE name = ?').run(name); + if (r.changes === 0) throw new Error(`Account '${name}' not found`); + ok({ removed: name, orphaned_leads: n }); + }); +} diff --git a/skills/linkedin-growth/scripts/db.mjs b/skills/linkedin-growth/scripts/db.mjs new file mode 100644 index 0000000..eecd569 --- /dev/null +++ b/skills/linkedin-growth/scripts/db.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { parseArgs } from './lib/args.mjs'; +import { openDb, migrate } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; +import { dbPath } from './lib/paths.mjs'; + +const { positional } = parseArgs(); +const cmd = positional[0] ?? 'init'; + +try { + if (cmd === 'init' || cmd === 'migrate') { + const db = openDb(); + const applied = migrate(db); + const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get().v; + db.close(); + ok({ path: dbPath(), schema_version: version, migrations_applied: applied }); + } else if (cmd === 'version') { + const db = openDb({ readonly: true }); + const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get(); + db.close(); + ok({ path: dbPath(), schema_version: row?.v ?? 0 }); + } else if (cmd === 'path') { + ok({ path: dbPath() }); + } else { + fail(`unknown command: ${cmd}. Use: init | migrate | version | path`, 5); + } +} catch (err) { + fail(err.message); +} diff --git a/skills/linkedin-growth/scripts/doctor.mjs b/skills/linkedin-growth/scripts/doctor.mjs new file mode 100644 index 0000000..4c95f9a --- /dev/null +++ b/skills/linkedin-growth/scripts/doctor.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { platform } from 'node:os'; +import { parseArgs, boolFlag } from './lib/args.mjs'; +import { ok, fail } from './lib/output.mjs'; +import { runCommand, runLinkedin, parseCliAccounts } from './lib/cli.mjs'; +import { SKILL_ROOT, dbPath, dataDir } from './lib/paths.mjs'; + +const { flags } = parseArgs(); +const fix = boolFlag(flags, 'fix'); + +const checks = []; + +async function check(name, fn) { + try { + const result = await fn(); + checks.push({ name, ok: true, ...result }); + } catch (err) { + checks.push({ name, ok: false, message: err.message, ...(err.extra ?? {}) }); + } +} + +function fail2(message, extra) { + const e = new Error(message); + if (extra) e.extra = extra; + throw e; +} + +await check('node', async () => { + const major = Number(process.versions.node.split('.')[0]); + if (major < 20) fail2(`Node ${process.versions.node} is too old; need >= 20`); + return { version: process.versions.node }; +}); + +await check('skill-deps', async () => { + const nm = join(SKILL_ROOT, 'node_modules'); + if (!existsSync(nm)) { + if (fix) { + const r = await runCommand('npm', ['install', '--omit=dev'], { timeoutMs: 300000 }); + if (!r.ok) fail2(`npm install failed: ${r.stderr || r.error}`); + return { installed: true }; + } + fail2('node_modules missing — run: npm install --omit=dev (or rerun doctor with --fix)'); + } + return {}; +}); + +await check('linkedin-cli', async () => { + const r = await runCommand('linkedin', ['--version'], { timeoutMs: 15000 }); + if (!r.ok) { + fail2('linkedin-cli not installed — run: npm install -g @linkedapi/linkedin-cli', { + remediation: 'npm install -g @linkedapi/linkedin-cli', + }); + } + return { version: (r.stdout || '').trim() }; +}); + +await check('cli-accounts', async () => { + const r = await runLinkedin(['account', 'list'], { timeoutMs: 15000 }); + if (!r.ok) fail2(`linkedin account list failed: ${r.stderr || r.error || 'exit ' + r.exitCode}`); + const accounts = parseCliAccounts(r.stdout); + if (accounts.length === 0) { + fail2( + 'No LinkedIn accounts registered in linkedin-cli — run: linkedin setup --linked-api-token=... --identification-token=...', + ); + } + return { count: accounts.length, accounts }; +}); + +await check('db', async () => { + const { openDb } = await import('./lib/db.mjs'); + const db = openDb(); + const v = db.prepare('SELECT MAX(version) AS v FROM schema_version').get().v; + db.close(); + return { path: dbPath(), schema_version: v }; +}); + +await check('db-accounts', async () => { + const { openDb } = await import('./lib/db.mjs'); + const db = openDb({ readonly: true }); + const n = db.prepare('SELECT COUNT(*) AS c FROM accounts').get().c; + db.close(); + if (n === 0) { + fail2('0 accounts in DB — register each linkedin-cli account: node scripts/account.mjs add'); + } + return { count: n }; +}); + +await check('scheduler', async () => { + const p = platform(); + if (p === 'darwin') { + const r = await runCommand('which', ['launchctl'], { timeoutMs: 5000 }); + if (!r.ok) fail2('launchctl not found on PATH'); + return { kind: 'launchd' }; + } + if (p === 'linux') { + const r = await runCommand('systemctl', ['--user', '--version'], { timeoutMs: 5000 }); + if (r.ok) return { kind: 'systemd-user' }; + const c = await runCommand('which', ['crontab'], { timeoutMs: 5000 }); + if (c.ok) return { kind: 'cron', note: 'systemd-user unavailable; will use crontab' }; + fail2('neither systemctl --user nor crontab available'); + } + if (p === 'win32') { + const r = await runCommand('schtasks', ['/Query', '/?'], { timeoutMs: 5000 }); + if (!r.ok) fail2('schtasks not available on PATH'); + return { kind: 'schtasks' }; + } + fail2(`unsupported platform: ${p}`); +}); + +await check('data-dir', async () => { + return { path: dataDir() }; +}); + +const allOk = checks.every((c) => c.ok); +if (allOk) { + ok({ ok: true, checks }); +} else { + const data = { ok: false, checks }; + if (process.argv.includes('--json')) { + process.stdout.write(`${JSON.stringify({ success: true, data })}\n`); + process.exit(0); + } else { + for (const c of checks) { + const mark = c.ok ? 'OK ' : 'FAIL'; + const info = c.ok + ? Object.entries(c) + .filter(([k]) => !['name', 'ok'].includes(k)) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(' ') + : c.message; + process.stdout.write(`[${mark}] ${c.name} ${info}\n`); + } + process.exit(1); + } +} diff --git a/skills/linkedin-growth/scripts/export.mjs b/skills/linkedin-growth/scripts/export.mjs new file mode 100644 index 0000000..be791b7 --- /dev/null +++ b/skills/linkedin-growth/scripts/export.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +import { parseArgs } from './lib/args.mjs'; +import { openDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; + +const { flags } = parseArgs(); + +try { + const format = String(flags.format ?? 'json').toLowerCase(); + if (!['json', 'csv'].includes(format)) throw new Error('--format must be json or csv'); + const out = flags.output ? String(flags.output) : null; + + const db = openDb({ readonly: true }); + const where = []; + const vals = []; + if (flags.account) { + where.push('owner_account = ?'); + vals.push(String(flags.account)); + } + if (flags.status) { + where.push('status = ?'); + vals.push(String(flags.status)); + } + const sql = `SELECT * FROM leads ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY created_at`; + const rows = db.prepare(sql).all(...vals); + db.close(); + + let payload; + if (format === 'json') { + payload = JSON.stringify(rows, null, 2); + } else { + payload = toCsv(rows); + } + + if (out) { + writeFileSync(out, payload); + ok({ output: out, rows: rows.length, format }); + } else { + process.stdout.write(payload); + if (!payload.endsWith('\n')) process.stdout.write('\n'); + } +} catch (err) { + fail(err.message); +} + +function toCsv(rows) { + if (rows.length === 0) return ''; + const cols = Object.keys(rows[0]); + const escape = (v) => { + if (v === null || v === undefined) return ''; + const s = String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = cols.join(','); + const body = rows.map((r) => cols.map((c) => escape(r[c])).join(',')).join('\n'); + return `${header}\n${body}\n`; +} diff --git a/skills/linkedin-growth/scripts/import.mjs b/skills/linkedin-growth/scripts/import.mjs new file mode 100644 index 0000000..7eab03f --- /dev/null +++ b/skills/linkedin-growth/scripts/import.mjs @@ -0,0 +1,406 @@ +#!/usr/bin/env node +import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { parseArgs, requireFlag, boolFlag } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail, info } from './lib/output.mjs'; +import { runLinkedin } from './lib/cli.mjs'; +import { tmpDir, ensureDir } from './lib/paths.mjs'; +import { qualificationPromptPath, defaults } from './lib/config.mjs'; +import { getSetting } from './lib/settings.mjs'; + +const { positional, flags } = parseArgs(); +const cmd = positional[0]; + +try { + switch (cmd) { + case 'prepare': + await prepare(); + break; + case 'commit': + commit(); + break; + case 'list': + list(); + break; + case 'show': + show(); + break; + case 'abort': + abort(); + break; + default: + fail( + 'Usage:\n' + + ' import.mjs prepare --searcher --list --limit [--type nv|st] [--search-url ] [--term ...] [filter flags...]\n' + + ' import.mjs commit --batch --results \n' + + ' import.mjs list [--state pending_qualification|committed|aborted]\n' + + ' import.mjs show --batch \n' + + ' import.mjs abort --batch ', + 5, + ); + } +} catch (err) { + fail(err.message); +} + +async function prepare() { + const searcherName = requireFlag(flags, 'searcher'); + const listName = requireFlag(flags, 'list'); + const type = String(flags.type ?? 'nv').toLowerCase(); + if (!['nv', 'st'].includes(type)) throw new Error('--type must be nv or st'); + + const searchUrl = flags['search-url'] ? String(flags['search-url']) : undefined; + const { limit, capApplied } = resolveLimit(flags.limit, type); + + const account = withDb( + (db) => db.prepare('SELECT * FROM accounts WHERE name = ?').get(searcherName), + { readonly: true }, + ); + if (!account) throw new Error(`Searcher account '${searcherName}' not found in DB`); + + info(`Running ${type === 'nv' ? 'Sales Navigator' : 'regular'} search via ${account.cli_account}...`); + + let cliResult; + if (searchUrl) { + const def = { + actionType: type === 'nv' ? 'nv.searchPeople' : 'st.searchPeople', + customSearchUrl: searchUrl, + }; + if (limit) def.limit = limit; + cliResult = await runLinkedin(['workflow', 'run'], { + cliAccount: account.cli_account, + input: JSON.stringify(def), + }); + } else { + const args = type === 'nv' ? ['navigator', 'person', 'search'] : ['person', 'search']; + if (flags.term) args.push('--term', String(flags.term)); + if (limit) args.push('--limit', String(limit)); + for (const f of [ + 'first-name', + 'last-name', + 'position', + 'locations', + 'industries', + 'current-companies', + 'previous-companies', + 'schools', + ]) { + if (flags[f]) args.push(`--${f}`, String(flags[f])); + } + if (type === 'nv' && flags['years-of-experience']) { + args.push('--years-of-experience', String(flags['years-of-experience'])); + } + cliResult = await runLinkedin(args, { cliAccount: account.cli_account }); + } + + if (!cliResult.ok) { + throw new Error( + `linkedin search failed (exit ${cliResult.exitCode}): ${cliResult.stderr || cliResult.error || 'no stderr'}`, + ); + } + if (!cliResult.json) throw new Error('linkedin returned non-JSON output'); + if (cliResult.json.success === false) { + throw new Error(`linkedin error: ${JSON.stringify(cliResult.json.error)}`); + } + + const items = extractSearchItems(cliResult.json.data); + if (!Array.isArray(items)) { + throw new Error( + `unexpected search response shape: ${JSON.stringify(cliResult.json.data).slice(0, 300)}`, + ); + } + + const normalized = items + .map((it) => normalizeCandidate(it, type)) + .filter((c) => c && c.hashed_url); + + if (normalized.length === 0) { + info('Search returned 0 candidates.'); + } + + const batchId = randomUUID(); + const dir = ensureDir(tmpDir()); + const candidateFile = join(dir, `qualify-${batchId}.candidates.json`); + const resultFile = join(dir, `qualify-${batchId}.results.json`); + + // Filter out candidates already present in DB to save qualification work + const { newCandidates, alreadyExisting } = withDb( + (db) => { + const stmt = db.prepare('SELECT 1 FROM leads WHERE hashed_url = ?'); + const fresh = []; + let existing = 0; + for (const c of normalized) { + if (stmt.get(c.hashed_url)) existing++; + else fresh.push(c); + } + return { newCandidates: fresh, alreadyExisting: existing }; + }, + { readonly: true }, + ); + + writeFileSync(candidateFile, JSON.stringify(newCandidates, null, 2)); + + const icp = withDb((db) => getSetting(db, 'icp_definition', null), { readonly: true }); + const icpConfigured = Boolean(icp && icp.trim()); + + withDb((db) => { + db.prepare( + `INSERT INTO import_batches (id, list_name, searcher_account, search_url, search_type, candidate_count, skipped_existing_count, state) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending_qualification')`, + ).run(batchId, listName, searcherName, searchUrl ?? null, type, newCandidates.length, alreadyExisting); + }); + + const commitCmd = `node scripts/import.mjs commit --batch ${batchId} --results ${resultFile}`; + let nextStep; + if (newCandidates.length === 0) { + nextStep = 'Nothing new to qualify (all candidates already in the DB). Run abort, or import a different search.'; + } else if (!icpConfigured) { + nextStep = + 'No ICP is configured yet. Do NOT guess who to keep. Ask the user who they want to ' + + 'reach and who to filter out (roles, industries, company size, locations, hard exclusions), ' + + `save it with: node scripts/settings.mjs set icp_definition --file , then qualify ${candidateFile} ` + + `against it using ${qualificationPromptPath()}, write ${resultFile} as [{hashed_url, suitable, reasoning}], then: ${commitCmd}`; + } else { + nextStep = + `An ICP is already configured (see icp_definition below). Confirm with the user whether to use it as-is ` + + `or adjust it for this list (if changed, re-save via settings.mjs set icp_definition). Then read ${candidateFile}, ` + + `qualify each lead against the ICP using the contract in ${qualificationPromptPath()}, write ${resultFile} as a ` + + `JSON array [{hashed_url, suitable, reasoning}] covering every candidate, then: ${commitCmd}. ` + + `After commit, report the keep/filter breakdown with a few sample reasons so the decision is transparent.`; + } + + ok({ + batch_id: batchId, + list_name: listName, + searcher: searcherName, + search_type: type, + limit_used: limit, + limit_capped_to_max: capApplied, + search_max_for_type: type === 'nv' ? defaults().search_max_limit_nv : defaults().search_max_limit_st, + found_total: items.length, + skipped_existing: alreadyExisting, + candidates_to_qualify: newCandidates.length, + candidate_file: candidateFile, + expected_result_file: resultFile, + qualification_prompt: qualificationPromptPath(), + icp_configured: icpConfigured, + icp_definition: icp ?? null, + next_step: nextStep, + }); +} + +// The dedicated CLI operations (navigator person search / person search) map the +// completion down to a clean array at cli.json.data. The URL path goes through +// `workflow run` (customWorkflow), which returns the raw action completion +// { actionType, success, data: [people] }, so the array sits at cli.json.data.data. +// Per docs each element is a person object directly. Handle both wrappings. +function extractSearchItems(raw) { + if (Array.isArray(raw)) return raw; + if (Array.isArray(raw?.data)) return raw.data; + return []; +} + +// The limit is mandatory: the agent must ask the user how many of the found leads to take. +// A number is clamped to the search type's cap; 'max'/'all' means the cap itself. +// Caps are LinkedIn/Linked API maximums per search type (nv = Sales Navigator, st = standard). +function resolveLimit(rawFlag, type) { + const cap = type === 'nv' ? defaults().search_max_limit_nv : defaults().search_max_limit_st; + if (rawFlag === undefined || rawFlag === true || String(rawFlag).trim() === '') { + throw new Error( + `Missing --limit. Ask the user how many of the found leads to take: a number, or 'max' ` + + `for the maximum (${type} cap is ${cap}). Then pass --limit .`, + ); + } + const v = String(rawFlag).trim().toLowerCase(); + if (v === 'max' || v === 'all') return { limit: cap, capApplied: false }; + const n = Number(v); + if (!Number.isInteger(n) || n < 1) throw new Error("--limit must be a positive integer or 'max'"); + if (n > cap) return { limit: cap, capApplied: true }; + return { limit: n, capApplied: false }; +} + +function normalizeCandidate(item, type) { + if (type === 'nv') { + return { + hashed_url: item.hashedUrl ?? null, + public_url: item.publicUrl ?? null, + full_name: item.name ?? null, + position: item.position ?? item.headline ?? null, + location: item.location ?? null, + }; + } + return { + hashed_url: item.publicUrl ?? null, + public_url: item.publicUrl ?? null, + full_name: item.name ?? null, + position: item.position ?? item.headline ?? null, + location: item.location ?? null, + }; +} + +function commit() { + const batchId = requireFlag(flags, 'batch'); + const resultsPath = requireFlag(flags, 'results'); + + const results = JSON.parse(readFileSync(resultsPath, 'utf8')); + if (!Array.isArray(results)) throw new Error('--results file must be a JSON array'); + + withDb((db) => { + const batch = db.prepare('SELECT * FROM import_batches WHERE id = ?').get(batchId); + if (!batch) throw new Error(`Batch '${batchId}' not found`); + if (batch.state !== 'pending_qualification') { + throw new Error(`Batch is in state '${batch.state}', cannot commit`); + } + + const activeAccounts = db + .prepare('SELECT name, daily_invite_limit FROM accounts WHERE paused = 0 ORDER BY name') + .all(); + if (activeAccounts.length === 0) { + throw new Error('No active (non-paused) accounts available for lead assignment'); + } + + // Assign each lead to the active account with the lowest projected runway + // (not_connected ÷ daily_invite_limit). Seeding each account's load from its + // current not_connected count means that, regardless of differing daily limits + // or pre-existing backlogs, every account's queue drains at roughly the same + // time instead of the lowest-limit account lagging far behind. + const loadByName = new Map(activeAccounts.map((a) => [a.name, 0])); + const limitByName = new Map(activeAccounts.map((a) => [a.name, a.daily_invite_limit])); + for (const row of db + .prepare( + "SELECT owner_account, COUNT(*) AS c FROM leads WHERE status = 'not_connected' GROUP BY owner_account", + ) + .all()) { + if (loadByName.has(row.owner_account)) loadByName.set(row.owner_account, row.c); + } + + function pickOwner() { + let best = activeAccounts[0].name; + let bestRunway = loadByName.get(best) / limitByName.get(best); + for (const a of activeAccounts) { + const runway = loadByName.get(a.name) / limitByName.get(a.name); + if (runway < bestRunway) { + best = a.name; + bestRunway = runway; + } + } + return best; + } + + // Re-load candidates from candidate file path stored in the batch dir + const candidateFile = join(tmpDir(), `qualify-${batchId}.candidates.json`); + const candidates = JSON.parse(readFileSync(candidateFile, 'utf8')); + const byHash = new Map(candidates.map((c) => [c.hashed_url, c])); + + const insert = db.prepare( + `INSERT OR IGNORE INTO leads + (hashed_url, public_url, full_name, position, location, list_name, reasoning, + owner_account, status, status_updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'not_connected', datetime('now'))`, + ); + const updateCursor = db.prepare( + 'UPDATE import_state SET last_assigned_account = ? WHERE id = 1', + ); + + const tx = db.transaction(() => { + let suitable = 0; + let unsuitable = 0; + let skippedExisting = 0; + let skippedMissing = 0; + let assigned = 0; + let lastAssigned = null; + + for (const r of results) { + if (!r || !r.hashed_url) { + skippedMissing++; + continue; + } + const cand = byHash.get(r.hashed_url); + if (!cand) { + skippedMissing++; + continue; + } + if (!r.suitable) { + unsuitable++; + continue; + } + suitable++; + const owner = pickOwner(); + const result = insert.run( + cand.hashed_url, + cand.public_url, + cand.full_name, + cand.position, + cand.location, + batch.list_name, + r.reasoning ?? null, + owner, + ); + if (result.changes === 1) { + assigned++; + loadByName.set(owner, loadByName.get(owner) + 1); + lastAssigned = owner; + } else { + skippedExisting++; + } + } + updateCursor.run(lastAssigned); + + db.prepare( + `UPDATE import_batches + SET state = 'committed', qualified_count = ?, committed_count = ?, + skipped_existing_count = COALESCE(skipped_existing_count, 0) + ?, + committed_at = datetime('now') + WHERE id = ?`, + ).run(suitable, assigned, skippedExisting, batchId); + + return { suitable, unsuitable, assigned, skippedExisting, skippedMissing }; + }); + + const stats = tx(); + ok({ batch_id: batchId, ...stats }); + }); +} + +function list() { + const state = flags.state ? String(flags.state) : null; + withDb( + (db) => { + const sql = state + ? 'SELECT * FROM import_batches WHERE state = ? ORDER BY created_at DESC' + : 'SELECT * FROM import_batches ORDER BY created_at DESC'; + const rows = state ? db.prepare(sql).all(state) : db.prepare(sql).all(); + ok(rows); + }, + { readonly: true }, + ); +} + +function show() { + const batchId = requireFlag(flags, 'batch'); + withDb( + (db) => { + const batch = db.prepare('SELECT * FROM import_batches WHERE id = ?').get(batchId); + if (!batch) throw new Error(`Batch '${batchId}' not found`); + ok(batch); + }, + { readonly: true }, + ); +} + +function abort() { + const batchId = requireFlag(flags, 'batch'); + withDb((db) => { + const r = db + .prepare( + "UPDATE import_batches SET state = 'aborted' WHERE id = ? AND state = 'pending_qualification'", + ) + .run(batchId); + if (r.changes === 0) { + throw new Error(`Batch '${batchId}' not found or not in pending_qualification state`); + } + ok({ batch_id: batchId, state: 'aborted' }); + }); +} diff --git a/skills/linkedin-growth/scripts/lead.mjs b/skills/linkedin-growth/scripts/lead.mjs new file mode 100644 index 0000000..ac0a13a --- /dev/null +++ b/skills/linkedin-growth/scripts/lead.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +import { parseArgs, requireFlag, intFlag } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; + +const { positional, flags } = parseArgs(); +const cmd = positional[0]; + +try { + switch (cmd) { + case 'list': + list(); + break; + case 'show': + show(); + break; + case 'reset': + reset(); + break; + case 'set-status': + setStatus(); + break; + case 'reassign': + reassign(); + break; + case 'delete': + remove(); + break; + default: + fail( + 'Usage:\n' + + ' lead.mjs list [--account X] [--status not_connected|pending|connected|exhausted|error] [--list "List Name"] [--limit N]\n' + + ' lead.mjs show \n' + + ' lead.mjs reset (error -> not_connected)\n' + + ' lead.mjs set-status --to \n' + + ' lead.mjs reassign --to \n' + + ' lead.mjs delete ', + 5, + ); + } +} catch (err) { + fail(err.message); +} + +function list() { + const limit = intFlag(flags, 'limit', 50); + const where = []; + const vals = []; + if (flags.account) { + where.push('owner_account = ?'); + vals.push(String(flags.account)); + } + if (flags.status) { + where.push('status = ?'); + vals.push(String(flags.status)); + } + if (flags.list) { + where.push('list_name = ?'); + vals.push(String(flags.list)); + } + const sql = `SELECT hashed_url, full_name, position, location, owner_account, status, + sent_at, status_updated_at, list_name + FROM leads + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY status_updated_at DESC + LIMIT ?`; + vals.push(limit); + withDb( + (db) => { + const rows = db.prepare(sql).all(...vals); + ok(rows); + }, + { readonly: true }, + ); +} + +function show() { + const key = positional[1]; + if (!key) throw new Error('Pass the lead identifier as a positional argument'); + withDb( + (db) => { + const lead = + db.prepare('SELECT * FROM leads WHERE hashed_url = ? OR public_url = ?').get(key, key) ?? + db.prepare('SELECT * FROM leads WHERE LOWER(full_name) = LOWER(?)').get(key); + if (!lead) throw new Error(`Lead '${key}' not found`); + const runs = db + .prepare( + `SELECT id, account, action, started_at, finished_at, success, error_message + FROM runs WHERE lead_hashed_url = ? ORDER BY started_at DESC LIMIT 25`, + ) + .all(lead.hashed_url); + // Accounts that have already sent this lead an invite (the cross-account retry trail). + const attempted = db + .prepare( + `SELECT DISTINCT account FROM runs + WHERE lead_hashed_url = ? AND action = 'invite' AND success = 1`, + ) + .all(lead.hashed_url) + .map((r) => r.account) + .filter(Boolean); + ok({ lead, attempted_accounts: attempted, recent_runs: runs }); + }, + { readonly: true }, + ); +} + +function reset() { + const key = positional[1]; + if (!key) throw new Error('Pass the lead hashed_url as a positional argument'); + withDb((db) => { + const r = db + .prepare( + `UPDATE leads SET status='not_connected', error_type=NULL, error_message=NULL, + status_updated_at=datetime('now') + WHERE hashed_url = ? AND status = 'error'`, + ) + .run(key); + if (r.changes === 0) throw new Error(`Lead '${key}' not found or not in error state`); + ok({ hashed_url: key, status: 'not_connected' }); + }); +} + +function setStatus() { + const key = positional[1]; + const target = requireFlag(flags, 'to'); + const allowed = ['not_connected', 'pending', 'connected', 'exhausted', 'error']; + if (!allowed.includes(target)) throw new Error(`--to must be one of ${allowed.join(', ')}`); + withDb((db) => { + const r = db + .prepare( + `UPDATE leads SET status = ?, status_updated_at = datetime('now') + WHERE hashed_url = ?`, + ) + .run(target, key); + if (r.changes === 0) throw new Error(`Lead '${key}' not found`); + ok({ hashed_url: key, status: target }); + }); +} + +function reassign() { + const key = positional[1]; + const target = requireFlag(flags, 'to'); + withDb((db) => { + const acc = db.prepare('SELECT name FROM accounts WHERE name = ?').get(target); + if (!acc) throw new Error(`Account '${target}' not found`); + const r = db + .prepare( + `UPDATE leads SET owner_account = ?, status_updated_at = datetime('now') + WHERE hashed_url = ?`, + ) + .run(target, key); + if (r.changes === 0) throw new Error(`Lead '${key}' not found`); + ok({ hashed_url: key, owner_account: target }); + }); +} + +function remove() { + const key = positional[1]; + withDb((db) => { + const r = db.prepare('DELETE FROM leads WHERE hashed_url = ?').run(key); + if (r.changes === 0) throw new Error(`Lead '${key}' not found`); + ok({ deleted: key }); + }); +} diff --git a/skills/linkedin-growth/scripts/lib/account.mjs b/skills/linkedin-growth/scripts/lib/account.mjs new file mode 100644 index 0000000..52def08 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/account.mjs @@ -0,0 +1,27 @@ +export function getAccountOrFail(db, name) { + const row = db.prepare('SELECT * FROM accounts WHERE name = ?').get(name); + if (!row) throw new Error(`Account '${name}' not found in DB`); + row.paused = !!row.paused; + return row; +} + +export function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +export function isFatalExitCode(code) { + return code === 4 || code === 6; +} + +// Stores the openPersonPage completion (the action object: { actionType, success, +// data: { ...personInfo }, then }) minus the long free-text `about` field, which is +// dropped to keep the stored payload compact. +export function trimBasicInfoForStorage(completion) { + try { + const clone = JSON.parse(JSON.stringify(completion)); + if (clone?.data?.about) delete clone.data.about; + return JSON.stringify(clone); + } catch { + return JSON.stringify(completion); + } +} diff --git a/skills/linkedin-growth/scripts/lib/args.mjs b/skills/linkedin-growth/scripts/lib/args.mjs new file mode 100644 index 0000000..fa5b227 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/args.mjs @@ -0,0 +1,49 @@ +export function parseArgs(argv = process.argv.slice(2)) { + const positional = []; + const flags = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const eq = a.indexOf('='); + if (eq !== -1) { + flags[a.slice(2, eq)] = a.slice(eq + 1); + } else { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + flags[key] = true; + } else { + flags[key] = next; + i++; + } + } + } else { + positional.push(a); + } + } + return { positional, flags }; +} + +export function requireFlag(flags, name) { + const v = flags[name]; + if (v === undefined || v === true || v === '') { + throw new Error(`Missing required flag: --${name}`); + } + return String(v); +} + +export function intFlag(flags, name, fallback) { + if (flags[name] === undefined) return fallback; + const n = Number(flags[name]); + if (!Number.isFinite(n)) throw new Error(`Invalid number for --${name}`); + return n; +} + +export function boolFlag(flags, name, fallback = false) { + if (flags[name] === undefined) return fallback; + if (flags[name] === true) return true; + const v = String(flags[name]).toLowerCase(); + if (v === 'true' || v === '1' || v === 'yes') return true; + if (v === 'false' || v === '0' || v === 'no') return false; + return Boolean(flags[name]); +} diff --git a/skills/linkedin-growth/scripts/lib/cli.mjs b/skills/linkedin-growth/scripts/lib/cli.mjs new file mode 100644 index 0000000..6c77b35 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/cli.mjs @@ -0,0 +1,90 @@ +import { spawn } from 'node:child_process'; + +const LINKEDAPI_CLIENT = 'skill:linkedin-growth'; + +export function runLinkedin(args, { cliAccount, input, timeoutMs } = {}) { + // oclif requires the command/topic tokens first, then flags: + // `linkedin --json -q --account ""`. Flags before the command + // make oclif treat the first flag as the command name (exit 2). + const finalArgs = [...args, '--json', '-q']; + if (cliAccount) finalArgs.push('--account', cliAccount); + return runCommand('linkedin', finalArgs, { + input, + timeoutMs, + env: { ...process.env, LINKEDAPI_CLIENT }, + }); +} + +export function runCommand(command, args, { input, timeoutMs, env } = {}) { + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: env ?? process.env, + }); + let stdout = ''; + let stderr = ''; + let killed = false; + // No timeout by default: long-running LinkedIn scrapes (e.g. a max Sales + // Navigator search) must run to completion. Callers that need a fast-fail + // probe (such as the doctor health checks) opt in by passing timeoutMs. + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + }, timeoutMs); + + child.stdout.on('data', (b) => (stdout += b.toString())); + child.stderr.on('data', (b) => (stderr += b.toString())); + child.on('error', (err) => { + if (timer) clearTimeout(timer); + resolve({ ok: false, exitCode: -1, stdout, stderr, error: err.message }); + }); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + resolve({ + ok: code === 0 && !killed, + exitCode: code ?? -1, + stdout, + stderr, + killed, + json: tryParse(stdout), + }); + }); + + if (input !== undefined) { + child.stdin.write(input); + child.stdin.end(); + } else { + child.stdin.end(); + } + }); +} + +// `linkedin account list` ignores --json and prints a human table: +// * Jane Doe (id_abc...123) +// John Smith (id_def...456) +// The leading `*` marks the active account. Returns the account display names. +export function parseCliAccounts(stdout) { + return stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !/^no accounts/i.test(l)) + .map((l) => { + const withoutMarker = l.replace(/^\*\s*/, ''); + const m = withoutMarker.match(/^(.*?)\s*\([^()]*\)\s*$/); + return m ? m[1].trim() : null; + }) + .filter(Boolean); +} + +function tryParse(s) { + const trimmed = s.trim(); + if (!trimmed) return undefined; + try { + return JSON.parse(trimmed); + } catch { + return undefined; + } +} diff --git a/skills/linkedin-growth/scripts/lib/config.mjs b/skills/linkedin-growth/scripts/lib/config.mjs new file mode 100644 index 0000000..ae34f8a --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/config.mjs @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SKILL_ROOT } from './paths.mjs'; + +let cached = null; + +export function defaults() { + if (!cached) { + const raw = readFileSync(join(SKILL_ROOT, 'config', 'defaults.json'), 'utf8'); + cached = JSON.parse(raw); + } + return cached; +} + +export function qualificationPromptPath() { + return join(SKILL_ROOT, 'config', 'qualification-prompt.md'); +} diff --git a/skills/linkedin-growth/scripts/lib/db.mjs b/skills/linkedin-growth/scripts/lib/db.mjs new file mode 100644 index 0000000..fc6fdd1 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/db.mjs @@ -0,0 +1,180 @@ +import Database from 'better-sqlite3'; +import { dbPath, ensureDir, dataDir } from './paths.mjs'; +import { dirname } from 'node:path'; + +// MIGRATIONS ARE APPEND-ONLY AND IMMUTABLE. Once an entry has shipped, never edit it — +// existing databases record the highest applied version and will not re-run earlier +// entries, so an in-place edit silently diverges fresh DBs from upgraded ones. To change +// the schema, ADD a new entry. Each entry is either a SQL string (run with db.exec) or a +// function(db) for logic that must inspect the current schema (e.g. ALTER TABLE guarded +// by PRAGMA table_info). Entry index + 1 is its version number. +const MIGRATIONS = [ + // 1: initial schema + ` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS accounts ( + name TEXT PRIMARY KEY, + cli_account TEXT NOT NULL, + paused INTEGER NOT NULL DEFAULT 0, + daily_invite_limit INTEGER NOT NULL DEFAULT 35, + min_invite_interval_minutes INTEGER NOT NULL DEFAULT 15, + active_start TEXT NOT NULL DEFAULT '09:00', + active_end TEXT NOT NULL DEFAULT '18:00', + max_pending_days INTEGER NOT NULL DEFAULT 10, + pending_batch_size INTEGER NOT NULL DEFAULT 5, + last_action_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS leads ( + hashed_url TEXT PRIMARY KEY, + public_url TEXT, + full_name TEXT NOT NULL, + position TEXT, + location TEXT, + list_name TEXT, + reasoning TEXT, + owner_account TEXT NOT NULL REFERENCES accounts(name) ON UPDATE CASCADE, + basic_info_json TEXT, + status TEXT NOT NULL DEFAULT 'not_connected', + sent_at TEXT, + status_updated_at TEXT NOT NULL DEFAULT (datetime('now')), + error_type TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_leads_owner_status ON leads(owner_account, status); + CREATE INDEX IF NOT EXISTS idx_leads_status_sent ON leads(status, sent_at); + CREATE INDEX IF NOT EXISTS idx_leads_created ON leads(created_at); + CREATE INDEX IF NOT EXISTS idx_leads_public_url ON leads(public_url); + + CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_hashed_url TEXT REFERENCES leads(hashed_url) ON DELETE SET NULL, + account TEXT, + action TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT, + success INTEGER, + raw_response_json TEXT, + error_message TEXT + ); + CREATE INDEX IF NOT EXISTS idx_runs_lead ON runs(lead_hashed_url); + CREATE INDEX IF NOT EXISTS idx_runs_account_started ON runs(account, started_at); + + CREATE TABLE IF NOT EXISTS import_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_assigned_account TEXT + ); + INSERT OR IGNORE INTO import_state (id, last_assigned_account) VALUES (1, NULL); + + CREATE TABLE IF NOT EXISTS import_batches ( + id TEXT PRIMARY KEY, + list_name TEXT NOT NULL, + searcher_account TEXT NOT NULL, + search_url TEXT, + search_type TEXT NOT NULL, + candidate_count INTEGER NOT NULL DEFAULT 0, + qualified_count INTEGER, + committed_count INTEGER, + skipped_existing_count INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + committed_at TEXT, + state TEXT NOT NULL DEFAULT 'pending_qualification' + ); + `, + // 2: reconcile the accounts table for databases created before columns were added/renamed + // during early development. No-op on a fresh DB (entry 1 already has the full schema). + reconcileAccountsTable, + // 3: global key/value settings (e.g. max_connect_attempts for cross-account retry). + ` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + INSERT OR IGNORE INTO settings (key, value) VALUES ('max_connect_attempts', '1'); + `, +]; + +// Brings any older `accounts` table up to the current schema by adding whatever columns +// are missing and folding renamed columns forward. Idempotent and safe on a fresh DB. +function reconcileAccountsTable(db) { + const cols = new Set(db.prepare('PRAGMA table_info(accounts)').all().map((c) => c.name)); + const addColumn = (name, ddl) => { + if (!cols.has(name)) { + db.exec(`ALTER TABLE accounts ADD COLUMN ${ddl}`); + cols.add(name); + } + }; + + addColumn('min_invite_interval_minutes', 'min_invite_interval_minutes INTEGER NOT NULL DEFAULT 15'); + addColumn('active_start', "active_start TEXT NOT NULL DEFAULT '09:00'"); + addColumn('active_end', "active_end TEXT NOT NULL DEFAULT '18:00'"); + addColumn('pending_batch_size', 'pending_batch_size INTEGER NOT NULL DEFAULT 5'); + addColumn('last_action_at', 'last_action_at TEXT'); + + // Fold older columns forward (kept in place — old SQLite can't easily DROP COLUMN, and + // no code reads them anymore, so they are harmless). + if (cols.has('cron_time')) { + db.exec('UPDATE accounts SET active_start = cron_time'); + } + if (cols.has('last_run_at')) { + db.exec('UPDATE accounts SET last_action_at = last_run_at WHERE last_action_at IS NULL'); + } +} + +export function openDb({ readonly = false } = {}) { + const path = dbPath(); + ensureDir(dirname(path)); + ensureDir(dataDir()); + // A readonly connection never migrates, and a readonly open on a missing file throws. + // So before opening readonly, briefly open a writable connection to create the file (if + // missing) AND apply any pending migrations (e.g. after a skill upgrade). This keeps read + // commands correct without a separate `db.mjs init` step. + if (readonly) { + const w = new Database(path); + w.pragma('journal_mode = WAL'); + w.pragma('busy_timeout = 5000'); + migrate(w); + w.close(); + } + const db = new Database(path, { readonly, fileMustExist: readonly }); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + // Per-account workers run concurrently now, so several short writes can land at once. + // Wait (don't fail) up to 5s for the write lock instead of throwing SQLITE_BUSY. + db.pragma('busy_timeout = 5000'); + if (!readonly) migrate(db); + return db; +} + +export function migrate(db) { + db.exec(`CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + );`); + const current = db.prepare('SELECT MAX(version) AS v FROM schema_version').get()?.v ?? 0; + for (let i = current; i < MIGRATIONS.length; i++) { + const version = i + 1; + const migration = MIGRATIONS[i]; + db.transaction(() => { + if (typeof migration === 'function') migration(db); + else db.exec(migration); + db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version); + })(); + } + return MIGRATIONS.length; +} + +export function withDb(fn, opts) { + const db = openDb(opts); + try { + return fn(db); + } finally { + db.close(); + } +} diff --git a/skills/linkedin-growth/scripts/lib/output.mjs b/skills/linkedin-growth/scripts/lib/output.mjs new file mode 100644 index 0000000..7b802f3 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/output.mjs @@ -0,0 +1,59 @@ +function isJsonRequested() { + return process.argv.includes('--json'); +} + +export function emit(payload) { + if (isJsonRequested()) { + process.stdout.write(`${JSON.stringify(payload)}\n`); + } else { + process.stdout.write(`${formatHuman(payload)}\n`); + } +} + +export function ok(data) { + emit({ success: true, data }); +} + +export function fail(error, exitCode = 1) { + const body = { success: false, error: typeof error === 'string' ? { message: error } : error }; + if (isJsonRequested()) { + process.stdout.write(`${JSON.stringify(body)}\n`); + } else { + process.stderr.write(`error: ${body.error.message || JSON.stringify(body.error)}\n`); + } + process.exit(exitCode); +} + +export function info(message) { + if (!isJsonRequested()) process.stderr.write(`${message}\n`); +} + +function formatHuman(payload) { + if (payload && typeof payload === 'object' && 'success' in payload) { + if (!payload.success) return `error: ${payload.error?.message ?? JSON.stringify(payload.error)}`; + return formatValue(payload.data); + } + return formatValue(payload); +} + +function formatValue(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value) && value.every((row) => row && typeof row === 'object')) { + return formatTable(value); + } + return JSON.stringify(value, null, 2); +} + +function formatTable(rows) { + if (rows.length === 0) return '(no rows)'; + const cols = Array.from(new Set(rows.flatMap((row) => Object.keys(row)))); + const widths = cols.map((c) => + Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)), + ); + const fmt = (vals) => vals.map((v, i) => String(v).padEnd(widths[i])).join(' '); + const head = fmt(cols); + const sep = widths.map((w) => '-'.repeat(w)).join(' '); + const body = rows.map((r) => fmt(cols.map((c) => r[c] ?? ''))).join('\n'); + return `${head}\n${sep}\n${body}`; +} diff --git a/skills/linkedin-growth/scripts/lib/paths.mjs b/skills/linkedin-growth/scripts/lib/paths.mjs new file mode 100644 index 0000000..e702e45 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/paths.mjs @@ -0,0 +1,36 @@ +import { homedir, platform } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync } from 'node:fs'; + +const APP_NAME = 'linkedapi-linkedin-growth'; + +export const SKILL_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); + +export function dataDir() { + if (process.env.LEADS_DATA_DIR) return process.env.LEADS_DATA_DIR; + if (platform() === 'win32') { + const base = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'); + return join(base, APP_NAME); + } + const xdg = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'); + return join(xdg, APP_NAME); +} + +export function dbPath() { + if (process.env.LEADS_DB_PATH) return process.env.LEADS_DB_PATH; + return join(dataDir(), 'db.sqlite'); +} + +export function logsDir() { + return join(dataDir(), 'logs'); +} + +export function tmpDir() { + return join(dataDir(), 'tmp'); +} + +export function ensureDir(path) { + mkdirSync(path, { recursive: true }); + return path; +} diff --git a/skills/linkedin-growth/scripts/lib/retry.mjs b/skills/linkedin-growth/scripts/lib/retry.mjs new file mode 100644 index 0000000..d12992b --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/retry.mjs @@ -0,0 +1,88 @@ +import { getSetting } from './settings.mjs'; + +// Distinct accounts that have already sent an invite to this lead (success=1). +// The lead's current owner is among them, since it sent the request that just failed. +export function attemptedAccounts(db, leadHashedUrl) { + return db + .prepare( + `SELECT DISTINCT account FROM runs + WHERE lead_hashed_url = ? AND action = 'invite' AND success = 1`, + ) + .all(leadHashedUrl) + .map((r) => r.account) + .filter(Boolean); +} + +// The Core error message returned when a profile URL resolves to no LinkedIn member. +const PERSON_NOT_FOUND_MESSAGE = 'not an existing LinkedIn person'; + +// Count the most recent consecutive `check_status` runs for a lead that failed with +// personNotFound. Used to terminate a lead only after a short streak, so a single +// transient miss does not close an otherwise-reachable person. +export function countTrailingPersonNotFound(db, leadHashedUrl) { + const rows = db + .prepare( + `SELECT success, error_message FROM runs + WHERE lead_hashed_url = ? AND action = 'check_status' + ORDER BY started_at DESC, id DESC + LIMIT 10`, + ) + .all(leadHashedUrl); + let streak = 0; + for (const row of rows) { + const isPersonNotFound = !row.success && (row.error_message ?? '').includes(PERSON_NOT_FOUND_MESSAGE); + if (!isPersonNotFound) break; + streak++; + } + return streak; +} + +// The configured cap on how many distinct accounts may attempt one lead. +// 'all' resolves to the current count of active accounts. +export function resolveMaxAttempts(db) { + const raw = getSetting(db, 'max_connect_attempts', '1'); + if (raw === 'all') { + return db.prepare('SELECT COUNT(*) AS c FROM accounts WHERE paused = 0').get().c; + } + const n = Number(raw); + return Number.isFinite(n) && n >= 1 ? n : 1; +} + +// Applied when an account's attempt on a lead failed (we withdrew a stale pending, OR +// the person declined / let it expire). Either hands the lead to another account for a +// fresh attempt, or marks it terminally 'exhausted'. +// +// Reassignment picks the least-loaded active account that has NOT yet tried this lead, +// so retries spread work the same way the import round-robin does. +export function resolveFailedAttempt(db, lead) { + const tried = new Set(attemptedAccounts(db, lead.hashed_url)); + const maxAttempts = resolveMaxAttempts(db); + + const eligible = db + .prepare( + `SELECT a.name AS name, + (SELECT COUNT(*) FROM leads l + WHERE l.owner_account = a.name AND l.status IN ('not_connected','pending')) AS load + FROM accounts a + WHERE a.paused = 0 + ORDER BY load ASC, a.name ASC`, + ) + .all() + .filter((a) => !tried.has(a.name)); + + if (tried.size < maxAttempts && eligible.length > 0) { + const next = eligible[0].name; + db.prepare( + `UPDATE leads SET owner_account = ?, status = 'not_connected', sent_at = NULL, + status_updated_at = datetime('now'), error_type = NULL, error_message = NULL + WHERE hashed_url = ?`, + ).run(next, lead.hashed_url); + return { outcome: 'reassigned', account: next, attempt_number: tried.size + 1 }; + } + + db.prepare( + `UPDATE leads SET status = 'exhausted', status_updated_at = datetime('now') + WHERE hashed_url = ?`, + ).run(lead.hashed_url); + return { outcome: 'exhausted', attempts: tried.size }; +} diff --git a/skills/linkedin-growth/scripts/lib/runs.mjs b/skills/linkedin-growth/scripts/lib/runs.mjs new file mode 100644 index 0000000..e221637 --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/runs.mjs @@ -0,0 +1,21 @@ +export function recordRunStart(db, { leadHashedUrl, account, action }) { + const r = db + .prepare( + `INSERT INTO runs (lead_hashed_url, account, action, started_at) + VALUES (?, ?, ?, datetime('now'))`, + ) + .run(leadHashedUrl ?? null, account ?? null, action); + return r.lastInsertRowid; +} + +export function recordRunFinish(db, runId, { success, rawResponse, errorMessage }) { + db.prepare( + `UPDATE runs SET finished_at = datetime('now'), success = ?, raw_response_json = ?, error_message = ? + WHERE id = ?`, + ).run( + success ? 1 : 0, + rawResponse !== undefined ? JSON.stringify(rawResponse) : null, + errorMessage ?? null, + runId, + ); +} diff --git a/skills/linkedin-growth/scripts/lib/settings.mjs b/skills/linkedin-growth/scripts/lib/settings.mjs new file mode 100644 index 0000000..0f57bcf --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/settings.mjs @@ -0,0 +1,15 @@ +export function getSetting(db, key, fallback) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? row.value : fallback; +} + +export function setSetting(db, key, value) { + db.prepare( + `INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ).run(key, String(value)); +} + +export function allSettings(db) { + return db.prepare('SELECT key, value FROM settings ORDER BY key').all(); +} diff --git a/skills/linkedin-growth/scripts/lib/time.mjs b/skills/linkedin-growth/scripts/lib/time.mjs new file mode 100644 index 0000000..5cbe52d --- /dev/null +++ b/skills/linkedin-growth/scripts/lib/time.mjs @@ -0,0 +1,36 @@ +// DB timestamps are stored as UTC strings 'YYYY-MM-DD HH:MM:SS' (SQLite datetime('now')). +// The active window is expressed in the machine's LOCAL time. These helpers bridge the two. + +export function toDbUtc(date) { + return date.toISOString().slice(0, 19).replace('T', ' '); +} + +// Parses a DB UTC string back to a Date (absolute instant). +export function parseDbUtc(s) { + if (!s) return null; + return new Date(`${s.replace(' ', 'T')}Z`); +} + +// UTC string for the start of the current LOCAL day — use to bound "today" quotas +// at local midnight regardless of the machine's timezone offset from UTC. +export function startOfLocalDayUtc(now = new Date()) { + const d = new Date(now); + d.setHours(0, 0, 0, 0); + return toDbUtc(d); +} + +// Local wall-clock "HH:MM" for comparing against active_start / active_end. +export function localHHMM(now = new Date()) { + const d = new Date(now); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +export function hhmmToMinutes(hhmm) { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} + +export function minutesSince(date, now = new Date()) { + if (!date) return Infinity; + return (now.getTime() - date.getTime()) / 60000; +} diff --git a/skills/linkedin-growth/scripts/migrate-notion.mjs b/skills/linkedin-growth/scripts/migrate-notion.mjs new file mode 100644 index 0000000..d4694f0 --- /dev/null +++ b/skills/linkedin-growth/scripts/migrate-notion.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node +// +// ONE-OFF migration: Notion "Lead" DB (under the "Social selling" page) -> skill SQLite. +// +// Mapping rules (agreed with the user): +// - A lead PROCESSED by a real account (status Connected/Pending/Withdrawn/Error) is tied +// to that real account (vlad / kiril). If both processed it, priority decides the owner: +// Connected > Pending > Withdrawn > Error +// Status map: Connected->connected, Pending->pending, Withdrawn->exhausted, Error->error. +// - A lead NOT processed by either account (both "Not connected") -> not_connected, owner +// assigned round-robin across the chosen accounts (default: non-paused accounts). +// - Dedup by hashed_url, both within Notion (same person across lists) and vs the existing DB. +// - For every account that actually sent a request, a synthetic `runs` invite row is written +// (started_at = original send date, so it never counts toward TODAY's quota). This makes +// the cross-account retry attribution correct for migrated `pending` leads. +// +// DRY-RUN by default (no writes). Pass --apply to write. +// Flags: --apply --refetch --rr=vlad,alex,maksim --pending=keep|exhaust +// +import Database from 'better-sqlite3'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const HOME = process.env.HOME; +const DATA_DIR = join(HOME, '.local/share/linkedapi-linkedin-growth'); +const TOKEN = readFileSync(join(DATA_DIR, '.notion-token'), 'utf8').trim(); +const DB_PATH = join(DATA_DIR, 'db.sqlite'); +const CACHE = join(DATA_DIR, 'tmp/notion-leads-cache.json'); +const PREVIEW = join(DATA_DIR, 'tmp/migration-preview.json'); +const NOTION_DB = '28665216-8ab0-809c-a8dc-d8fe366d0266'; +const HIST = '2026-01-01 00:00:00'; // fallback historical timestamp (safely before today) + +const args = process.argv.slice(2); +const APPLY = args.includes('--apply'); +const REFETCH = args.includes('--refetch'); +const rrArg = (args.find((a) => a.startsWith('--rr=')) || '').split('=')[1]; +const pendingMode = (args.find((a) => a.startsWith('--pending=')) || '').split('=')[1] || 'keep'; + +const H = { Authorization: 'Bearer ' + TOKEN, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' }; +const rt = (p) => (p?.rich_text || []).map((t) => t.plain_text).join(''); +const ti = (p) => (p?.title || []).map((t) => t.plain_text).join(''); +const norm = (s) => (s || '').trim().toLowerCase(); +const RANK = { connected: 4, pending: 3, withdrawn: 2, error: 1 }; + +function mapStatus(oldStatus) { + const n = norm(oldStatus); + if (n === 'connected') return 'connected'; + if (n === 'pending') return pendingMode === 'exhaust' ? 'exhausted' : 'pending'; + if (n === 'withdrawn') return 'exhausted'; + if (n === 'error') return 'error'; + return 'not_connected'; +} + +async function fetchAll() { + if (existsSync(CACHE) && !REFETCH) { + const cached = JSON.parse(readFileSync(CACHE, 'utf8')); + process.stderr.write(`using cache (${cached.length} rows); pass --refetch to refresh\n`); + return cached; + } + let cursor; + const rows = []; + do { + const body = { page_size: 100 }; + if (cursor) body.start_cursor = cursor; + const r = await fetch(`https://api.notion.com/v1/databases/${NOTION_DB}/query`, { + method: 'POST', headers: H, body: JSON.stringify(body), + }); + const j = await r.json(); + if (j.object === 'error') throw new Error('Notion error: ' + j.message); + for (const row of j.results) { + const p = row.properties; + rows.push({ + full_name: ti(p['Full Name']), + hashed_url: rt(p['Hashed URL']).trim(), + public_url: rt(p['Public URL']).trim(), + list_name: rt(p['List Name']).trim(), + reasoning: rt(p['Reasoning']), + basic_info: rt(p['Basic Info']), + st_vlad: p['Status with Vlad']?.status?.name || '', + st_kiril: p['Status with Kiril']?.status?.name || '', + sent_vlad: p['Sent from Vlad']?.date?.start || null, + sent_kiril: p['Sent from Kiril']?.date?.start || null, + }); + } + cursor = j.has_more ? j.next_cursor : undefined; + process.stderr.write(`\rfetched ${rows.length}...`); + } while (cursor); + process.stderr.write('\n'); + writeFileSync(CACHE, JSON.stringify(rows)); + return rows; +} + +function bestFor(rowsArr, stKey, sentKey) { + let bestRank = 0; + let bestStatus = ''; + let sent = null; + for (const r of rowsArr) { + const rk = RANK[norm(r[stKey])] ?? 0; + if (rk > bestRank) { bestRank = rk; bestStatus = r[stKey]; } + if (r[sentKey] && (!sent || r[sentKey] < sent)) sent = r[sentKey]; + } + return { rank: bestRank, status: bestStatus, sent }; +} + +function extractPositionLocation(basicInfo) { + if (!basicInfo) return { position: null, location: null }; + try { + const parsed = JSON.parse(basicInfo); + const d = parsed.data ?? parsed; + return { position: d.position ?? d.headline ?? null, location: d.location ?? null }; + } catch { + return { position: null, location: null }; + } +} + +function firstNonEmpty(rowsArr, key) { + for (const r of rowsArr) if (r[key]) return r[key]; + return null; +} + +async function main() { + const rows = await fetchAll(); + + // --- collapse by hashed_url --- + const groups = new Map(); + let noHash = 0; + for (const r of rows) { + if (!r.hashed_url) { noHash++; continue; } + let g = groups.get(r.hashed_url); + if (!g) { g = []; groups.set(r.hashed_url, g); } + g.push(r); + } + + // --- existing DB state --- + const db = new Database(DB_PATH, { readonly: !APPLY }); + db.pragma('foreign_keys = ON'); + const existing = new Set(db.prepare('SELECT hashed_url FROM leads').all().map((r) => r.hashed_url)); + const accountNames = new Set(db.prepare('SELECT name FROM accounts').all().map((r) => r.name)); + let rr = rrArg + ? rrArg.split(',').map((s) => s.trim()).filter(Boolean) + : db.prepare('SELECT name FROM accounts WHERE paused = 0 ORDER BY name').all().map((r) => r.name); + for (const a of rr) if (!accountNames.has(a)) throw new Error(`--rr account '${a}' not in DB`); + if (rr.length === 0) throw new Error('no round-robin accounts available'); + const cursor = db.prepare('SELECT last_assigned_account FROM import_state WHERE id = 1').get()?.last_assigned_account ?? null; + let rrIdx = cursor && rr.includes(cursor) ? (rr.indexOf(cursor) + 1) % rr.length : 0; + + // --- build planned rows --- + const planned = []; + const runs = []; + let skippedExisting = 0; + let multiList = 0; + const byStatus = {}; + const byOwner = {}; + const rrSplit = {}; + let lastAssigned = cursor; + + for (const [hashed_url, gRows] of groups) { + if (existing.has(hashed_url)) { skippedExisting++; continue; } + + const v = bestFor(gRows, 'st_vlad', 'sent_vlad'); + const k = bestFor(gRows, 'st_kiril', 'sent_kiril'); + const cands = [{ acct: 'vlad', ...v }, { acct: 'kiril', ...k }]; + const maxRank = Math.max(v.rank, k.rank); + + let owner; + let status; + let sent_at = null; + if (maxRank > 0) { + const winners = cands.filter((c) => c.rank === maxRank).sort((a, b) => { + const ad = a.sent ? 0 : 1; + const bd = b.sent ? 0 : 1; + if (ad !== bd) return ad - bd; + if (a.sent && b.sent) return a.sent < b.sent ? -1 : 1; + return a.acct < b.acct ? -1 : 1; + }); + const w = winners[0]; + owner = w.acct; + status = mapStatus(w.status); + sent_at = w.sent; + if (status === 'pending' && !sent_at) sent_at = HIST; // keep it checkable + } else { + status = 'not_connected'; + owner = rr[rrIdx]; + rrIdx = (rrIdx + 1) % rr.length; + rrSplit[owner] = (rrSplit[owner] || 0) + 1; + lastAssigned = owner; + } + + // synthetic invite runs for each account that actually sent a request + for (const c of cands) { + if (c.rank > 0) { + runs.push({ + hashed_url, + account: c.acct, + success: norm(c.status) === 'error' ? 0 : 1, + started_at: c.sent || HIST, + }); + } + } + + const { position, location } = extractPositionLocation(firstNonEmpty(gRows, 'basic_info')); + const distinctLists = new Set(gRows.map((r) => r.list_name).filter(Boolean)); + if (distinctLists.size > 1) multiList++; + + planned.push({ + hashed_url, + public_url: firstNonEmpty(gRows, 'public_url'), + full_name: firstNonEmpty(gRows, 'full_name') || '(unknown)', + position, + location, + list_name: firstNonEmpty(gRows, 'list_name'), + reasoning: firstNonEmpty(gRows, 'reasoning'), + owner_account: owner, + basic_info_json: firstNonEmpty(gRows, 'basic_info'), + status, + sent_at, + error_type: status === 'error' ? 'migrated' : null, + error_message: status === 'error' ? 'Migrated from Notion (status: Error)' : null, + }); + byStatus[status] = (byStatus[status] || 0) + 1; + byOwner[owner] = (byOwner[owner] || 0) + 1; + } + + // --- report --- + const summary = { + apply: APPLY, + pending_mode: pendingMode, + rr_accounts: rr, + notion_rows: rows.length, + rows_without_hash_skipped: noHash, + distinct_people: groups.size, + duplicates_collapsed: rows.length - noHash - groups.size, + skipped_already_in_db: skippedExisting, + to_insert: planned.length, + by_status: byStatus, + by_owner: byOwner, + round_robin_split: rrSplit, + synthetic_runs: runs.length, + multi_list_people: multiList, + }; + console.log(JSON.stringify(summary, null, 2)); + writeFileSync(PREVIEW, JSON.stringify({ summary, sample: planned.slice(0, 25) }, null, 2)); + console.log(`\npreview (summary + 25 sample rows) written to: ${PREVIEW}`); + + if (!APPLY) { + console.log('\nDRY-RUN only. Re-run with --apply to write.'); + db.close(); + return; + } + + // --- apply --- + const insLead = db.prepare( + `INSERT OR IGNORE INTO leads + (hashed_url, public_url, full_name, position, location, list_name, reasoning, + owner_account, basic_info_json, status, sent_at, status_updated_at, error_type, error_message) + VALUES (@hashed_url, @public_url, @full_name, @position, @location, @list_name, @reasoning, + @owner_account, @basic_info_json, @status, @sent_at, @status_updated_at, @error_type, @error_message)`, + ); + const insRun = db.prepare( + `INSERT INTO runs (lead_hashed_url, account, action, started_at, finished_at, success, raw_response_json, error_message) + VALUES (?, ?, 'invite', ?, ?, ?, NULL, 'migrated from Notion')`, + ); + let inserted = 0; + const tx = db.transaction(() => { + for (const p of planned) { + const res = insLead.run({ ...p, status_updated_at: p.sent_at || HIST }); + if (res.changes === 1) inserted++; + } + for (const r of runs) insRun.run(r.hashed_url, r.account, r.started_at, r.started_at, r.success); + if (lastAssigned) db.prepare('UPDATE import_state SET last_assigned_account = ? WHERE id = 1').run(lastAssigned); + }); + tx(); + console.log(`\nAPPLIED: inserted ${inserted} leads, ${runs.length} synthetic runs. rr cursor -> ${lastAssigned}`); + db.close(); +} + +main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); }); diff --git a/skills/linkedin-growth/scripts/network-invite.mjs b/skills/linkedin-growth/scripts/network-invite.mjs new file mode 100644 index 0000000..6e990a9 --- /dev/null +++ b/skills/linkedin-growth/scripts/network-invite.mjs @@ -0,0 +1,378 @@ +#!/usr/bin/env node +import { parseArgs, requireFlag, intFlag } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail, info } from './lib/output.mjs'; +import { runLinkedin } from './lib/cli.mjs'; +import { getAccountOrFail, sleep, isFatalExitCode, trimBasicInfoForStorage } from './lib/account.mjs'; +import { recordRunStart, recordRunFinish } from './lib/runs.mjs'; +import { defaults } from './lib/config.mjs'; +import { startOfLocalDayUtc } from './lib/time.mjs'; + +const { flags } = parseArgs(); + +try { + await main(); +} catch (err) { + fail(err.message); +} + +async function main() { + const accountName = requireFlag(flags, 'account'); + const delay = intFlag(flags, 'delay-seconds', defaults().invite_delay_seconds); + const userLimit = intFlag(flags, 'limit', undefined); + + const account = withDb((db) => getAccountOrFail(db, accountName), { readonly: true }); + if (account.paused) { + ok({ skipped: 'paused', account: account.name }); + return; + } + + const dayStartUtc = startOfLocalDayUtc(); + const sentToday = withDb( + (db) => + db + .prepare( + `SELECT COUNT(*) AS c FROM runs + WHERE account = ? AND action = 'invite' AND success = 1 + AND started_at >= ?`, + ) + .get(account.name, dayStartUtc).c, + { readonly: true }, + ); + const remainingByPolicy = Math.max(0, account.daily_invite_limit - sentToday); + const budget = userLimit !== undefined ? Math.min(userLimit, remainingByPolicy) : remainingByPolicy; + if (budget === 0) { + ok({ + account: account.name, + sent_today: sentToday, + daily_limit: account.daily_invite_limit, + processed: 0, + message: 'daily invite limit reached', + }); + return; + } + + // Prefer never-tried leads, then least-recently-attempted, so a lead that keeps failing + // transiently rotates to the back instead of blocking the whole queue. (NULL last_try + // sorts first in SQLite ASC.) + const leads = withDb( + (db) => + db + .prepare( + `SELECT l.hashed_url, l.public_url, l.full_name, + (SELECT MAX(r.started_at) FROM runs r + WHERE r.lead_hashed_url = l.hashed_url AND r.action = 'invite') AS last_try + FROM leads l + WHERE l.owner_account = ? AND l.status = 'not_connected' + ORDER BY last_try ASC, l.created_at ASC + LIMIT ?`, + ) + .all(account.name, budget), + { readonly: true }, + ); + + if (leads.length === 0) { + ok({ account: account.name, processed: 0, message: 'no not_connected leads' }); + return; + } + + const summary = { + processed: 0, pending: 0, connected: 0, transient: 0, limited: 0, + restricted_backoff: 0, restricted_closed: 0, restricted_deferred: 0, + errors: 0, aborted: false, + }; + for (const lead of leads) { + // Prefer the hashed member URL (stable across name / vanity-URL changes) so a reassigned + // lead whose stored public slug has gone stale still opens. Falls back to public_url for + // `st` leads, where the hashed_url IS the public URL. + const personUrl = lead.hashed_url || lead.public_url; + const def = { + actionType: 'st.openPersonPage', + personUrl, + basicInfo: true, + then: { actionType: 'st.sendConnectionRequest' }, + }; + + let runId; + withDb((db) => { + runId = recordRunStart(db, { + leadHashedUrl: lead.hashed_url, + account: account.name, + action: 'invite', + }); + }); + + info(`[invite] ${lead.full_name} <${personUrl}>`); + const cli = await runLinkedin(['workflow', 'run'], { + cliAccount: account.cli_account, + input: JSON.stringify(def), + }); + + if (isFatalExitCode(cli.exitCode)) { + withDb((db) => + recordRunFinish(db, runId, { + success: false, + rawResponse: cli.json, + errorMessage: `fatal exit ${cli.exitCode}: ${cli.stderr || cli.error || ''}`.trim(), + }), + ); + summary.aborted = true; + summary.abort_reason = `linkedin-cli exit ${cli.exitCode}`; + break; + } + + const outcome = classifyInviteResult(cli); + + if (outcome.status === 'limited') { + // The account hit its connection-request limit (platform-side, action category). + // This is NOT a verdict on the lead — leave it not_connected and back off so we + // don't burn the rest of the queue against the same wall. The lead is retried on a + // later wake-up, once the account's limit recovers (or is raised). + withDb((db) => + recordRunFinish(db, runId, { + success: false, + rawResponse: cli.json, + errorMessage: outcome.errorMessage ?? 'account action limit reached', + }), + ); + summary.limited++; + summary.aborted = true; + summary.abort_reason = 'account invite limit reached'; + break; + } + + if (outcome.status === 'restricted') { + // LinkedIn returned "restricted sending a connection request". Two distinct causes, + // same message — disambiguate by pattern (see classifyRestricted): + // - streak (back-to-back, no successes between) => the account's weekly invite + // limit. NOT the lead's fault: leave it not_connected, back off, do not count it. + // - isolated (account otherwise sending fine) => this person restricts invites. + // Count it against the lead; after RESTRICTED_LEAD_ATTEMPTS isolated hits, close + // the lead (terminal 'exhausted') so it never hangs forever. + withDb((db) => + recordRunFinish(db, runId, { + success: false, + rawResponse: cli.json, + errorMessage: outcome.errorMessage ?? 'connection request not allowed', + }), + ); + const decision = withDb((db) => classifyRestricted(db, account.name, lead.hashed_url)); + if (decision === 'streak') { + summary.restricted_backoff++; + summary.aborted = true; + summary.abort_reason = 'account restricted (likely weekly invite limit)'; + break; + } + if (decision === 'terminate') { + withDb((db) => + db + .prepare( + `UPDATE leads SET status='exhausted', status_updated_at=datetime('now'), + error_type='requestNotAllowed', error_message=? WHERE hashed_url=?`, + ) + .run(outcome.errorMessage ?? null, lead.hashed_url), + ); + summary.restricted_closed++; + } else { + summary.restricted_deferred++; // left not_connected, retried much later + } + summary.processed++; + if (summary.processed < leads.length) await sleep(delay * 1000); + continue; + } + + const sent = outcome.status === 'pending' || outcome.status === 'connected'; + withDb((db) => { + recordRunFinish(db, runId, { + success: sent, + rawResponse: cli.json, + errorMessage: outcome.errorMessage ?? null, + }); + applyInviteOutcome(db, lead, outcome, cli.json); + }); + + summary.processed++; + if (outcome.status === 'pending') summary.pending++; + else if (outcome.status === 'connected') summary.connected++; + else if (outcome.status === 'transient') summary.transient++; + else summary.errors++; + + if (summary.processed < leads.length) { + await sleep(delay * 1000); + } + } + + withDb((db) => + db.prepare("UPDATE accounts SET last_action_at = datetime('now') WHERE name = ?").run(account.name), + ); + + ok({ + account: account.name, + daily_limit: account.daily_invite_limit, + sent_today_before: sentToday, + budget, + ...summary, + }); +} + +// Five kinds of outcome: +// pending/connected — the request was sent (or they were already connected). +// limited — the account hit its platform-side limit for the connection-request +// action category. NOT a verdict on the lead → keep it not_connected +// and back off (the caller aborts the rest of this account's cycle). +// The lead is retried on a later wake-up. +// restricted — LinkedIn "restricted sending a connection request". Ambiguous between +// the account's weekly invite limit (streak) and a person who restricts +// invites (isolated). The caller disambiguates via classifyRestricted. +// transient — an infra/session hiccup (no parseable response, CLI error, or the +// profile page never opened). NOT a verdict on the lead → keep it +// not_connected and retry later. Common on a cold first run. +// error — a real per-person failure: the profile opened but +// sendConnectionRequest returned a definite failure. Terminal. +function classifyInviteResult(cli) { + // No parseable JSON, or the CLI itself failed (non-fatal exit) → transient. + if (!cli.json || cli.exitCode !== 0) { + return { + status: 'transient', + errorType: 'transient', + errorMessage: (cli.stderr || cli.error || `no response (exit ${cli.exitCode})`) + .toString() + .trim() + .slice(0, 200), + }; + } + const body = cli.json; + if (body.success === false) { + const topErrType = (body.error?.type ?? '').toLowerCase(); + if (isLimitError(topErrType)) { + return { + status: 'limited', + errorType: body.error?.type || 'limitExceeded', + errorMessage: body.error?.message ?? 'account action limit reached', + }; + } + return { status: 'transient', errorType: body.error?.type ?? 'requestError', errorMessage: body.error?.message ?? 'request error' }; + } + // completion = the st.openPersonPage result. The chained sendConnectionRequest result is + // at completion.then (sibling) or completion.data.then (nested). Accept either. + const completion = body.data ?? {}; + const then = completion.then ?? completion.data?.then; + if (then && then.success === true) return { status: 'pending' }; + const thenErrType = (then?.error?.type ?? '').toLowerCase(); + if (thenErrType.includes('alreadypending')) return { status: 'pending' }; + if (thenErrType.includes('alreadyconnected')) return { status: 'connected' }; + if (then && then.success === false) { + if (thenErrType.includes('noteslimitexceeded')) { + return { + status: 'limited', + errorType: then.error?.type || 'noteLimitExceeded', + errorMessage: then.error?.message ?? 'account personalized invite note limit reached', + }; + } + + // Account-level rate / action-category limit (platform-side) → back off, don't burn + // the lead. It stays not_connected and is retried on a later wake-up. + if (isLimitError(thenErrType)) { + return { + status: 'limited', + errorType: then.error?.type || 'limitExceeded', + errorMessage: then.error?.message ?? 'account action limit reached', + }; + } + // "LinkedIn has restricted sending a connection request" — ambiguous: either the account's + // weekly invite limit (comes in a streak) or this person restricting invites (isolated). + // The caller (classifyRestricted) disambiguates by pattern; never burn it blindly here. + if (thenErrType.includes('requestnotallowed')) { + return { + status: 'restricted', + errorType: then.error?.type || 'requestNotAllowed', + errorMessage: then.error?.message ?? 'LinkedIn restricted sending a connection request', + }; + } + // The profile opened but the request was genuinely refused for this person → terminal. + return { + status: 'error', + errorType: then.error?.type || 'unknown', + errorMessage: then.error?.message ?? 'connection request failed', + }; + } + // No `then` result at all → the profile page never opened (session/page issue) → transient. + return { + status: 'transient', + errorType: completion.error?.type ?? 'openFailed', + errorMessage: completion.error?.message ?? 'profile did not open', + }; +} + +// An account-level limit on an action category (e.g. "configured limit for this action +// category has been exceeded", a rate limit, or too-many-requests). Signals back-off, not +// a per-lead failure. `type` is already lower-cased by the caller. +function isLimitError(type) { + return type.includes('limit') || type.includes('toomany'); +} + +// How many ISOLATED requestNotAllowed hits a single lead may take before we close it. +const RESTRICTED_LEAD_ATTEMPTS = 2; + +// Disambiguate a fresh requestNotAllowed (already recorded as a failed run) for this account: +// 'streak' — 2+ requestNotAllowed in a row with no successful invite since → the +// account's weekly invite limit. Back off; do NOT blame the lead. +// 'terminate' — isolated, and this lead has now hit the cap → close it (terminal). +// 'defer' — isolated, lead under the cap → leave not_connected, retry much later. +function classifyRestricted(db, account, hashedUrl) { + const RNA = '%restricted sending a connection request%'; + const lastOk = db + .prepare(`SELECT MAX(started_at) AS t FROM runs WHERE account = ? AND action = 'invite' AND success = 1`) + .get(account).t; + const streak = db + .prepare( + `SELECT COUNT(*) AS c FROM runs + WHERE account = ? AND action = 'invite' AND success = 0 + AND error_message LIKE ? AND started_at > COALESCE(?, '0')`, + ) + .get(account, RNA, lastOk).c; + if (streak >= 2) return 'streak'; + const leadHits = db + .prepare( + `SELECT COUNT(*) AS c FROM runs + WHERE lead_hashed_url = ? AND action = 'invite' AND error_message LIKE ?`, + ) + .get(hashedUrl, RNA).c; + return leadHits >= RESTRICTED_LEAD_ATTEMPTS ? 'terminate' : 'defer'; +} + +function applyInviteOutcome(db, lead, outcome, payload) { + // payload = cli.json = { success, data: } + // The completion is { actionType, success, data: { ...personInfo, publicUrl }, then }. + const completion = payload?.data ?? {}; + const personData = completion?.data ?? {}; + const basicInfo = trimBasicInfoForStorage(completion); + const publicUrl = personData?.publicUrl ?? lead.public_url ?? null; + + if (outcome.status === 'pending') { + db.prepare( + `UPDATE leads SET status='pending', sent_at = datetime('now'), + status_updated_at = datetime('now'), public_url = COALESCE(?, public_url), + basic_info_json = ?, error_type = NULL, error_message = NULL + WHERE hashed_url = ?`, + ).run(publicUrl, basicInfo, lead.hashed_url); + } else if (outcome.status === 'connected') { + db.prepare( + `UPDATE leads SET status='connected', sent_at = datetime('now'), + status_updated_at = datetime('now'), public_url = COALESCE(?, public_url), + basic_info_json = ?, error_type = NULL, error_message = NULL + WHERE hashed_url = ?`, + ).run(publicUrl, basicInfo, lead.hashed_url); + } else if (outcome.status === 'transient' || outcome.status === 'limited') { + // Infra/session hiccup or an account-level limit — leave the lead not_connected so it + // retries. The failed attempt is captured in the runs table for audit; we don't touch + // the lead's status. (The main loop also handles `limited` by aborting the cycle.) + return; + } else { + db.prepare( + `UPDATE leads SET status='error', status_updated_at = datetime('now'), + error_type = ?, error_message = ?, basic_info_json = COALESCE(?, basic_info_json) + WHERE hashed_url = ?`, + ).run(outcome.errorType ?? 'unknown', outcome.errorMessage ?? null, basicInfo, lead.hashed_url); + } +} diff --git a/skills/linkedin-growth/scripts/network-pending.mjs b/skills/linkedin-growth/scripts/network-pending.mjs new file mode 100644 index 0000000..0b7c7df --- /dev/null +++ b/skills/linkedin-growth/scripts/network-pending.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import { parseArgs, requireFlag, intFlag } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail, info } from './lib/output.mjs'; +import { runLinkedin } from './lib/cli.mjs'; +import { getAccountOrFail, isFatalExitCode } from './lib/account.mjs'; +import { recordRunStart, recordRunFinish } from './lib/runs.mjs'; +import { resolveFailedAttempt, countTrailingPersonNotFound } from './lib/retry.mjs'; + +// How many consecutive personNotFound status checks before a lead is closed terminally. +// We query the stable hashed member URL, so a streak means the member is genuinely gone +// (deleted/deactivated) rather than a stale public slug — there is nothing left to retry. +const PERSON_NOT_FOUND_ATTEMPTS = 2; + +const { flags } = parseArgs(); + +try { + await main(); +} catch (err) { + fail(err.message); +} + +async function main() { + const accountName = requireFlag(flags, 'account'); + const account = withDb((db) => getAccountOrFail(db, accountName), { readonly: true }); + if (account.paused) { + ok({ skipped: 'paused', account: account.name }); + return; + } + const minDays = intFlag(flags, 'min-days', account.max_pending_days); + const limit = intFlag(flags, 'limit', undefined); + + const leads = withDb( + (db) => + db + .prepare( + `SELECT hashed_url, public_url, full_name, sent_at + FROM leads + WHERE owner_account = ? AND status = 'pending' + AND sent_at IS NOT NULL + AND sent_at < datetime('now', ?) + ORDER BY sent_at ASC + ${limit !== undefined ? 'LIMIT ?' : ''}`, + ) + .all(...(limit !== undefined ? [account.name, `-${minDays} days`, limit] : [account.name, `-${minDays} days`])), + { readonly: true }, + ); + + if (leads.length === 0) { + ok({ account: account.name, min_days: minDays, processed: 0 }); + return; + } + + const summary = { + processed: 0, + connected: 0, + reassigned: 0, + exhausted: 0, + errors: 0, + aborted: false, + }; + + for (const lead of leads) { + // Prefer the hashed member URL: it is stable across name / vanity-URL changes, whereas a + // stored public slug goes dead the moment the person renames (then `connection status` + // returns personNotFound forever). Fall back to public_url for `st` leads, where the + // hashed_url IS the public URL. + const personUrl = lead.hashed_url || lead.public_url; + + let runId; + withDb((db) => { + runId = recordRunStart(db, { + leadHashedUrl: lead.hashed_url, + account: account.name, + action: 'check_status', + }); + }); + + info(`[check] ${lead.full_name} <${personUrl}>`); + const cli = await runLinkedin(['connection', 'status', personUrl], { + cliAccount: account.cli_account, + }); + + if (isFatalExitCode(cli.exitCode)) { + withDb((db) => + recordRunFinish(db, runId, { + success: false, + rawResponse: cli.json, + errorMessage: `fatal exit ${cli.exitCode}: ${cli.stderr || cli.error || ''}`.trim(), + }), + ); + summary.aborted = true; + summary.abort_reason = `linkedin-cli exit ${cli.exitCode}`; + break; + } + + const statusBody = cli.json ?? {}; + if (statusBody.success === false) { + const errorType = statusBody.error?.type; + const errorMessage = statusBody.error?.message ?? `exit ${cli.exitCode}`; + withDb((db) => + recordRunFinish(db, runId, { success: false, rawResponse: cli.json, errorMessage }), + ); + summary.processed++; + + // personNotFound means the URL no longer resolves to a LinkedIn member. We query the + // stable hashed member URL above, so this is a permanent condition (deleted/deactivated + // account), not a stale public slug — retrying it every cycle just burns workflow runs + // and never resolves. After a short streak, close the lead terminally instead of + // leaving it pending forever. Other (transient) errors keep the previous behaviour. + const shouldExhaust = + errorType === 'personNotFound' && + withDb((db) => countTrailingPersonNotFound(db, lead.hashed_url)) >= PERSON_NOT_FOUND_ATTEMPTS; + if (shouldExhaust) { + withDb((db) => + db + .prepare( + `UPDATE leads SET status='exhausted', status_updated_at = datetime('now'), + error_type='personNotFound', error_message=? WHERE hashed_url = ?`, + ) + .run(errorMessage, lead.hashed_url), + ); + summary.exhausted++; + } else { + summary.errors++; + } + continue; + } + const status = statusBody.data?.connectionStatus ?? statusBody.data?.status; + withDb((db) => + recordRunFinish(db, runId, { success: true, rawResponse: cli.json, errorMessage: null }), + ); + + if (status === 'connected') { + withDb((db) => + db + .prepare( + `UPDATE leads SET status='connected', status_updated_at = datetime('now') + WHERE hashed_url = ?`, + ) + .run(lead.hashed_url), + ); + summary.processed++; + summary.connected++; + } else if (status === 'notConnected') { + // The person declined or the request expired without connecting — a failed + // attempt by this account. Retry from another account or exhaust. + const res = withDb((db) => resolveFailedAttempt(db, lead)); + summary.processed++; + summary[res.outcome === 'reassigned' ? 'reassigned' : 'exhausted']++; + } else if (status === 'pending') { + const withdrawRunId = withDb((db) => + recordRunStart(db, { + leadHashedUrl: lead.hashed_url, + account: account.name, + action: 'withdraw', + }), + ); + const withdraw = await runLinkedin(['connection', 'withdraw', personUrl], { + cliAccount: account.cli_account, + }); + const wOk = withdraw.ok && withdraw.json?.success !== false; + withDb((db) => + recordRunFinish(db, withdrawRunId, { + success: wOk, + rawResponse: withdraw.json, + errorMessage: wOk + ? null + : withdraw.json?.error?.message ?? `exit ${withdraw.exitCode}`, + }), + ); + if (isFatalExitCode(withdraw.exitCode)) { + summary.aborted = true; + summary.abort_reason = `linkedin-cli exit ${withdraw.exitCode}`; + break; + } + if (wOk) { + // Stale request cancelled — a failed attempt. Retry from another account or exhaust. + const res = withDb((db) => resolveFailedAttempt(db, lead)); + summary[res.outcome === 'reassigned' ? 'reassigned' : 'exhausted']++; + } else { + summary.errors++; + } + summary.processed++; + } else { + summary.processed++; + summary.errors++; + } + } + + ok({ account: account.name, min_days: minDays, ...summary }); +} diff --git a/skills/linkedin-growth/scripts/network-run.mjs b/skills/linkedin-growth/scripts/network-run.mjs new file mode 100644 index 0000000..596750b --- /dev/null +++ b/skills/linkedin-growth/scripts/network-run.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { parseArgs, requireFlag } from './lib/args.mjs'; +import { ok, fail } from './lib/output.mjs'; +import { withDb } from './lib/db.mjs'; +import { getAccountOrFail } from './lib/account.mjs'; +import { SKILL_ROOT } from './lib/paths.mjs'; + +const { flags } = parseArgs(); + +try { + const accountName = requireFlag(flags, 'account'); + const account = withDb((db) => getAccountOrFail(db, accountName), { readonly: true }); + if (account.paused) { + ok({ account: account.name, skipped: 'paused' }); + process.exit(0); + } + + const isJson = process.argv.includes('--json'); + const phaseFlags = isJson ? ['--account', accountName, '--json'] : ['--account', accountName]; + + const invite = runChild('network-invite.mjs', phaseFlags); + const pending = runChild('network-pending.mjs', phaseFlags); + + ok({ + account: accountName, + invite: invite.parsed?.data ?? null, + pending: pending.parsed?.data ?? null, + exit: { invite: invite.code, pending: pending.code }, + }); +} catch (err) { + fail(err.message); +} + +function runChild(script, args) { + const r = spawnSync(process.execPath, [join(SKILL_ROOT, 'scripts', script), ...args], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + let parsed = null; + try { + parsed = JSON.parse(r.stdout.toString()); + } catch { + /* not JSON — normal in human mode */ + } + return { code: r.status, parsed, stdout: r.stdout.toString() }; +} diff --git a/skills/linkedin-growth/scripts/query.mjs b/skills/linkedin-growth/scripts/query.mjs new file mode 100644 index 0000000..79fed30 --- /dev/null +++ b/skills/linkedin-growth/scripts/query.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { parseArgs, requireFlag, boolFlag } from './lib/args.mjs'; +import { openDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; + +const { flags } = parseArgs(); + +try { + const sql = requireFlag(flags, 'sql'); + if (containsWrite(sql)) { + throw new Error('query.mjs is read-only — write/DDL statements are not allowed'); + } + const db = openDb({ readonly: true }); + try { + if (boolFlag(flags, 'explain')) { + const plan = db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(); + ok({ explain: plan }); + } else { + const rows = db.prepare(sql).all(); + ok(rows); + } + } finally { + db.close(); + } +} catch (err) { + fail(err.message); +} + +function containsWrite(sql) { + const stripped = sql + .replace(/--.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .trim() + .toUpperCase(); + if (!/^(SELECT|WITH|PRAGMA|EXPLAIN)\b/.test(stripped)) return true; + return /\b(INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|TRUNCATE|ATTACH|DETACH|REINDEX|VACUUM)\b/.test( + stripped, + ); +} diff --git a/skills/linkedin-growth/scripts/schedule.mjs b/skills/linkedin-growth/scripts/schedule.mjs new file mode 100644 index 0000000..cdae419 --- /dev/null +++ b/skills/linkedin-growth/scripts/schedule.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node +import { writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { platform, homedir, userInfo } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { parseArgs, intFlag } from './lib/args.mjs'; +import { ok, fail, info } from './lib/output.mjs'; +import { SKILL_ROOT, logsDir, ensureDir, dataDir } from './lib/paths.mjs'; +import { defaults } from './lib/config.mjs'; + +const SERVICE_ID = 'io.linkedapi.linkedin-growth.tick'; + +const { positional, flags } = parseArgs(); +const cmd = positional[0]; + +try { + switch (cmd) { + case 'install': + install(); + break; + case 'uninstall': + uninstall(); + break; + case 'status': + status(); + break; + case 'detect': + ok({ kind: detectScheduler() }); + break; + default: + fail( + 'Usage:\n' + + ' schedule.mjs install [--interval-minutes 5]\n' + + ' schedule.mjs uninstall\n' + + ' schedule.mjs status\n' + + ' schedule.mjs detect', + 5, + ); + } +} catch (err) { + fail(err.message); +} + +function detectScheduler() { + const p = platform(); + if (p === 'darwin') return 'launchd'; + if (p === 'win32') return 'schtasks'; + if (p === 'linux') { + const r = spawnSync('systemctl', ['--user', '--version'], { stdio: 'ignore' }); + if (r.status === 0) return 'systemd-user'; + const c = spawnSync('which', ['crontab'], { stdio: 'ignore' }); + if (c.status === 0) return 'cron'; + throw new Error('No supported scheduler on Linux (need systemctl --user or crontab)'); + } + throw new Error(`Unsupported platform: ${p}`); +} + +function tickCommand() { + const node = process.execPath; + const script = join(SKILL_ROOT, 'scripts', 'tick.mjs'); + return { node, script }; +} + +// Background schedulers (launchd / systemd / cron) run with a minimal PATH that usually +// excludes /usr/local/bin and node-manager bin dirs — so a bare `linkedin` spawn would fail +// with ENOENT and every scheduled invite would silently fail. Bake the install-time PATH +// (the user's shell PATH, which has `linkedin`) into the job, and make sure the directory of +// the `linkedin` binary is included. +function jobPath() { + const parts = (process.env.PATH || '').split(':').filter(Boolean); + const r = spawnSync('which', ['linkedin'], { stdio: 'pipe' }); + if (r.status === 0) { + const dir = r.stdout.toString().trim().replace(/\/linkedin$/, ''); + if (dir && !parts.includes(dir)) parts.unshift(dir); + } + return parts.join(':'); +} + +function install() { + const intervalMinutes = intFlag(flags, 'interval-minutes', defaults().tick_interval_minutes); + if (intervalMinutes < 1 || intervalMinutes > 60) { + throw new Error('--interval-minutes must be between 1 and 60'); + } + ensureDir(logsDir()); + const kind = detectScheduler(); + switch (kind) { + case 'launchd': + installLaunchd(intervalMinutes); + break; + case 'systemd-user': + installSystemd(intervalMinutes); + break; + case 'cron': + installCron(intervalMinutes); + break; + case 'schtasks': + installSchtasks(intervalMinutes); + break; + } + ok({ installed: kind, interval_minutes: intervalMinutes, service_id: SERVICE_ID }); +} + +function uninstall() { + const kind = detectScheduler(); + switch (kind) { + case 'launchd': + uninstallLaunchd(); + break; + case 'systemd-user': + uninstallSystemd(); + break; + case 'cron': + uninstallCron(); + break; + case 'schtasks': + uninstallSchtasks(); + break; + } + ok({ uninstalled: kind, service_id: SERVICE_ID }); +} + +function status() { + const kind = detectScheduler(); + let installed = false; + let detail = null; + switch (kind) { + case 'launchd': { + const path = launchdPlistPath(); + installed = existsSync(path); + detail = installed ? { plist: path } : null; + break; + } + case 'systemd-user': { + const { servicePath, timerPath } = systemdPaths(); + installed = existsSync(timerPath) && existsSync(servicePath); + detail = installed ? { service: servicePath, timer: timerPath } : null; + break; + } + case 'cron': { + const current = currentCrontab(); + installed = current.includes(SERVICE_ID); + detail = installed ? { crontab_marker: SERVICE_ID } : null; + break; + } + case 'schtasks': { + const r = spawnSync('schtasks', ['/Query', '/TN', SERVICE_ID], { stdio: 'pipe' }); + installed = r.status === 0; + break; + } + } + ok({ kind, installed, ...(detail ?? {}) }); +} + +// --- launchd --- +function launchdPlistPath() { + return join(homedir(), 'Library', 'LaunchAgents', `${SERVICE_ID}.plist`); +} + +function installLaunchd(intervalMinutes) { + const { node, script } = tickCommand(); + const plistPath = launchdPlistPath(); + mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true }); + const stdoutLog = join(logsDir(), 'tick.stdout.log'); + const stderrLog = join(logsDir(), 'tick.stderr.log'); + const plist = ` + + + + Label${SERVICE_ID} + ProgramArguments + + ${node} + ${script} + + EnvironmentVariables + + PATH${jobPath()} + + StartInterval${intervalMinutes * 60} + RunAtLoad + StandardOutPath${stdoutLog} + StandardErrorPath${stderrLog} + + +`; + writeFileSync(plistPath, plist); + spawnSync('launchctl', ['bootout', `gui/${userInfo().uid}/${SERVICE_ID}`], { stdio: 'ignore' }); + const r = spawnSync('launchctl', ['bootstrap', `gui/${userInfo().uid}`, plistPath], { + stdio: 'pipe', + }); + if (r.status !== 0) { + info(r.stderr.toString()); + throw new Error(`launchctl bootstrap failed (exit ${r.status})`); + } +} + +function uninstallLaunchd() { + const path = launchdPlistPath(); + spawnSync('launchctl', ['bootout', `gui/${userInfo().uid}/${SERVICE_ID}`], { stdio: 'ignore' }); + if (existsSync(path)) unlinkSync(path); +} + +// --- systemd user --- +function systemdPaths() { + const base = join(homedir(), '.config', 'systemd', 'user'); + return { + base, + servicePath: join(base, `${SERVICE_ID}.service`), + timerPath: join(base, `${SERVICE_ID}.timer`), + }; +} + +function installSystemd(intervalMinutes) { + const { node, script } = tickCommand(); + const { base, servicePath, timerPath } = systemdPaths(); + mkdirSync(base, { recursive: true }); + const service = `[Unit] +Description=Linked API linkedin-growth tick + +[Service] +Type=oneshot +Environment=PATH=${jobPath()} +ExecStart=${node} ${script} +StandardOutput=append:${join(logsDir(), 'tick.stdout.log')} +StandardError=append:${join(logsDir(), 'tick.stderr.log')} +`; + const timer = `[Unit] +Description=Linked API linkedin-growth tick timer + +[Timer] +OnBootSec=1min +OnUnitActiveSec=${intervalMinutes}min +Persistent=true +Unit=${SERVICE_ID}.service + +[Install] +WantedBy=timers.target +`; + writeFileSync(servicePath, service); + writeFileSync(timerPath, timer); + spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' }); + const r = spawnSync('systemctl', ['--user', 'enable', '--now', `${SERVICE_ID}.timer`], { + stdio: 'inherit', + }); + if (r.status !== 0) throw new Error('systemctl --user enable failed'); +} + +function uninstallSystemd() { + const { servicePath, timerPath } = systemdPaths(); + spawnSync('systemctl', ['--user', 'disable', '--now', `${SERVICE_ID}.timer`], { stdio: 'ignore' }); + if (existsSync(timerPath)) unlinkSync(timerPath); + if (existsSync(servicePath)) unlinkSync(servicePath); + spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' }); +} + +// --- cron fallback --- +function currentCrontab() { + const r = spawnSync('crontab', ['-l'], { stdio: 'pipe' }); + return r.status === 0 ? r.stdout.toString() : ''; +} + +function installCron(intervalMinutes) { + const { node, script } = tickCommand(); + const existing = currentCrontab() + .split('\n') + .filter((l) => !l.includes(SERVICE_ID)) + .filter((l) => l.length > 0); + const minutes = intervalMinutes === 1 ? '*' : `*/${intervalMinutes}`; + // PATH is set inline so the spawned `linkedin` binary is found (cron's default PATH is minimal). + const line = `${minutes} * * * * PATH="${jobPath()}" ${node} ${script} >> ${join(logsDir(), 'tick.cron.log')} 2>&1 # ${SERVICE_ID}`; + const next = [...existing, line, ''].join('\n'); + const r = spawnSync('crontab', ['-'], { input: next, stdio: ['pipe', 'inherit', 'inherit'] }); + if (r.status !== 0) throw new Error('crontab update failed'); +} + +function uninstallCron() { + const next = currentCrontab() + .split('\n') + .filter((l) => !l.includes(SERVICE_ID)) + .join('\n'); + spawnSync('crontab', ['-'], { input: next, stdio: ['pipe', 'inherit', 'inherit'] }); +} + +// --- Windows schtasks --- +function installSchtasks(intervalMinutes) { + const { node, script } = tickCommand(); + spawnSync('schtasks', ['/Delete', '/F', '/TN', SERVICE_ID], { stdio: 'ignore' }); + const r = spawnSync( + 'schtasks', + [ + '/Create', + '/SC', 'MINUTE', + '/MO', String(intervalMinutes), + '/TN', SERVICE_ID, + '/TR', `"${node}" "${script}"`, + '/F', + ], + { stdio: 'inherit' }, + ); + if (r.status !== 0) throw new Error('schtasks /Create failed'); +} + +function uninstallSchtasks() { + spawnSync('schtasks', ['/Delete', '/F', '/TN', SERVICE_ID], { stdio: 'inherit' }); +} diff --git a/skills/linkedin-growth/scripts/schema.mjs b/skills/linkedin-growth/scripts/schema.mjs new file mode 100644 index 0000000..40a7c39 --- /dev/null +++ b/skills/linkedin-growth/scripts/schema.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import { parseArgs, boolFlag } from './lib/args.mjs'; +import { openDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; + +const { flags } = parseArgs(); +const examples = boolFlag(flags, 'examples'); + +try { + const db = openDb({ readonly: true }); + const tables = db + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, + ) + .all() + .map((r) => r.name); + const schema = tables.map((t) => ({ + table: t, + columns: db.prepare(`PRAGMA table_info(${t})`).all(), + indexes: db.prepare(`PRAGMA index_list(${t})`).all(), + })); + db.close(); + + const enums = { + 'leads.status': ['not_connected', 'pending', 'connected', 'exhausted', 'error'], + 'runs.action': ['invite', 'check_status', 'withdraw'], + 'import_batches.state': ['pending_qualification', 'committed', 'aborted'], + }; + + const notes = { + 'leads.status': + "not_connected = awaiting/ready for an invite from owner_account (attempt 1..N); " + + "pending = invite sent, awaiting acceptance; connected = success (terminal); " + + "exhausted = tried up to max_connect_attempts accounts, none accepted (terminal); " + + 'error = an invite failed technically (reset with lead.mjs reset).', + 'settings.max_connect_attempts': + "global retry policy: how many DISTINCT accounts may try one lead. '1' = no retry. " + + "'all' = every active account. On a failed attempt (we withdrew a stale pending, or the " + + 'person declined/expired) the lead is reassigned to the least-loaded untried account, ' + + 'or marked exhausted.', + }; + + ok({ + schema, + enums, + notes, + examples: examples ? exampleQueries() : undefined, + timezone_notes: { + all_timestamps: 'stored as SQLite datetime() in UTC', + filter_today: "use date('now') in WHERE clauses", + filter_relative: "use datetime('now', '-7 days') etc.", + }, + }); +} catch (err) { + fail(err.message); +} + +function exampleQueries() { + return [ + { + question: 'How many pending invites does a given account have? (replace )', + sql: "SELECT COUNT(*) AS c FROM leads WHERE owner_account = '' AND status = 'pending'", + }, + { + question: 'Leads imported in the last 7 days per account', + sql: + "SELECT owner_account, COUNT(*) AS c FROM leads " + + "WHERE created_at >= datetime('now','-7 days') GROUP BY owner_account", + }, + { + question: 'Invites sent today per account', + sql: + "SELECT account, COUNT(*) AS c FROM runs " + + "WHERE action='invite' AND success=1 AND started_at >= date('now') GROUP BY account", + }, + { + question: 'Lists with the best connection conversion', + sql: + "SELECT list_name, COUNT(*) AS total, " + + "100.0 * SUM(CASE WHEN status='connected' THEN 1 ELSE 0 END) / COUNT(*) AS conv_pct " + + "FROM leads GROUP BY list_name HAVING total >= 10 ORDER BY conv_pct DESC", + }, + { + question: 'Pending leads older than 7 days for a given account (replace )', + sql: + "SELECT full_name, sent_at FROM leads " + + "WHERE owner_account='' AND status='pending' " + + "AND sent_at < datetime('now','-7 days') ORDER BY sent_at", + }, + { + question: 'Most common error types in the last 30 days', + sql: + "SELECT error_type, COUNT(*) AS c FROM leads " + + "WHERE status='error' AND status_updated_at >= datetime('now','-30 days') " + + 'GROUP BY error_type ORDER BY c DESC', + }, + ]; +} diff --git a/skills/linkedin-growth/scripts/settings.mjs b/skills/linkedin-growth/scripts/settings.mjs new file mode 100644 index 0000000..c647fbc --- /dev/null +++ b/skills/linkedin-growth/scripts/settings.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { parseArgs } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; +import { getSetting, setSetting, allSettings } from './lib/settings.mjs'; + +const { positional, flags } = parseArgs(); +const cmd = positional[0]; + +// Known settings with validation. Extend here as new global settings are added. +const VALIDATORS = { + max_connect_attempts: (v) => { + if (v === 'all') return 'all'; + const n = Number(v); + if (!Number.isInteger(n) || n < 1 || n > 50) { + throw new Error("max_connect_attempts must be a positive integer (1-50) or 'all'"); + } + return String(n); + }, + // Free-text definition of the user's ideal lead (who to keep / who to filter out). + // Consumed by the qualification step during import. + icp_definition: (v) => { + const t = String(v).trim(); + if (!t) throw new Error('icp_definition cannot be empty'); + if (t.length > 12000) throw new Error('icp_definition is too long (max 12000 chars)'); + return t; + }, +}; + +try { + switch (cmd) { + case 'list': + withDb((db) => ok(allSettings(db)), { readonly: true }); + break; + case 'get': { + const key = positional[1]; + if (!key) throw new Error('Usage: settings.mjs get '); + withDb((db) => ok({ key, value: getSetting(db, key, null) }), { readonly: true }); + break; + } + case 'set': { + const key = positional[1]; + // Value source, in priority order: + // --stdin read from stdin (preferred for multi-line text like the ICP — no file) + // --file read from a file + // inline positional + // The value is stored in the DB (settings table); --file/--stdin are just transports. + const raw = flags.stdin + ? readFileSync(0, 'utf8') + : flags.file + ? readFileSync(String(flags.file), 'utf8') + : positional[2]; + if (!key || raw === undefined) { + throw new Error( + 'Usage: settings.mjs set (or: set --stdin | set --file )', + ); + } + const validate = VALIDATORS[key]; + const value = validate ? validate(raw) : String(raw); + withDb((db) => { + setSetting(db, key, value); + ok({ key, value }); + }); + break; + } + default: + fail( + 'Usage:\n' + + ' settings.mjs list\n' + + ' settings.mjs get \n' + + ' settings.mjs set max_connect_attempts <1-50|all>\n' + + " settings.mjs set icp_definition --stdin (pipe the ICP text in; stored in the DB)\n" + + " settings.mjs set icp_definition '' (short inline alternative)", + 5, + ); + } +} catch (err) { + fail(err.message); +} diff --git a/skills/linkedin-growth/scripts/status.mjs b/skills/linkedin-growth/scripts/status.mjs new file mode 100644 index 0000000..8864473 --- /dev/null +++ b/skills/linkedin-growth/scripts/status.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +import { parseArgs } from './lib/args.mjs'; +import { withDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; +import { startOfLocalDayUtc } from './lib/time.mjs'; +import { getSetting } from './lib/settings.mjs'; + +const { flags } = parseArgs(); + +try { + const accountFilter = flags.account ? String(flags.account) : null; + const sinceArg = flags.since ? String(flags.since) : null; + const sinceClause = sinceArg ? parseSince(sinceArg) : null; + + const dayStartUtc = startOfLocalDayUtc(); + const data = withDb( + (db) => { + const accounts = db + .prepare( + accountFilter + ? 'SELECT * FROM accounts WHERE name = ? ORDER BY name' + : 'SELECT * FROM accounts ORDER BY name', + ) + .all(...(accountFilter ? [accountFilter] : [])) + .map((a) => ({ ...a, paused: !!a.paused })); + + if (accountFilter && accounts.length === 0) { + throw new Error(`Account '${accountFilter}' not found`); + } + + const perAccount = accounts.map((acc) => { + const statuses = countByStatus(db, acc.name); + const sentToday = db + .prepare( + `SELECT COUNT(*) AS c FROM runs + WHERE account = ? AND action = 'invite' AND success = 1 + AND started_at >= ?`, + ) + .get(acc.name, dayStartUtc).c; + const imported = sinceClause + ? db + .prepare( + `SELECT COUNT(*) AS c FROM leads WHERE owner_account = ? AND created_at >= ?`, + ) + .get(acc.name, sinceClause).c + : null; + const recentErrors = db + .prepare( + `SELECT hashed_url, full_name, error_type, error_message, status_updated_at + FROM leads + WHERE owner_account = ? AND status = 'error' + ORDER BY status_updated_at DESC + LIMIT 5`, + ) + .all(acc.name); + return { + name: acc.name, + paused: acc.paused, + daily_limit: acc.daily_invite_limit, + min_invite_interval_minutes: acc.min_invite_interval_minutes, + active_hours: `${acc.active_start}-${acc.active_end}`, + effective_max_per_day: effectiveMaxPerDay(acc), + max_pending_days: acc.max_pending_days, + pending_batch_size: acc.pending_batch_size, + last_action_at: acc.last_action_at, + sent_today: sentToday, + remaining_today: Math.max(0, acc.daily_invite_limit - sentToday), + statuses, + imported_since: imported, + recent_errors: recentErrors, + }; + }); + + const totals = accounts.length > 1 ? aggregate(perAccount) : null; + const maxConnectAttempts = getSetting(db, 'max_connect_attempts', '1'); + + return { + since: sinceClause, + retry_policy: { max_connect_attempts: maxConnectAttempts }, + accounts: perAccount, + totals, + }; + }, + { readonly: true }, + ); + + ok(data); +} catch (err) { + fail(err.message); +} + +// The real daily invite ceiling is the tighter of the configured limit and what the +// interval allows inside the active window: floor(window_minutes / interval) + 1. +function effectiveMaxPerDay(acc) { + const [sh, sm] = acc.active_start.split(':').map(Number); + const [eh, em] = acc.active_end.split(':').map(Number); + const windowMin = eh * 60 + em - (sh * 60 + sm); + const byInterval = Math.floor(windowMin / acc.min_invite_interval_minutes) + 1; + return Math.min(acc.daily_invite_limit, byInterval); +} + +function countByStatus(db, account) { + const rows = db + .prepare( + `SELECT status, COUNT(*) AS c FROM leads + WHERE owner_account = ? + GROUP BY status`, + ) + .all(account); + const out = { + not_connected: 0, + pending: 0, + connected: 0, + exhausted: 0, + error: 0, + }; + for (const r of rows) out[r.status] = r.c; + out.total = rows.reduce((s, r) => s + r.c, 0); + return out; +} + +function aggregate(perAccount) { + const totals = { not_connected: 0, pending: 0, connected: 0, exhausted: 0, error: 0, total: 0 }; + let sentToday = 0; + for (const a of perAccount) { + for (const k of Object.keys(totals)) totals[k] += a.statuses[k] ?? 0; + sentToday += a.sent_today; + } + return { statuses: totals, sent_today: sentToday }; +} + +function parseSince(s) { + const m = /^(\d+)([dwm])$/i.exec(s.trim()); + if (m) { + const n = Number(m[1]); + const unit = m[2].toLowerCase(); + const days = unit === 'd' ? n : unit === 'w' ? n * 7 : n * 30; + return new Date(Date.now() - days * 86400000).toISOString().replace('T', ' ').slice(0, 19); + } + if (!Number.isNaN(Date.parse(s))) return new Date(s).toISOString().replace('T', ' ').slice(0, 19); + throw new Error(`--since must be ISO timestamp or shorthand like 7d, 2w, 1m (got: ${s})`); +} diff --git a/skills/linkedin-growth/scripts/tick.mjs b/skills/linkedin-growth/scripts/tick.mjs new file mode 100644 index 0000000..dae066d --- /dev/null +++ b/skills/linkedin-growth/scripts/tick.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +// +// The scheduler heartbeat, in two roles selected by the --account flag: +// +// DISPATCHER (no --account): launched by the OS timer every few minutes. It is +// lightweight and NEVER blocks. For each active account that is inside its active +// window and not already being processed, it spawns a DETACHED worker and returns +// immediately. Because it never waits on a worker, a slow account — or one that is busy +// with another workflow the user launched on the same LinkedIn session — can never hold +// up the next tick or any other account. Accounts are therefore processed in parallel. +// +// WORKER (--account ): does the work for ONE account, holding a per-account lock for +// its whole lifetime: +// 1. INVITE (top priority): if under the daily quota AND at least +// `min_invite_interval_minutes` since the last successful send, send ONE invite. +// It always runs BEFORE any pending work, so pending can never delay a connect. +// 2. PENDING (fills the rest): drain due pending checks in small batches until none +// remain or a soft time budget elapses. The budget only decides whether to START +// another batch — a batch already running is never interrupted. +// +// Nothing here is ever killed by a timeout. If an operation is slow because the account is +// busy, the worker simply keeps holding its lock; the next dispatcher ticks see a LIVE +// worker and skip that account, so no work piles up and nothing is force-terminated. When +// the op returns, the worker finishes and the normal cadence resumes on the next tick. +// +// The per-account lock is reclaimed ONLY when its owning process is actually dead (PID +// probe), never on a timer — so a long-but-alive run is never preempted or double-started, +// and a crashed run self-heals on the next tick. Each operation is persisted to the DB the +// moment it completes, and quotas are recomputed from the runs table each tick (bounded to +// the local calendar day), so state stays correct across interruptions. +// +import { + existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync, + appendFileSync, readdirSync, statSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { withDb } from './lib/db.mjs'; +import { ok, fail } from './lib/output.mjs'; +import { parseArgs } from './lib/args.mjs'; +import { dataDir, ensureDir, logsDir, SKILL_ROOT } from './lib/paths.mjs'; +import { defaults } from './lib/config.mjs'; +import { + startOfLocalDayUtc, localHHMM, parseDbUtc, minutesSince, +} from './lib/time.mjs'; + +const { flags } = parseArgs(); + +try { + if (flags.account) { + await runWorker(String(flags.account)); + } else { + dispatch(); + } +} catch (err) { + fail(err.message); +} + +// Spawn one detached worker per eligible account, then exit. Never waits on a worker. +function dispatch() { + ensureDir(dataDir()); + const now = new Date(); + const nowHHMM = localHHMM(now); + + const accounts = withDb( + (db) => + db + .prepare('SELECT name, active_start, active_end FROM accounts WHERE paused = 0 ORDER BY name') + .all(), + { readonly: true }, + ); + + const dispatched = []; + const skipped = {}; + for (const acc of accounts) { + if (nowHHMM < acc.active_start || nowHHMM > acc.active_end) { + skipped[acc.name] = 'outside_active_window'; + } else if (lockHeldByLiveProcess(acc.name)) { + skipped[acc.name] = 'previous_run_still_active'; + } else { + spawnWorker(acc.name); + dispatched.push(acc.name); + } + } + + pruneLogs(); + ok({ + now: now.toISOString(), + local_time: nowHHMM, + dispatched, + ...(Object.keys(skipped).length ? { skipped } : {}), + }); +} + +// Fire-and-forget: the worker is detached and fully owns its lifetime. The dispatcher does +// not keep a handle to it, so the dispatcher (the OS-timer job) exits right away. +function spawnWorker(account) { + const child = spawn( + process.execPath, + [join(SKILL_ROOT, 'scripts', 'tick.mjs'), '--account', account, '--json'], + { detached: true, stdio: 'ignore' }, + ); + child.unref(); +} + +async function runWorker(account) { + if (!acquireLock(account)) { + ok({ account, skipped: 'already_running' }); + return; + } + try { + const now = new Date(); + const nowHHMM = localHHMM(now); + const dayStartUtc = startOfLocalDayUtc(now); + + const acc = withDb( + (db) => db.prepare('SELECT * FROM accounts WHERE name = ? AND paused = 0').get(account), + { readonly: true }, + ); + if (!acc) { + ok({ account, skipped: 'not_active' }); + return; + } + if (nowHHMM < acc.active_start || nowHHMM > acc.active_end) { + ok({ account, skipped: 'outside_active_window' }); + return; + } + + const stats = readStats(acc, dayStartUtc); + const did = []; + const skipped = {}; + + // 1) INVITE — highest priority, before any pending work so it can never be delayed. + if (stats.notConnected === 0) { + skipped.invite = 'no_not_connected_leads'; + } else if (stats.sentToday >= acc.daily_invite_limit) { + skipped.invite = 'daily_quota_reached'; + } else { + const elapsed = minutesSince(parseDbUtc(stats.lastSentAt), now); + if (elapsed >= acc.min_invite_interval_minutes) { + await runChild('network-invite.mjs', account, ['--limit', '1']); + did.push({ op: 'invite', sent_today_before: stats.sentToday, daily_limit: acc.daily_invite_limit }); + } else { + skipped.invite = `paced_waiting (${Math.round(acc.min_invite_interval_minutes - elapsed)}min left)`; + } + } + + // 2) PENDING — drain due checks; never count-capped, only bounded by a soft time budget + // that gates STARTING another batch. A batch already running is never interrupted. + const budgetMs = defaults().pending_tick_budget_seconds * 1000; + const batch = defaults().pending_drain_batch; + const startMs = Date.now(); + let due = stats.duePending; + let prevDue = Infinity; + let drained = 0; + while (due > 0 && due < prevDue && Date.now() - startMs < budgetMs) { + await runChild('network-pending.mjs', account, ['--limit', String(batch)]); + prevDue = due; + due = readDuePending(acc); + drained += Math.max(0, prevDue - due); + } + if (drained > 0 || stats.duePending > 0) { + did.push({ op: 'pending', drained, due_remaining: due }); + } else { + skipped.pending = 'no_due_pending'; + } + + logWorker(account, { did, ...(Object.keys(skipped).length ? { skipped } : {}) }); + ok({ account, did, ...(Object.keys(skipped).length ? { skipped } : {}) }); + } finally { + releaseLock(account); + } +} + +function readStats(acc, dayStartUtc) { + return withDb( + (db) => ({ + sentToday: db + .prepare( + `SELECT COUNT(*) AS c FROM runs + WHERE account = ? AND action = 'invite' AND success = 1 AND started_at >= ?`, + ) + .get(acc.name, dayStartUtc).c, + // Pace on the last SUCCESSFUL send, not on any attempt. A transient/failed attempt did + // not send a request, so it must not consume the interval — otherwise a cold first run + // stalls the account for a full interval. + lastSentAt: db + .prepare( + `SELECT MAX(started_at) AS t FROM runs + WHERE account = ? AND action = 'invite' AND success = 1 AND started_at >= ?`, + ) + .get(acc.name, dayStartUtc).t, + notConnected: db + .prepare(`SELECT COUNT(*) AS c FROM leads WHERE owner_account = ? AND status = 'not_connected'`) + .get(acc.name).c, + duePending: readDuePending(acc), + }), + { readonly: true }, + ); +} + +function readDuePending(acc) { + return withDb( + (db) => + db + .prepare( + `SELECT COUNT(*) AS c FROM leads + WHERE owner_account = ? AND status = 'pending' AND sent_at IS NOT NULL + AND sent_at < datetime('now', ?)`, + ) + .get(acc.name, `-${acc.max_pending_days} days`).c, + { readonly: true }, + ); +} + +// Run a child script for this account and wait for it to finish. No timeout: a slow op +// (e.g. the account is busy with another workflow) is allowed to take as long as it needs — +// the per-account lock keeps later ticks from starting a second op in the meantime. +function runChild(script, account, extraArgs) { + return new Promise((resolve) => { + const logFile = join( + ensureDir(logsDir()), + `${account}-${new Date().toISOString().slice(0, 10)}.log`, + ); + appendFileSync(logFile, `\n=== ${new Date().toISOString()} ${account} ${script} ===\n`); + const child = spawn( + process.execPath, + [join(SKILL_ROOT, 'scripts', script), '--account', account, '--json', ...extraArgs], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let out = ''; + let err = ''; + child.stdout.on('data', (b) => (out += b.toString())); + child.stderr.on('data', (b) => (err += b.toString())); + child.on('error', (e) => { + appendFileSync(logFile, `spawn error: ${e.message}\n`); + resolve(-1); + }); + child.on('close', (code) => { + if (out) appendFileSync(logFile, out); + if (err) appendFileSync(logFile, err); + appendFileSync(logFile, `\n--- exit ${code} ---\n`); + resolve(code); + }); + }); +} + +function logWorker(account, summary) { + try { + const logFile = join( + ensureDir(logsDir()), + `${account}-${new Date().toISOString().slice(0, 10)}.log`, + ); + appendFileSync(logFile, `\n[worker ${new Date().toISOString()}] ${JSON.stringify(summary)}\n`); + } catch { + /* logging is best-effort */ + } +} + +// --- Per-account lock: liveness-based, never time-based. --- +function lockPath(account) { + return join(dataDir(), `tick-${account}.lock`); +} + +function isProcessAlive(pid) { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === 'EPERM'; // exists but owned by another user + } +} + +function lockHeldByLiveProcess(account) { + const p = lockPath(account); + if (!existsSync(p)) return false; + try { + return isProcessAlive(Number(readFileSync(p, 'utf8'))); + } catch { + return false; + } +} + +function acquireLock(account) { + const p = lockPath(account); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const fd = openSync(p, 'wx'); + writeFileSync(fd, String(process.pid)); + closeSync(fd); + return true; + } catch { + // Lock exists. Reclaim it only if its owner is dead; a live worker keeps it. + try { + if (isProcessAlive(Number(readFileSync(p, 'utf8')))) return false; + unlinkSync(p); + } catch { + return false; + } + } + } + return false; +} + +function releaseLock(account) { + const p = lockPath(account); + try { + if (!existsSync(p)) return; + if (Number(readFileSync(p, 'utf8')) === process.pid) unlinkSync(p); + } catch { + /* ignore */ + } +} + +function pruneLogs() { + const dir = logsDir(); + if (!existsSync(dir)) return; + const cutoff = Date.now() - defaults().log_retention_days * 86400 * 1000; + for (const f of readdirSync(dir)) { + const path = join(dir, f); + try { + if (statSync(path).mtimeMs < cutoff) unlinkSync(path); + } catch { + /* ignore */ + } + } +} diff --git a/skills/linkedin/SKILL.md b/skills/linkedin/SKILL.md new file mode 100644 index 0000000..e9726b8 --- /dev/null +++ b/skills/linkedin/SKILL.md @@ -0,0 +1,535 @@ +--- +name: linkedin +description: General-purpose LinkedIn automation – fetch profiles, search people and companies, send messages, manage connections, create posts, and more. Use when the user wants to interact with LinkedIn. +--- + +# LinkedIn Skill + +You have access to `linkedin` – a CLI tool for LinkedIn automation. Use it to fetch profiles, search people and companies, send messages, manage connections, create posts, react, comment, and more. + +Each command sends a request to Linked API, which runs a real cloud browser to perform the action on LinkedIn. Operations are **not instant** – expect 30 seconds to several minutes depending on complexity. + +If `linkedin` is not available, install it: + +```bash +npm install -g @linkedapi/linkedin-cli +``` + +## Authentication + +If a command fails with exit code 2 (authentication error), ask the user to set up their account: + +1. Go to [app.linkedapi.io](https://app.linkedapi.io) and sign up or log in +2. Connect their LinkedIn account +3. Copy the **Linked API Token** and **Identification Token** from the dashboard + +Once the user provides the tokens, run: + +```bash +linkedin setup --linked-api-token=TOKEN --identification-token=TOKEN +``` + +## Global Flags + +Always use `--json` and `-q` for machine-readable output: + +```bash +LINKEDAPI_CLIENT=skill:linkedin linkedin --json -q +``` + +When using this skill, run every `linkedin ...` example below with the +`LINKEDAPI_CLIENT=skill:linkedin` prefix so Linked API can attribute usage to the skill. + +| Flag | Description | +|------|-------------| +| `--json` | Structured JSON output | +| `--quiet` / `-q` | Suppress stderr progress messages | +| `--fields name,url,...` | Select specific fields in output | +| `--no-color` | Disable colors | +| `--account "Name"` | Use a specific account for this command | + +## Output Format + +Success: +```json +{"success": true, "data": {"name": "John Doe", "headline": "Engineer"}} +``` + +Error: +```json +{"success": false, "error": {"type": "personNotFound", "message": "Person not found"}} +``` + +Exit code 0 means the API call succeeded – always check the `success` field for the action outcome. Non-zero exit codes indicate infrastructure errors: + +| Exit Code | Meaning | +|-----------|---------| +| 0 | Success (check `success` field – action may have returned an error like "person not found") | +| 1 | General/unexpected error | +| 2 | Missing or invalid tokens | +| 3 | Subscription/plan required | +| 4 | LinkedIn account issue | +| 5 | Invalid arguments | +| 6 | Rate limited | +| 7 | Network error | +| 8 | Workflow timeout (workflowId returned for recovery) | + +## Commands + +### Fetch a Person Profile + +```bash +linkedin person fetch [flags] --json -q +``` + +Optional flags to include additional data: +- `--experience` – work history +- `--education` – education history +- `--skills` – skills list +- `--languages` – languages +- `--posts` – recent posts (with `--posts-limit N`, `--posts-since TIMESTAMP`) +- `--comments` – recent comments (with `--comments-limit N`, `--comments-since TIMESTAMP`) +- `--reactions` – recent reactions (with `--reactions-limit N`, `--reactions-since TIMESTAMP`) + +Only request additional data when needed – each flag increases execution time. + +```bash +# Basic profile +linkedin person fetch https://www.linkedin.com/in/username --json -q + +# With experience and education +linkedin person fetch https://www.linkedin.com/in/username --experience --education --json -q + +# With last 5 posts +linkedin person fetch https://www.linkedin.com/in/username --posts --posts-limit 5 --json -q +``` + +### Search People + +```bash +linkedin person search [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--term` | Search keyword or phrase | +| `--limit` | Max results | +| `--first-name` | Filter by first name | +| `--last-name` | Filter by last name | +| `--position` | Filter by job position | +| `--locations` | Comma-separated locations | +| `--industries` | Comma-separated industries | +| `--current-companies` | Comma-separated current company names | +| `--previous-companies` | Comma-separated previous company names | +| `--schools` | Comma-separated school names | + +```bash +linkedin person search --term "product manager" --locations "San Francisco" --json -q +linkedin person search --current-companies "Google" --position "Engineer" --limit 20 --json -q +``` + +### Fetch a Company + +```bash +linkedin company fetch [flags] --json -q +``` + +Optional flags: +- `--employees` – include employees +- `--dms` – include decision makers +- `--posts` – include company posts + +Employee filters (require `--employees`): + +| Flag | Description | +|------|-------------| +| `--employees-limit` | Max employees to retrieve | +| `--employees-first-name` | Filter by first name | +| `--employees-last-name` | Filter by last name | +| `--employees-position` | Filter by position | +| `--employees-locations` | Comma-separated locations | +| `--employees-industries` | Comma-separated industries | +| `--employees-schools` | Comma-separated school names | + +| Flag | Description | +|------|-------------| +| `--dms-limit` | Max decision makers to retrieve (requires `--dms`) | +| `--posts-limit` | Max posts to retrieve (requires `--posts`) | +| `--posts-since` | Posts since ISO timestamp (requires `--posts`) | + +```bash +# Basic company info +linkedin company fetch https://www.linkedin.com/company/name --json -q + +# With employees filtered by position +linkedin company fetch https://www.linkedin.com/company/name --employees --employees-position "Engineer" --json -q + +# With decision makers and posts +linkedin company fetch https://www.linkedin.com/company/name --dms --posts --posts-limit 10 --json -q +``` + +### Search Companies + +```bash +linkedin company search [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--term` | Search keyword | +| `--limit` | Max results | +| `--sizes` | Comma-separated sizes: `1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+` | +| `--locations` | Comma-separated locations | +| `--industries` | Comma-separated industries | + +```bash +linkedin company search --term "fintech" --sizes "11-50,51-200" --json -q +``` + +### Send a Message + +```bash +linkedin message send '' --json -q +``` + +Text up to 1900 characters. Wrap the message in single quotes to avoid shell interpretation issues. + +```bash +linkedin message send https://www.linkedin.com/in/username 'Hey, loved your latest post!' --json -q +``` + +### Get Conversation + +```bash +linkedin message get [--since TIMESTAMP] --json -q +``` + +The first call for a conversation triggers a background sync and may take longer. Subsequent calls are faster. + +```bash +linkedin message get https://www.linkedin.com/in/username --json -q +linkedin message get https://www.linkedin.com/in/username --since 2024-01-15T10:30:00Z --json -q +``` + +### Connection Management + +#### Check connection status + +```bash +linkedin connection status --json -q +``` + +#### Send connection request + +```bash +linkedin connection send [--note 'text'] [--email user@example.com] --json -q +``` + +#### List connections + +```bash +linkedin connection list [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--limit` | Max connections to return | +| `--since` | Only connections made since ISO timestamp (only works when no filter flags are used) | +| `--first-name` | Filter by first name | +| `--last-name` | Filter by last name | +| `--position` | Filter by job position | +| `--locations` | Comma-separated locations | +| `--industries` | Comma-separated industries | +| `--current-companies` | Comma-separated current company names | +| `--previous-companies` | Comma-separated previous company names | +| `--schools` | Comma-separated school names | + +```bash +linkedin connection list --limit 50 --json -q +linkedin connection list --current-companies "Google" --position "Engineer" --json -q +linkedin connection list --since 2024-01-01T00:00:00Z --json -q +``` + +#### List pending outgoing requests + +```bash +linkedin connection pending --json -q +``` + +#### List incoming connection requests + +Invitations that others have sent to you (received requests). + +```bash +linkedin connection requests --json -q +``` + +#### Accept an incoming request + +```bash +linkedin connection accept --json -q +``` + +#### Ignore an incoming request + +```bash +linkedin connection ignore --json -q +``` + +#### Withdraw a pending request + +```bash +linkedin connection withdraw [--no-unfollow] --json -q +``` + +By default, withdrawing also unfollows the person. Use `--no-unfollow` to keep following. + +#### Remove a connection + +```bash +linkedin connection remove --json -q +``` + +### Posts + +#### Fetch a post + +```bash +linkedin post fetch [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--comments` | Include comments | +| `--reactions` | Include reactions | +| `--comments-limit` | Max comments to retrieve (requires `--comments`) | +| `--comments-sort` | Sort order: `mostRelevant` or `mostRecent` (requires `--comments`) | +| `--comments-replies` | Include replies to comments (requires `--comments`) | +| `--reactions-limit` | Max reactions to retrieve (requires `--reactions`) | + +```bash +linkedin post fetch https://www.linkedin.com/posts/username_activity-123 --json -q + +# With comments sorted by most recent, including replies +linkedin post fetch https://www.linkedin.com/posts/username_activity-123 \ + --comments --comments-sort mostRecent --comments-replies --json -q +``` + +#### Create a post + +```bash +linkedin post create '' [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--company-url` | Post on behalf of a company page (requires admin access) | +| `--attachments` | Attachment as `url:type` or `url:type:name`. Types: `image`, `video`, `document`. Can be specified multiple times. | + +Attachment limits: up to 9 images, or 1 video, or 1 document. Cannot mix types. + +```bash +linkedin post create 'Excited to share our latest update!' --json -q + +# With a document +linkedin post create 'Our Q4 report' \ + --attachments "https://example.com/report.pdf:document:Q4 Report" --json -q + +# Post as a company +linkedin post create 'Company announcement' \ + --company-url https://www.linkedin.com/company/name --json -q +``` + +#### React to a post + +```bash +linkedin post react --type [--company-url ] --json -q +``` + +Reaction types: `like`, `love`, `support`, `celebrate`, `insightful`, `funny`. + +```bash +linkedin post react https://www.linkedin.com/posts/username_activity-123 --type like --json -q + +# React on behalf of a company +linkedin post react https://www.linkedin.com/posts/username_activity-123 --type celebrate \ + --company-url https://www.linkedin.com/company/name --json -q +``` + +#### Comment on a post + +```bash +linkedin post comment '' [--company-url ] --json -q +``` + +Text up to 1000 characters. + +```bash +linkedin post comment https://www.linkedin.com/posts/username_activity-123 'Great insights!' --json -q + +# Comment on behalf of a company +linkedin post comment https://www.linkedin.com/posts/username_activity-123 'Well said!' \ + --company-url https://www.linkedin.com/company/name --json -q +``` + +### Statistics + +```bash +# Social Selling Index +linkedin stats ssi --json -q + +# Performance analytics (profile views, post impressions, search appearances) +linkedin stats performance --json -q + +# API usage for a date range +linkedin stats usage --start 2024-01-01T00:00:00Z --end 2024-01-31T00:00:00Z --json -q +``` + +### Sales Navigator + +Requires a LinkedIn Sales Navigator subscription. Uses hashed URLs for person/company lookups. + +#### Fetch person + +```bash +linkedin navigator person fetch --json -q +``` + +#### Search people + +```bash +linkedin navigator person search [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--term` | Search keyword or phrase | +| `--limit` | Max results | +| `--first-name` | Filter by first name | +| `--last-name` | Filter by last name | +| `--position` | Filter by job position | +| `--locations` | Comma-separated locations | +| `--industries` | Comma-separated industries | +| `--current-companies` | Comma-separated current company names | +| `--previous-companies` | Comma-separated previous company names | +| `--schools` | Comma-separated school names | +| `--years-of-experience` | Comma-separated ranges: `lessThanOne`, `oneToTwo`, `threeToFive`, `sixToTen`, `moreThanTen` | + +```bash +linkedin navigator person search --term "VP Marketing" --locations "United States" --json -q +linkedin navigator person search --years-of-experience "moreThanTen" --position "CEO" --json -q +``` + +#### Fetch company + +```bash +linkedin navigator company fetch [flags] --json -q +``` + +Optional flags: +- `--employees` – include employees +- `--dms` – include decision makers + +Employee filters (require `--employees`): + +| Flag | Description | +|------|-------------| +| `--employees-limit` | Max employees to retrieve | +| `--employees-first-name` | Filter by first name | +| `--employees-last-name` | Filter by last name | +| `--employees-positions` | Comma-separated positions | +| `--employees-locations` | Comma-separated locations | +| `--employees-industries` | Comma-separated industries | +| `--employees-schools` | Comma-separated school names | +| `--employees-years-of-experience` | Comma-separated experience ranges | +| `--dms-limit` | Max decision makers to retrieve (requires `--dms`) | + +```bash +linkedin navigator company fetch https://www.linkedin.com/sales/company/97ural --employees --dms --json -q +linkedin navigator company fetch https://www.linkedin.com/sales/company/97ural \ + --employees --employees-positions "Engineer,Designer" --employees-locations "Europe" --json -q +``` + +#### Search companies + +```bash +linkedin navigator company search [flags] --json -q +``` + +| Flag | Description | +|------|-------------| +| `--term` | Search keyword | +| `--limit` | Max results | +| `--sizes` | Comma-separated sizes: `1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+` | +| `--locations` | Comma-separated locations | +| `--industries` | Comma-separated industries | +| `--revenue-min` | Min annual revenue in M USD: `0`, `0.5`, `1`, `2.5`, `5`, `10`, `20`, `50`, `100`, `500`, `1000` | +| `--revenue-max` | Max annual revenue in M USD: `0.5`, `1`, `2.5`, `5`, `10`, `20`, `50`, `100`, `500`, `1000`, `1000+` | + +```bash +linkedin navigator company search --term "fintech" --sizes "11-50,51-200" --json -q +linkedin navigator company search --revenue-min 10 --revenue-max 100 --locations "United States" --json -q +``` + +#### Send InMail + +```bash +linkedin navigator message send '' --subject '' --json -q +``` + +Text up to 1900 characters. Subject up to 80 characters. + +```bash +linkedin navigator message send https://www.linkedin.com/in/username \ + 'Would love to chat about API integrations' --subject 'Partnership Opportunity' --json -q +``` + +#### Get Sales Navigator conversation + +```bash +linkedin navigator message get [--since TIMESTAMP] --json -q +``` + +### Custom Workflows + +Execute a custom workflow definition from a file, stdin, or inline: + +```bash +# From file +linkedin workflow run --file workflow.json --json -q + +# From stdin +cat workflow.json | linkedin workflow run --json -q + +# Inline +echo '{"actions":[...]}' | linkedin workflow run --json -q +``` + +Check workflow status or wait for completion: + +```bash +linkedin workflow status --json -q +linkedin workflow status --wait --json -q +``` + +See [Building Workflows](https://linkedapi.io/docs/building-workflows/) for the workflow JSON schema. + +### Account Management + +```bash +linkedin account list # List accounts (* = active) +linkedin account switch "Name" # Switch active account +linkedin account rename "Name" --name "New Name" # Rename account +linkedin reset # Remove active account +linkedin reset --all # Remove all accounts +``` + +## Important Behavior + +- **Sequential execution.** All operations for an account run one at a time. Multiple requests queue up. +- **Not instant.** A real browser navigates LinkedIn – expect 30 seconds to several minutes per operation. +- **Timestamps in UTC.** All dates and times are in UTC. +- **Single quotes for text arguments.** Use single quotes around message text, post text, and comments to avoid shell interpretation issues with special characters. +- **Action limits.** Per-account limits are configurable on the platform. A `limitExceeded` error means the limit was reached. +- **URL normalization.** All LinkedIn URLs in responses are normalized to `https://www.linkedin.com/...` format without trailing slashes. +- **Null fields.** Fields that are unavailable are returned as `null` or `[]`, not omitted.