@alfe.ai/openclaw-linkedin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/THIRD_PARTY_NOTICES.md +24 -0
- package/dist/cli.cjs +45 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +46 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/installer.cjs +320 -0
- package/dist/installer.js +285 -0
- package/dist/plugin.cjs +5 -0
- package/dist/plugin.d.cts +6 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js +6 -0
- package/dist/runtime.cjs +525 -0
- package/dist/runtime.d.cts +117 -0
- package/dist/runtime.d.ts +117 -0
- package/dist/runtime.js +496 -0
- package/openclaw.plugin.json +8 -0
- package/package.json +62 -0
- package/python/requirements.txt +3 -0
- package/python/supervisor.py +149 -0
- package/python/worker.py +235 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Own the worker's unreaped PID until its private process group is quiescent.
|
|
2
|
+
|
|
3
|
+
The supervisor is outside that group. Only it may signal the group, and it
|
|
4
|
+
never reaps its direct child until the last signal and liveness check finish.
|
|
5
|
+
Node sends cancellation and receives the cleanup acknowledgement over fd 3;
|
|
6
|
+
the worker never inherits that channel. No provider data crosses this channel.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import select
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
CONTROL_FD = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def observe_child(pid):
|
|
21
|
+
# WNOWAIT preserves the PID reservation, including after the leader exits.
|
|
22
|
+
# Never use Popen.poll()/wait() or a reaping SIGCHLD handler for this child.
|
|
23
|
+
return os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def signal_owned_group(pid, value):
|
|
27
|
+
# Losing wait ownership must fail closed, not signal a possibly reused ID.
|
|
28
|
+
observe_child(pid)
|
|
29
|
+
try:
|
|
30
|
+
os.killpg(pid, value)
|
|
31
|
+
except (ProcessLookupError, PermissionError):
|
|
32
|
+
# Darwin reports EPERM for an all-zombie group. The independent state
|
|
33
|
+
# check still must prove quiescence; a live inaccessible group parks.
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def process_snapshot():
|
|
38
|
+
"""Use the exact same bounded process inspection for readiness and cleanup."""
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["/bin/ps", "-axo", "pgid=,stat="],
|
|
41
|
+
stdout=subprocess.PIPE,
|
|
42
|
+
stderr=subprocess.DEVNULL,
|
|
43
|
+
check=True,
|
|
44
|
+
timeout=5,
|
|
45
|
+
text=True,
|
|
46
|
+
)
|
|
47
|
+
rows = [line.split() for line in result.stdout.splitlines() if line.strip()]
|
|
48
|
+
if not rows or any(
|
|
49
|
+
len(row) != 2
|
|
50
|
+
or not re.fullmatch(r"[0-9]+", row[0])
|
|
51
|
+
or not re.fullmatch(r"[RSDTtZXIWUPEN][A-Za-z<>+]*", row[1])
|
|
52
|
+
for row in rows
|
|
53
|
+
):
|
|
54
|
+
raise ValueError("Process inspection output is incompatible")
|
|
55
|
+
# Empty/truncated output must not masquerade as an absent worker group.
|
|
56
|
+
# This inspector is running and must be visible in its own process table.
|
|
57
|
+
if not any(int(row[0]) == os.getpgrp() for row in rows):
|
|
58
|
+
raise ValueError("Process inspection omitted the inspector")
|
|
59
|
+
return rows
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def group_is_quiescent(pid):
|
|
63
|
+
# The leader stays waitable while ps runs, so this PGID cannot be recycled.
|
|
64
|
+
# Already-dead orphan zombies cannot retain browser sockets or fork again.
|
|
65
|
+
try:
|
|
66
|
+
rows = process_snapshot()
|
|
67
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
68
|
+
return False
|
|
69
|
+
group = [row for row in rows if int(row[0]) == pid]
|
|
70
|
+
# The unreaped leader must still appear, even as a zombie. An absent group
|
|
71
|
+
# is incomplete inspection, not proof that our reserved identity vanished.
|
|
72
|
+
return bool(group) and all(row[1].startswith(("Z", "X")) for row in group)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def supervise(pid, control_fd, grace_seconds):
|
|
76
|
+
stopping_at = None
|
|
77
|
+
control_open = True
|
|
78
|
+
while True:
|
|
79
|
+
status = observe_child(pid)
|
|
80
|
+
now = time.monotonic()
|
|
81
|
+
if status is not None or (stopping_at is not None and now >= stopping_at + grace_seconds):
|
|
82
|
+
signal_owned_group(pid, signal.SIGKILL)
|
|
83
|
+
if observe_child(pid) is not None and group_is_quiescent(pid):
|
|
84
|
+
# This is the ONLY reaping call, and no signaling follows it.
|
|
85
|
+
_, wait_status = os.waitpid(pid, 0)
|
|
86
|
+
return os.waitstatus_to_exitcode(wait_status)
|
|
87
|
+
|
|
88
|
+
if control_open:
|
|
89
|
+
readable, _, _ = select.select([control_fd], [], [], 0.025)
|
|
90
|
+
if readable:
|
|
91
|
+
command = os.read(control_fd, 64)
|
|
92
|
+
if not command:
|
|
93
|
+
control_open = False
|
|
94
|
+
# Any control input (or parent EOF) cancels the exact operation.
|
|
95
|
+
if stopping_at is None:
|
|
96
|
+
stopping_at = time.monotonic()
|
|
97
|
+
signal_owned_group(pid, signal.SIGTERM)
|
|
98
|
+
else:
|
|
99
|
+
time.sleep(0.025)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main():
|
|
103
|
+
if sys.argv[1:] == ["--check"]:
|
|
104
|
+
# No worker/browser capability exists during this preflight. Do not
|
|
105
|
+
# reuse the unavailable/quiescent control acknowledgements here.
|
|
106
|
+
try:
|
|
107
|
+
process_snapshot()
|
|
108
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
109
|
+
return 78
|
|
110
|
+
print("ready")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
required = ("fork", "setsid", "waitid", "WNOWAIT", "WEXITED", "WNOHANG", "P_PID")
|
|
114
|
+
if not all(hasattr(os, name) for name in required):
|
|
115
|
+
# No worker exists, so Node can safely reject this unsupported runtime.
|
|
116
|
+
os.write(CONTROL_FD, b"unavailable\n")
|
|
117
|
+
return 78
|
|
118
|
+
|
|
119
|
+
signal.signal(signal.SIGCHLD, signal.SIG_DFL)
|
|
120
|
+
ready_read, ready_write = os.pipe()
|
|
121
|
+
pid = os.fork()
|
|
122
|
+
if pid == 0:
|
|
123
|
+
try:
|
|
124
|
+
os.close(CONTROL_FD)
|
|
125
|
+
os.close(ready_read)
|
|
126
|
+
os.setsid()
|
|
127
|
+
os.write(ready_write, b"ready")
|
|
128
|
+
os.close(ready_write)
|
|
129
|
+
os.execv(sys.executable, [sys.executable, "-I", sys.argv[1]])
|
|
130
|
+
finally:
|
|
131
|
+
os._exit(127)
|
|
132
|
+
|
|
133
|
+
os.close(ready_write)
|
|
134
|
+
ready = os.read(ready_read, 5)
|
|
135
|
+
os.close(ready_read)
|
|
136
|
+
if ready != b"ready":
|
|
137
|
+
# The child did not reach exec and therefore could not create a driver.
|
|
138
|
+
os.waitpid(pid, 0)
|
|
139
|
+
os.write(CONTROL_FD, b"quiescent\n")
|
|
140
|
+
return 127
|
|
141
|
+
|
|
142
|
+
exit_code = supervise(pid, CONTROL_FD, float(sys.argv[2]) / 1000)
|
|
143
|
+
# The callback must not settle on stdout alone, or on an unacknowledged exit.
|
|
144
|
+
os.write(CONTROL_FD, b"quiescent\n")
|
|
145
|
+
return exit_code if exit_code >= 0 else 128 - exit_code
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
sys.exit(main())
|
package/python/worker.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""One bounded extraction against an exact Alfe-owned browser target.
|
|
2
|
+
|
|
3
|
+
No MCP bootstrap, authentication manager, cookie import, browser launch or close.
|
|
4
|
+
The Node owner holds its shared operation gate until this process and driver exit.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib.metadata
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
from urllib.parse import urljoin, urlparse, urlunparse
|
|
15
|
+
|
|
16
|
+
# Set before importing upstream; explicit debug dirs override trace_mode upstream.
|
|
17
|
+
os.environ["LINKEDIN_TRACE_MODE"] = "off"
|
|
18
|
+
os.environ["LINKEDIN_DEBUG_TRACE_DIR"] = ""
|
|
19
|
+
os.environ["PYTHON_DOTENV_DISABLED"] = "1"
|
|
20
|
+
logging.disable(logging.CRITICAL)
|
|
21
|
+
|
|
22
|
+
EXTRACTOR_VERSION = "4.24.0"
|
|
23
|
+
PATCHRIGHT_VERSION = "1.61.2"
|
|
24
|
+
DOTENV_VERSION = "1.2.3"
|
|
25
|
+
MAX_INPUT = 24 * 1024
|
|
26
|
+
MAX_OUTPUT = 384 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RequestError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_request(value):
|
|
34
|
+
if not isinstance(value, dict):
|
|
35
|
+
raise RequestError()
|
|
36
|
+
endpoint = value.get("browserWSEndpoint")
|
|
37
|
+
target = value.get("targetId")
|
|
38
|
+
parsed = urlparse(endpoint) if isinstance(endpoint, str) else None
|
|
39
|
+
if (not parsed or parsed.scheme != "ws" or parsed.hostname not in ("127.0.0.1", "localhost", "::1")
|
|
40
|
+
or parsed.username or parsed.password or not parsed.path.startswith("/devtools/browser/")
|
|
41
|
+
or parsed.query or parsed.fragment or len(endpoint) > 2048):
|
|
42
|
+
raise RequestError()
|
|
43
|
+
if not isinstance(target, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", target):
|
|
44
|
+
raise RequestError()
|
|
45
|
+
if value.get("action") not in ("status", "login", "inbox", "conversation", "search", "prepare_message", "send_message"):
|
|
46
|
+
raise RequestError()
|
|
47
|
+
params = value.get("params", {})
|
|
48
|
+
if not isinstance(params, dict):
|
|
49
|
+
raise RequestError()
|
|
50
|
+
action = value["action"]
|
|
51
|
+
if action in ("inbox", "search"):
|
|
52
|
+
limit = params.get("limit", 20)
|
|
53
|
+
if type(limit) is not int or not 1 <= limit <= 50:
|
|
54
|
+
raise RequestError()
|
|
55
|
+
if action == "search":
|
|
56
|
+
bounded_string(params, "keywords", 200)
|
|
57
|
+
if action == "conversation":
|
|
58
|
+
from linkedin_mcp_server.scraping.identifiers import normalize_thread_id
|
|
59
|
+
params["threadId"] = normalize_reference(bounded_string(params, "threadId", 2048), normalize_thread_id)
|
|
60
|
+
if action in ("prepare_message", "send_message"):
|
|
61
|
+
from linkedin_mcp_server.scraping.identifiers import normalize_person_identifier
|
|
62
|
+
params["username"] = normalize_reference(bounded_string(params, "username", 2048), normalize_person_identifier)
|
|
63
|
+
if len(params["username"]) > 100:
|
|
64
|
+
raise RequestError()
|
|
65
|
+
bounded_string(params, "message", 3000)
|
|
66
|
+
if action == "send_message" and params.get("confirm") is not True:
|
|
67
|
+
raise RequestError()
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_reference(value, normalizer):
|
|
72
|
+
if "://" in value:
|
|
73
|
+
parsed = urlparse(value)
|
|
74
|
+
if parsed.scheme != "https" or parsed.hostname != "www.linkedin.com" or parsed.username or parsed.password or parsed.port:
|
|
75
|
+
raise RequestError()
|
|
76
|
+
try:
|
|
77
|
+
return normalizer(value)
|
|
78
|
+
except Exception:
|
|
79
|
+
raise RequestError() from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bounded_string(params, key, maximum):
|
|
83
|
+
value = params.get(key)
|
|
84
|
+
if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\x00" in value:
|
|
85
|
+
raise RequestError()
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def exact_page(browser, target_id):
|
|
90
|
+
"""Enumerate all attached pages, then match Chrome target identity, never order."""
|
|
91
|
+
matches = []
|
|
92
|
+
for context in browser.contexts:
|
|
93
|
+
for page in context.pages:
|
|
94
|
+
if page.is_closed():
|
|
95
|
+
continue
|
|
96
|
+
session = await context.new_cdp_session(page)
|
|
97
|
+
try:
|
|
98
|
+
info = await session.send("Target.getTargetInfo")
|
|
99
|
+
if info.get("targetInfo", {}).get("targetId") == target_id:
|
|
100
|
+
matches.append(page)
|
|
101
|
+
finally:
|
|
102
|
+
await session.detach()
|
|
103
|
+
if len(matches) != 1 or matches[0].is_closed():
|
|
104
|
+
raise RequestError()
|
|
105
|
+
return matches[0]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def project_read(result):
|
|
109
|
+
sections = {}
|
|
110
|
+
budget = 120_000
|
|
111
|
+
for key, value in result.get("sections", {}).items():
|
|
112
|
+
if key in ("inbox", "conversation", "search_results") and isinstance(value, str):
|
|
113
|
+
sections[key] = value[:budget]
|
|
114
|
+
budget -= len(sections[key])
|
|
115
|
+
references = []
|
|
116
|
+
for items in result.get("references", {}).values():
|
|
117
|
+
if not isinstance(items, list):
|
|
118
|
+
continue
|
|
119
|
+
for item in items:
|
|
120
|
+
if not isinstance(item, dict) or not isinstance(item.get("url"), str):
|
|
121
|
+
continue
|
|
122
|
+
parsed = urlparse(urljoin("https://www.linkedin.com", item["url"]))
|
|
123
|
+
if parsed.scheme != "https" or parsed.hostname != "www.linkedin.com" or parsed.username or parsed.password or parsed.port:
|
|
124
|
+
continue
|
|
125
|
+
url = urlunparse(("https", "www.linkedin.com", parsed.path, "", "", ""))
|
|
126
|
+
if len(url) > 2048:
|
|
127
|
+
continue
|
|
128
|
+
label = item.get("label", item.get("text", "LinkedIn reference"))
|
|
129
|
+
references.append({"label": str(label)[:300], "url": url})
|
|
130
|
+
if len(references) >= 100:
|
|
131
|
+
break
|
|
132
|
+
if len(references) >= 100:
|
|
133
|
+
break
|
|
134
|
+
return {"status": "ok", "sections": sections, "references": references}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def perform(page, request, extractor_class, detect_auth_barrier, is_logged_in):
|
|
138
|
+
action = request["action"]
|
|
139
|
+
params = request.get("params", {})
|
|
140
|
+
if action == "login":
|
|
141
|
+
await page.goto("https://www.linkedin.com/login", wait_until="domcontentloaded", timeout=30_000)
|
|
142
|
+
return {"status": "needs_login", "code": "needs_login"}
|
|
143
|
+
if action == "status":
|
|
144
|
+
await page.goto("https://www.linkedin.com/feed/", wait_until="domcontentloaded", timeout=30_000)
|
|
145
|
+
host = urlparse(page.url).hostname
|
|
146
|
+
if host != "www.linkedin.com" or await detect_auth_barrier(page) or not await is_logged_in(page):
|
|
147
|
+
return {"status": "needs_login", "code": "needs_login"}
|
|
148
|
+
return {"status": "ready"}
|
|
149
|
+
|
|
150
|
+
class SharedPageExtractor(extractor_class):
|
|
151
|
+
async def _goto_with_auth_checks(self, url, *, wait_until="domcontentloaded", allow_remember_me=False):
|
|
152
|
+
# Never click an account chooser or try to repair authentication.
|
|
153
|
+
# This narrow override is version-pinned and covered by a contract test.
|
|
154
|
+
return await super()._goto_with_auth_checks(url, wait_until=wait_until, allow_remember_me=False)
|
|
155
|
+
|
|
156
|
+
extractor = SharedPageExtractor(page)
|
|
157
|
+
if action == "inbox":
|
|
158
|
+
return project_read(await extractor.get_inbox(limit=params.get("limit", 20)))
|
|
159
|
+
if action == "conversation":
|
|
160
|
+
return project_read(await extractor.get_conversation(thread_id=params["threadId"]))
|
|
161
|
+
if action == "search":
|
|
162
|
+
return project_read(await extractor.search_conversations(params["keywords"], limit=params.get("limit", 20)))
|
|
163
|
+
if action in ("prepare_message", "send_message"):
|
|
164
|
+
sending = action == "send_message"
|
|
165
|
+
result = await extractor.send_message(params["username"], params["message"], confirm_send=sending)
|
|
166
|
+
if not sending:
|
|
167
|
+
if result.get("status") == "confirmation_required" and result.get("recipient_selected") is True:
|
|
168
|
+
return {"status": "prepared", "username": params["username"]}
|
|
169
|
+
return {"status": "error", "code": "recipient_unavailable"}
|
|
170
|
+
if result.get("status") == "sent" and result.get("sent") is True and result.get("recipient_selected") is True:
|
|
171
|
+
return {"status": "sent"}
|
|
172
|
+
return {"status": "error", "code": "send_unknown"}
|
|
173
|
+
raise RequestError()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def run(request):
|
|
177
|
+
from patchright.async_api import async_playwright, TimeoutError as DriverTimeout
|
|
178
|
+
from linkedin_mcp_server.scraping.extractor import LinkedInExtractor
|
|
179
|
+
from linkedin_mcp_server.core.auth import detect_auth_barrier, is_logged_in
|
|
180
|
+
from linkedin_mcp_server.core.exceptions import AuthenticationError, RateLimitError, NetworkError, ProfileNotFoundError
|
|
181
|
+
|
|
182
|
+
current = asyncio.current_task()
|
|
183
|
+
loop = asyncio.get_running_loop()
|
|
184
|
+
if os.name != "nt":
|
|
185
|
+
loop.add_signal_handler(signal.SIGTERM, current.cancel)
|
|
186
|
+
driver = await async_playwright().start()
|
|
187
|
+
try:
|
|
188
|
+
browser = await driver.chromium.connect_over_cdp(request["browserWSEndpoint"], no_defaults=True, timeout=15_000)
|
|
189
|
+
page = await exact_page(browser, request["targetId"])
|
|
190
|
+
try:
|
|
191
|
+
return await perform(page, request, LinkedInExtractor, detect_auth_barrier, is_logged_in)
|
|
192
|
+
except AuthenticationError:
|
|
193
|
+
return {"status": "needs_login", "code": "needs_login"}
|
|
194
|
+
except RateLimitError:
|
|
195
|
+
return {"status": "error", "code": "rate_limited"}
|
|
196
|
+
except ProfileNotFoundError:
|
|
197
|
+
return {"status": "error", "code": "page_unavailable"}
|
|
198
|
+
except (NetworkError, DriverTimeout):
|
|
199
|
+
return {"status": "error", "code": "send_unknown" if request["action"] == "send_message" else "network_error"}
|
|
200
|
+
finally:
|
|
201
|
+
# Patchright.stop tears down this connection/driver, not Alfe's browser.
|
|
202
|
+
# Calling Browser.close, Context.close or Page.close here is forbidden.
|
|
203
|
+
await driver.stop()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main():
|
|
207
|
+
try:
|
|
208
|
+
raw = sys.stdin.buffer.read(MAX_INPUT + 1)
|
|
209
|
+
if len(raw) > MAX_INPUT:
|
|
210
|
+
raise RequestError()
|
|
211
|
+
unvalidated = json.loads(raw)
|
|
212
|
+
if (importlib.metadata.version("mcp-server-linkedin") != EXTRACTOR_VERSION
|
|
213
|
+
or importlib.metadata.version("patchright") != PATCHRIGHT_VERSION
|
|
214
|
+
or importlib.metadata.version("python-dotenv") != DOTENV_VERSION):
|
|
215
|
+
result = {"status": "error", "code": "runtime_mismatch"}
|
|
216
|
+
else:
|
|
217
|
+
# Identifier normalization imports upstream too. Verify before ANY
|
|
218
|
+
# such import, including validation-only and mutation input paths.
|
|
219
|
+
request = validate_request(unvalidated)
|
|
220
|
+
result = asyncio.run(run(request))
|
|
221
|
+
except (RequestError, json.JSONDecodeError, UnicodeDecodeError):
|
|
222
|
+
result = {"status": "error", "code": "invalid_request"}
|
|
223
|
+
except importlib.metadata.PackageNotFoundError:
|
|
224
|
+
result = {"status": "error", "code": "runtime_mismatch"}
|
|
225
|
+
except Exception:
|
|
226
|
+
result = {"status": "error", "code": "extraction_failed"}
|
|
227
|
+
encoded = json.dumps(result, ensure_ascii=False).encode("utf-8")
|
|
228
|
+
if len(encoded) > MAX_OUTPUT:
|
|
229
|
+
encoded = b'{"status":"error","code":"extraction_failed"}'
|
|
230
|
+
sys.stdout.buffer.write(encoded)
|
|
231
|
+
sys.stdout.buffer.flush()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
main()
|