linkedin-cdp

LinkedIn CDP: Input-only automation (mouse, keyboard, screenshots). Zero DOM access.

33 stars

Best use case

linkedin-cdp is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

LinkedIn CDP: Input-only automation (mouse, keyboard, screenshots). Zero DOM access.

Teams using linkedin-cdp should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/linkedin-cdp/SKILL.md --create-dirs "https://raw.githubusercontent.com/aAAaqwq/AGI-Super-Team/main/skills/linkedin-cdp/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/linkedin-cdp/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How linkedin-cdp Compares

Feature / Agentlinkedin-cdpStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

LinkedIn CDP: Input-only automation (mouse, keyboard, screenshots). Zero DOM access.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# LinkedIn CDP Automation

> LinkedIn automation via Chrome DevTools Protocol. Input-only (mouse/keyboard/screenshots). Zero DOM access.

## When to use

- "collect messages from linkedin" -> `LinkedInMessages`
- "who messaged me on linkedin" -> `LinkedInMessages`
- "write on linkedin" -> `LinkedInMessages.send_message()`
- "find on linkedin" -> `LinkedInSearch`
- "view profile" -> `LinkedInProfile`
- "send connection request" -> `LinkedInConnect`
- "show connection requests" -> `LinkedInConnect.screenshot_invitations()`

## Dependencies

- External: Chrome, `pip install websocket-client requests`
- Chrome must run with `--remote-debugging-port=9222` (separate instance)

## Paths

| What | Path |
|------|------|
| Script | `$HOME/linkedin-cdp/linkedin_cdp.py` |
| Modules | `$HOME/linkedin-cdp/linkedin_*.py` |
| Rate limiter | `$HOME/linkedin-cdp/rate_limiter.py` |
| Screenshots | `/tmp/li_screenshots/shot_*.jpg` |

## How to execute

### Step 0: Chrome Launch

**IMPORTANT:** Use the binary path directly, NOT `open -a 'Google Chrome'`.
`open -a` on macOS opens a tab in existing Chrome instead of a separate instance.

```bash
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  '--remote-allow-origins=*' \
  --user-data-dir="$HOME/chrome-debug-profile" \
  "https://www.linkedin.com" > /dev/null 2>&1 &
```

Run in background (`run_in_background: true`), then verify:
```bash
sleep 3 && curl -s "http://localhost:9222/json/version"
```

- `--user-data-dir` MUST differ from user's main Chrome profile
- `$HOME/chrome-debug-profile` persists login session between runs
- Don't connect CDP while user logs in. Wait for login to complete.

### Step 1: Calibration (REQUIRED on first run per session)

Every session may have different window size / DPR. On first run, take a calibration screenshot
and determine the coordinate mapping:

```python
import sys, subprocess, time
sys.path.insert(0, '$HOME/linkedin-cdp')
from linkedin_cdp import LinkedInBot

bot = LinkedInBot()
bot.connect()

# Take calibration screenshot — returns file path
path = bot.take_screenshot()
print(path)  # /tmp/li_screenshots/shot_0001.jpg

# Get image dimensions to determine DPR
result = subprocess.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path],
                       capture_output=True, text=True)
print(result.stdout)
bot.close()
```

Then read the calibration screenshot with Read tool. Calculate:
- **DPR** = image_width / expected_viewport_width (typically 2 on Retina Mac)
- **CSS coordinates** = image_pixel_coordinates / DPR

Store the DPR for all subsequent coordinate calculations in this session.

### Step 2: Coordinate System

All `click_at()`, `_click()` calls use **CSS coordinates**, not image pixels.

**Formula:** `CSS_coord = image_pixel_coord / DPR`

Typical Retina Mac (DPR=2, viewport ~1531x801):
- Screenshot: 3062x1602 pixels
- To click where you see something at image pixel (600, 400): click CSS (300, 200)

### Step 3: Use modules

```python
import sys
sys.path.insert(0, '$HOME/linkedin-cdp')
from linkedin_cdp import LinkedInBot

bot = LinkedInBot()
bot.connect()

# All actions use CSS coordinates
bot.click_at(x, y)        # click + screenshot
bot.type_text("text")      # type with human-like delays
bot.scroll_wheel(delta_y=500)  # scroll (keyword arg only!)
bot.take_screenshot()      # returns file path (JPEG)
bot.navigate_to(url)       # navigate + auto reconnect
bot.reconnect_to_tab()     # reconnect WebSocket after page change
bot.close()
```

