refactor: strip to installer + doctor + docs, remove read/search wrapper layer
BREAKING CHANGE: Remove all `agent-reach read` and `agent-reach search-*` commands. Agent Reach is now an installer, configuration tool, and doctor — not a wrapper layer. After installation, agents call upstream tools directly (bird CLI, yt-dlp, mcporter, gh CLI, Jina Reader, etc.). What's kept: - agent-reach install: one-shot installer - agent-reach doctor: channel status overview - agent-reach configure: cookies, proxy, credentials - agent-reach setup: interactive wizard - SKILL.md: complete guide for agents to use upstream tools directly What's removed: - agent-reach read URL (and all channel read() methods) - agent-reach search-* commands (and all channel search() methods) - ReadResult / SearchResult data classes - URL routing system (get_channel_for_url) - All parsing/conversion logic (VTT, Reddit JSON, bird text parser, etc.) - MCP server read/search tools (kept only get_status) Net change: -1790 lines. Less code = fewer bugs.
This commit is contained in:
@@ -1,268 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""LinkedIn — via linkedin-scraper-mcp (MCP) or Jina Reader fallback.
|
||||
|
||||
Backend: linkedin-scraper-mcp (916 stars, Patchright browser automation)
|
||||
Swap to: any LinkedIn access tool
|
||||
"""
|
||||
"""LinkedIn — check if linkedin-scraper-mcp is available."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from urllib.parse import urlparse
|
||||
from .base import Channel, ReadResult, SearchResult
|
||||
from typing import List
|
||||
import requests
|
||||
|
||||
|
||||
def _mcporter_has_linkedin() -> bool:
|
||||
"""Check if mcporter has linkedin MCP configured."""
|
||||
if not shutil.which("mcporter"):
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return "linkedin" in r.stdout.lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _mcporter_call(expr: str, timeout: int = 30) -> str:
|
||||
"""Call a LinkedIn MCP tool via mcporter."""
|
||||
r = subprocess.run(
|
||||
["mcporter", "call", expr],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(r.stderr or r.stdout)
|
||||
return r.stdout
|
||||
from .base import Channel
|
||||
|
||||
|
||||
class LinkedInChannel(Channel):
|
||||
name = "linkedin"
|
||||
description = "LinkedIn 个人/公司 Profile 和职位"
|
||||
description = "LinkedIn 职业社交"
|
||||
backends = ["linkedin-scraper-mcp", "Jina Reader"]
|
||||
tier = 2
|
||||
|
||||
def can_handle(self, url: str) -> bool:
|
||||
domain = urlparse(url).netloc.lower()
|
||||
return "linkedin.com" in domain
|
||||
from urllib.parse import urlparse
|
||||
return "linkedin.com" in urlparse(url).netloc.lower()
|
||||
|
||||
def check(self, config=None):
|
||||
if _mcporter_has_linkedin():
|
||||
return "ok", "完整可用(Profile、公司、职位搜索)"
|
||||
|
||||
# Check if linkedin-scraper-mcp is installed as CLI
|
||||
if shutil.which("linkedin-scraper-mcp"):
|
||||
return "warn", (
|
||||
"linkedin-scraper-mcp 已安装但未接入 mcporter。运行:\n"
|
||||
" 1. linkedin-scraper-mcp --login(在有浏览器的机器上登录)\n"
|
||||
" 2. linkedin-scraper-mcp --transport streamable-http --port 8001\n"
|
||||
" 3. mcporter config add linkedin http://localhost:8001/mcp"
|
||||
if not shutil.which("mcporter"):
|
||||
return "off", (
|
||||
"基本内容可通过 Jina Reader 读取。完整功能需要:\n"
|
||||
" pip install linkedin-scraper-mcp\n"
|
||||
" mcporter config add linkedin http://localhost:3000/mcp\n"
|
||||
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
|
||||
)
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["mcporter", "list"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if "linkedin" in r.stdout.lower():
|
||||
return "ok", "完整可用(Profile、公司、职位搜索)"
|
||||
except Exception:
|
||||
pass
|
||||
return "off", (
|
||||
"可通过 Jina Reader 读取部分内容。完整功能需要:\n"
|
||||
" 1. pip install linkedin-scraper-mcp\n"
|
||||
" 2. linkedin-scraper-mcp --login(在有浏览器的机器上登录)\n"
|
||||
" 3. linkedin-scraper-mcp --transport streamable-http --port 8001\n"
|
||||
" 4. mcporter config add linkedin http://localhost:8001/mcp\n"
|
||||
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
|
||||
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
|
||||
" pip install linkedin-scraper-mcp\n"
|
||||
" mcporter config add linkedin http://localhost:3000/mcp"
|
||||
)
|
||||
|
||||
async def read(self, url: str, config=None) -> ReadResult:
|
||||
path = urlparse(url).path.strip("/")
|
||||
|
||||
# Try MCP first
|
||||
if _mcporter_has_linkedin():
|
||||
try:
|
||||
if "/in/" in url:
|
||||
return await self._read_profile_mcp(url)
|
||||
elif "/company/" in url:
|
||||
return await self._read_company_mcp(url)
|
||||
elif "/jobs/view/" in url:
|
||||
return await self._read_job_mcp(url)
|
||||
except Exception:
|
||||
pass # Fall through to Jina
|
||||
|
||||
# Fallback: Jina Reader
|
||||
return await self._read_jina(url)
|
||||
|
||||
async def _read_profile_mcp(self, url: str) -> ReadResult:
|
||||
"""Read a LinkedIn profile via MCP."""
|
||||
import re
|
||||
# Extract username from URL: /in/username/
|
||||
match = re.search(r"/in/([^/]+)", url)
|
||||
if not match:
|
||||
return await self._read_jina(url)
|
||||
username = match.group(1)
|
||||
safe_username = username.replace('"', '\\"')
|
||||
out = _mcporter_call(
|
||||
f'linkedin.get_person_profile(linkedin_username: "{safe_username}")',
|
||||
timeout=60,
|
||||
)
|
||||
return ReadResult(
|
||||
title=self._extract_title(out) or f"LinkedIn Profile - {username}",
|
||||
content=out.strip(),
|
||||
url=url,
|
||||
platform="linkedin",
|
||||
)
|
||||
|
||||
async def _read_company_mcp(self, url: str) -> ReadResult:
|
||||
"""Read a LinkedIn company page via MCP."""
|
||||
import re
|
||||
# Extract company name from URL: /company/name/
|
||||
match = re.search(r"/company/([^/]+)", url)
|
||||
if not match:
|
||||
return await self._read_jina(url)
|
||||
company = match.group(1)
|
||||
safe_company = company.replace('"', '\\"')
|
||||
out = _mcporter_call(
|
||||
f'linkedin.get_company_profile(company_name: "{safe_company}")',
|
||||
timeout=60,
|
||||
)
|
||||
return ReadResult(
|
||||
title=self._extract_title(out) or "LinkedIn Company",
|
||||
content=out.strip(),
|
||||
url=url,
|
||||
platform="linkedin",
|
||||
)
|
||||
|
||||
async def _read_job_mcp(self, url: str) -> ReadResult:
|
||||
"""Read a LinkedIn job posting via MCP."""
|
||||
import re
|
||||
match = re.search(r"/jobs/view/(\d+)", url)
|
||||
if not match:
|
||||
return await self._read_jina(url)
|
||||
|
||||
job_id = match.group(1)
|
||||
out = _mcporter_call(
|
||||
f'linkedin.get_job_details(job_id: "{job_id}")',
|
||||
timeout=30,
|
||||
)
|
||||
return ReadResult(
|
||||
title=self._extract_title(out) or f"LinkedIn Job {job_id}",
|
||||
content=out.strip(),
|
||||
url=url,
|
||||
platform="linkedin",
|
||||
)
|
||||
|
||||
async def _read_jina(self, url: str) -> ReadResult:
|
||||
"""Fallback: use Jina Reader."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"https://r.jina.ai/{url}",
|
||||
headers={"Accept": "text/markdown"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.text
|
||||
|
||||
# Check if content is usable
|
||||
if len(text.strip()) < 100 or "Sign in" in text[:200]:
|
||||
return ReadResult(
|
||||
title="LinkedIn",
|
||||
content=(
|
||||
f"⚠️ LinkedIn 页面需要登录才能完整查看。\n\n"
|
||||
f"URL: {url}\n\n"
|
||||
"完整功能需安装 linkedin-scraper-mcp:\n"
|
||||
" pip install linkedin-scraper-mcp\n"
|
||||
" uvx linkedin-scraper-mcp --login\n"
|
||||
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
|
||||
),
|
||||
url=url,
|
||||
platform="linkedin",
|
||||
)
|
||||
|
||||
return ReadResult(
|
||||
title=text[:100] if text else url,
|
||||
content=text,
|
||||
url=url,
|
||||
platform="linkedin",
|
||||
)
|
||||
except Exception:
|
||||
return ReadResult(
|
||||
title="LinkedIn",
|
||||
content=(
|
||||
f"⚠️ 无法读取此 LinkedIn 页面: {url}\n\n"
|
||||
"提示:\n"
|
||||
"- LinkedIn 需要登录才能查看大部分内容\n"
|
||||
"- 安装 linkedin-scraper-mcp 解锁完整功能\n"
|
||||
"- 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
|
||||
),
|
||||
url=url,
|
||||
platform="linkedin",
|
||||
)
|
||||
|
||||
async def search(self, query: str, config=None, **kwargs) -> List[SearchResult]:
|
||||
limit = kwargs.get("limit", 10)
|
||||
|
||||
# Try MCP search first
|
||||
if _mcporter_has_linkedin():
|
||||
try:
|
||||
return await self._search_mcp(query, limit)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to Exa
|
||||
from agent_reach.channels.exa_search import ExaSearchChannel
|
||||
exa = ExaSearchChannel()
|
||||
return await exa.search(f"site:linkedin.com {query}", config=config, limit=limit)
|
||||
|
||||
async def _search_mcp(self, query: str, limit: int) -> List[SearchResult]:
|
||||
"""Search LinkedIn via MCP."""
|
||||
safe_q = query.replace('"', '\\"')
|
||||
# Try job search first (most common use case)
|
||||
try:
|
||||
out = _mcporter_call(
|
||||
f'linkedin.search_jobs(keywords: "{safe_q}")',
|
||||
timeout=60,
|
||||
)
|
||||
results = self._parse_search_results(out, "job")
|
||||
if results:
|
||||
return results[:limit]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try people search
|
||||
try:
|
||||
out = _mcporter_call(
|
||||
f'linkedin.search_people(keywords: "{safe_q}")',
|
||||
timeout=60,
|
||||
)
|
||||
results = self._parse_search_results(out, "people")
|
||||
if results:
|
||||
return results
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return []
|
||||
|
||||
def _parse_search_results(self, text: str, result_type: str) -> List[SearchResult]:
|
||||
"""Parse MCP search output into SearchResults."""
|
||||
import json
|
||||
results = []
|
||||
try:
|
||||
data = json.loads(text)
|
||||
items = data if isinstance(data, list) else data.get("results", data.get("jobs", []))
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
title = item.get("title") or item.get("name") or item.get("headline", "")
|
||||
url = item.get("url") or item.get("link", "")
|
||||
snippet = item.get("description") or item.get("company", "")
|
||||
results.append(SearchResult(
|
||||
title=title,
|
||||
url=url,
|
||||
snippet=snippet[:200] if snippet else "",
|
||||
))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Try line-by-line parsing
|
||||
pass
|
||||
return results
|
||||
|
||||
def _extract_title(self, text: str) -> str:
|
||||
"""Extract a title from MCP output."""
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith(("{", "[", "#", "http")):
|
||||
return line[:80]
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user