linkedin-cdp

v2026.09.24

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

GitHub
安装命令
npx skhub add aaaaqwq/linkedin-cdp
Markdown
SKILL.md

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

WhatPath
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.

/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:

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:

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

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:

ElementCSS (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

ElementCSS (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.

ElementCSS (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.

ElementCSS (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

ElementCSS (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

FileClassKey Methods
linkedin_cdp.pyLinkedInBotclick_at(), type_text(), scroll_wheel(), take_screenshot() -> path, take_screenshot_base64(), navigate_to()
linkedin_messages.pyLinkedInMessagesscreenshot_conversations(), open_conversation(), send_message(), collect_screenshots()
linkedin_search.pyLinkedInSearchsearch_people(), search_companies(), next_page()
linkedin_profile.pyLinkedInProfileview_profile(), screenshot_full_profile(), scroll_to_section()
linkedin_connect.pyLinkedInConnectview_profile(), send_connection_note(), screenshot_invitations(), accept_invitation()
rate_limiter.pyRateLimitercan_search(), can_view_profile(), wait_if_needed()

Examples

Search → Profile → Connect with Note (full flow)

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

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)

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

ProblemSolution
WebSocketConnectionClosedException after navigateCall reconnect_to_tab() — page change breaks WebSocket
Click misses targetVerify DPR: image_pixels / DPR = CSS coords. Re-run calibration.
screenshot_full_profile() crashesUse manual navigate + reconnect + scroll loop (see View Profile example)
scroll_wheel() TypeErrorUse keyword arg only: scroll_wheel(delta_y=500), NOT positional args
Chrome opens tab in existing browserUse binary path directly, NOT open -a 'Google Chrome'
CDP port conflictChange --remote-debugging-port=9223 and update CDP_PORT in linkedin_cdp.py
Modal coordinates wrongWindow 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)

ActionConservativeModerate
Profile views50100
Searches2050
Connection requests1525
Messages sent3050

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
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/linkedin-cdp

默认分支

main

最新提交

b996aac

Tree SHA

07e787b