## Known Fixed Coordinates (DPR=2, viewport ~1531x801)

These modal/dialog coordinates are **consistent across sessions** at the same viewport size.
Recalibrate if window size changes.

### Connection Request Modal

After clicking "Connect" on a profile:

| Element | CSS (x, y) | Notes |
|---------|-----------|-------|
| "Add a note" button | (751, 239) | White/outline button |
| "Send without a note" button | (911, 239) | Blue button |
| Note text field (click to focus) | (752, 259) | After clicking "Add a note" |
| "Send" button (with note) | (968, 393) | Blue, active after typing |
| "Cancel" button | (889, 393) | |

### Profile Page

| Element | CSS (x, y) | Notes |
|---------|-----------|-------|
| "Connect" button | (~270, 467-499) | y varies by profile layout (banner height, etc.) |
| "Message" button | (~383, 467-499) | Next to Connect |

### Messaging Page

`/messaging/` — conversation list on left, active thread on right.

| Element | CSS (x, y) | Notes |
|---------|-----------|-------|
| "Write a message..." text field | (715, 604) | Click to focus, then type_text() |
| Send button | (885, 724) | Active (blue) only after typing |

### Invitation Manager

`/mynetwork/invitation-manager/` — lists received/sent invitations.

| Element | CSS (x, y) | Notes |
|---------|-----------|-------|
| "Accept" button (first invite) | (~920, 274) | Approximate — verify via screenshot |
| "Ignore" button (first invite) | (~838, 274) | Next to Accept |
| "Received" / "Sent" tabs | (~265, 142) / (~350, 142) | Top of page |

### Search Results

| Element | CSS (x, y) | Notes |
|---------|-----------|-------|
| First result name | (~305, 155) | Approximate — **always verify via screenshot** |
| Second result name | (~305, 280) | Approximate — positions shift with ads/banners |

## Architecture

```
LinkedInBot (linkedin_cdp.py)  -- base class
   |-- CDP core: connect(), _send(), close(), reconnect_to_tab()
   |-- Mouse: _bezier(), _human_path(), _move_to(), _click(), _maybe_fake_hover()
   |-- Keyboard: type_text(), press_key()
   |-- Scroll: scroll_wheel()
   |-- Screenshot: take_screenshot() -> path, take_screenshot_base64(), save_screenshot()
   |-- Navigation: navigate_to(), wait_for_page()
   |-- Convenience: click_at(x,y) -> screenshot, scroll_and_screenshot()
   |
   +-- LinkedInMessages   (linkedin_messages.py)
   +-- LinkedInSearch      (linkedin_search.py)
   +-- LinkedInProfile     (linkedin_profile.py)
   +-- LinkedInConnect     (linkedin_connect.py)
```

## Modules

| File | Class | Key Methods |
|------|-------|-------------|
| `linkedin_cdp.py` | `LinkedInBot` | `click_at()`, `type_text()`, `scroll_wheel()`, `take_screenshot()` -> path, `take_screenshot_base64()`, `navigate_to()` |
| `linkedin_messages.py` | `LinkedInMessages` | `screenshot_conversations()`, `open_conversation()`, `send_message()`, `collect_screenshots()` |
| `linkedin_search.py` | `LinkedInSearch` | `search_people()`, `search_companies()`, `next_page()` |
| `linkedin_profile.py` | `LinkedInProfile` | `view_profile()`, `screenshot_full_profile()`, `scroll_to_section()` |
| `linkedin_connect.py` | `LinkedInConnect` | `view_profile()`, `send_connection_note()`, `screenshot_invitations()`, `accept_invitation()` |
| `rate_limiter.py` | `RateLimiter` | `can_search()`, `can_view_profile()`, `wait_if_needed()` |

## Examples

### Search → Profile → Connect with Note (full flow)

```python
import sys, time
sys.path.insert(0, '$HOME/linkedin-cdp')
from linkedin_cdp import LinkedInBot

bot = LinkedInBot()
bot.connect()

# 1. Search
bot.navigate_to('https://www.linkedin.com/search/results/people/?keywords=Name%20Company')
time.sleep(4)
bot.reconnect_to_tab()
time.sleep(2)
# take_screenshot() returns file path — read with Read tool to find coordinates

# 2. Click profile (coordinates from screenshot / DPR)
path = bot.click_at(305, 155)  # first result — verify from screenshot!
time.sleep(4)
bot.reconnect_to_tab()
# Read path with Read tool to verify correct profile

# 3. Click Connect (~270, 467-499 — verify from screenshot)
bot.click_at(270, 499)
time.sleep(2)
# Modal appears

# 4. Click "Add a note" (fixed coordinate)
bot.click_at(751, 239)
time.sleep(1.5)

# 5. Click text field + type note
bot._click(752, 259)
time.sleep(0.5)
bot.type_text("Hi Name, personalized note here...")
time.sleep(1)

# 6. Click Send (fixed coordinate)
bot.click_at(968, 393)
# Done — verify "Pending" status + "Invitation sent" toast

bot.close()
```

### Read Messages

```python
from linkedin_messages import LinkedInMessages
lm = LinkedInMessages()
lm.connect()

path = lm.screenshot_conversations()
# path = '/tmp/li_screenshots/shot_NNNN.jpg' — read with Read tool

path = lm.open_conversation(200, 350)  # coordinates from screenshot
# Read path with Read tool

lm.close()
```

### View Profile (manual scroll)

```python
import time
from linkedin_profile import LinkedInProfile

prof = LinkedInProfile()
prof.connect()
prof.navigate_to('https://linkedin.com/in/username')
time.sleep(4)
prof.reconnect_to_tab()

paths = [prof.take_screenshot()]
for _ in range(4):
    prof.scroll_wheel(delta_y=500)
    time.sleep(1.5)
    paths.append(prof.take_screenshot())
# paths = ['/tmp/li_screenshots/shot_0001.jpg', ...] — read each with Read tool
prof.close()
```

## Troubleshooting

| Problem | Solution |
|---------|----------|
| `WebSocketConnectionClosedException` after navigate | Call `reconnect_to_tab()` — page change breaks WebSocket |
| Click misses target | Verify DPR: `image_pixels / DPR = CSS coords`. Re-run calibration. |
| `screenshot_full_profile()` crashes | Use manual navigate + reconnect + scroll loop (see View Profile example) |
| `scroll_wheel()` TypeError | Use keyword arg only: `scroll_wheel(delta_y=500)`, NOT positional args |
| Chrome opens tab in existing browser | Use binary path directly, NOT `open -a 'Google Chrome'` |
| CDP port conflict | Change `--remote-debugging-port=9223` and update `CDP_PORT` in `linkedin_cdp.py` |
| Modal coordinates wrong | Window resized? Re-run calibration. Fixed coords assume viewport ~1531x801. |

## Practical Tips

1. **LinkedIn URLs from web research are often wrong.** Always search by name + company.
2. **`scroll_wheel()` takes `delta_y` keyword arg only.** Not positional.
3. **After any `navigate_to()` or page-changing click, always `reconnect_to_tab()`.**
4. **Always `sys.path.insert(0, '$HOME/linkedin-cdp')`** before imports.
5. **Screenshots auto-save to `/tmp/li_screenshots/shot_*.jpg`** — read them with Read tool. Old files auto-cleaned (keeps last 50).
6. **One module instance at a time.** Close previous before opening new.
7. **Calibrate DPR on first run.** Don't assume DPR=2 — verify.
8. **No em-dashes in messages.** Never use long dashes (--) in connection notes or messages. People recognize it as AI-generated text. Use commas, periods, or short sentences instead.

## Rate Limits (recommended daily)

| Action | Conservative | Moderate |
|--------|-------------|----------|
| Profile views | 50 | 100 |
| Searches | 20 | 50 |
| Connection requests | 15 | 25 |
| Messages sent | 30 | 50 |

## Security Model

**Zero DOM access** — NEVER `Runtime.evaluate`, `querySelector`, `innerText`.
**Screenshot-based reading** — `Page.captureScreenshot` (JPEG, quality 80) saved to files, not base64 in memory.
**Human-like mouse** — unique Bezier curves, tremor, overshoot, micro-pauses, speed variation.
**Real Chrome** — not headless. Normal fingerprint.
**Rate limiting** — built-in daily caps.
**Human-in-the-loop** — Claude reads screenshots, adds natural irregularity.

### NEVER do

- **NEVER use `Runtime.evaluate`** or CDP Runtime domain — #1 way bots get caught
- **NEVER bypass rate limits** — even if asked to "go faster"
- **NEVER run headless** — always visible Chrome window
- **NEVER automate login** — user logs in manually

## Post-outreach CRM logging (REQUIRED)

After sending a connection request or message, ALWAYS log to CRM:

1. **Company** in `companies.csv` (if new)
2. **Person** in `people.csv` (if new)
3. **Lead** in `leads.csv` (stage=new, source=linkedin, source_direction=outbound)
4. **Activity** in `activities.csv` with the **exact message text** in `notes` field:
   - `type`: outreach
   - `channel`: linkedin
   - `direction`: outbound
   - `subject`: "LinkedIn connection request with note" (or "LinkedIn message")
   - `notes`: the full text of the message sent

Do NOT skip step 4. The message text must be preserved in CRM for follow-up context.

## Related skills

- `add-lead` — add found contacts to CRM
- `update-lead` — update lead status after outreach
- `log-activity` — log activity to activities.csv
- `email-send-bulk` — follow up via email after LinkedIn connect

Related Skills

linkedin-inbound-run

33
from aAAaqwq/AGI-Super-Team

Automatic inbound LinkedIn message processing

linkedin-automation

33
from aAAaqwq/AGI-Super-Team

Automate LinkedIn tasks via Rube MCP (Composio): create posts, manage profile, company info, comments, and image uploads. Always search tools first for current schemas.

wemp-operator

33
from aAAaqwq/AGI-Super-Team

> 微信公众号全功能运营——草稿/发布/评论/用户/素材/群发/统计/菜单/二维码 API 封装

Content & Documentation

zsxq-smart-publish

33
from aAAaqwq/AGI-Super-Team

Publish and manage content on 知识星球 (zsxq.com). Supports talk posts, Q&A, long articles, file sharing, digest/bookmark, homework tasks, and tag management. Use when publishing content to 知识星球, creating/editing posts, uploading files/images/audio, managing digests, batch publishing, or formatting content for 知识星球.

zoom-automation

33
from aAAaqwq/AGI-Super-Team

Automate Zoom meeting creation, management, recordings, webinars, and participant tracking via Rube MCP (Composio). Always search tools first for current schemas.

zoho-crm-automation

33
from aAAaqwq/AGI-Super-Team

Automate Zoho CRM tasks via Rube MCP (Composio): create/update records, search contacts, manage leads, and convert leads. Always search tools first for current schemas.

ziliu-publisher

33
from aAAaqwq/AGI-Super-Team

字流(Ziliu) - AI驱动的多平台内容分发工具。用于一次创作、智能适配排版、一键分发到16+平台(公众号/知乎/小红书/B站/抖音/微博/X等)。当用户需要多平台发布、内容排版、格式适配时使用。触发词:字流、ziliu、多平台发布、一键分发、内容分发、排版发布。

zhihu-post-skill

33
from aAAaqwq/AGI-Super-Team

> 知乎文章发布——知乎平台内容创作与发布自动化

zendesk-automation

33
from aAAaqwq/AGI-Super-Team

Automate Zendesk tasks via Rube MCP (Composio): tickets, users, organizations, replies. Always search tools first for current schemas.

youtube-knowledge-extractor

33
from aAAaqwq/AGI-Super-Team

This skill performs deep analysis of YouTube videos through **both information channels** Multimodal YouTube video analysis through both audio (transcript) and visual (frame extraction + image analysis) channels. Especially powerful for HowTo videos, tutorials, demos, and explainer videos where what is SHOWN (screenshots, UI demos, diagrams, code, physical actions) is just as important as what is SAID. Use this skill whenever a user wants to analyze, summarize, or create step-by-step guides from YouTube videos, or when they share a YouTube URL and want to understand what happens in the video. Triggers on requests like "Analyze this YouTube video", "Create a step-by-step guide from this video", "What does this video show?", "Summarize this tutorial", or any YouTube URL shared with analysis intent.

youtube-factory

33
from aAAaqwq/AGI-Super-Team

Generate complete YouTube videos from a single prompt - script, voiceover, stock footage, captions, thumbnail. Self-contained, no external modules. 100% free tools.

youtube-automation

33
from aAAaqwq/AGI-Super-Team

Automate YouTube tasks via Rube MCP (Composio): upload videos, manage playlists, search content, get analytics, and handle comments. Always search tools first for current schemas.