net-sift 0.1.0__py3-none-any.whl
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.
- net_sift/__init__.py +3 -0
- net_sift/access/__init__.py +2 -0
- net_sift/access/opencli.py +320 -0
- net_sift/cli.py +159 -0
- net_sift/config.py +25 -0
- net_sift/doctor.py +100 -0
- net_sift/engine/__init__.py +1 -0
- net_sift/engine/core.py +235 -0
- net_sift/engine/ranking.py +261 -0
- net_sift/engine/sources.py +470 -0
- net_sift/guides/setup-opencli.md +49 -0
- net_sift/guides/sources.md +39 -0
- net_sift/mcp_server.py +109 -0
- net_sift/py.typed +0 -0
- net_sift/search.py +115 -0
- net_sift/sessions.py +120 -0
- net_sift/skill/SKILL.md +40 -0
- net_sift/status.py +27 -0
- net_sift-0.1.0.dist-info/METADATA +228 -0
- net_sift-0.1.0.dist-info/RECORD +24 -0
- net_sift-0.1.0.dist-info/WHEEL +4 -0
- net_sift-0.1.0.dist-info/entry_points.txt +2 -0
- net_sift-0.1.0.dist-info/licenses/LICENSE +21 -0
- net_sift-0.1.0.dist-info/licenses/NOTICE +14 -0
net_sift/__init__.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Walled-platform access through OpenCLI (https://github.com/jackwener/opencli).
|
|
2
|
+
|
|
3
|
+
OpenCLI drives the user's real logged-in Chrome via a browser-bridge extension and
|
|
4
|
+
a local daemon, so login platforms are reached as the human, with no cookie
|
|
5
|
+
copying or private-API reverse engineering. Built-in adapters cover Twitter/X,
|
|
6
|
+
Reddit, Instagram, Facebook, Bilibili, Xiaohongshu, Zhihu, and more.
|
|
7
|
+
|
|
8
|
+
net-sift shells to ``opencli <site> <command> -f json`` directly and normalizes the
|
|
9
|
+
structured output into engine records, so the raw platform payload never reaches
|
|
10
|
+
the agent. Adapters are discovered at runtime via ``opencli list`` rather than
|
|
11
|
+
hardcoded, so net-sift tracks OpenCLI's adapter set as it changes.
|
|
12
|
+
|
|
13
|
+
Desktop-only: requires Node >= 20.18.1, the OpenCLI runtime, Chrome with the
|
|
14
|
+
bridge extension, and an existing logged-in session. When any of that is missing,
|
|
15
|
+
access degrades to a declared gap, never a crash.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import urllib.request
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
|
|
27
|
+
from ..engine.core import Record, parse_iso, rec
|
|
28
|
+
|
|
29
|
+
OPENCLI_BIN = os.environ.get("NET_SIFT_OPENCLI_BIN", "opencli")
|
|
30
|
+
_DAEMON_STATUS_URL = "http://127.0.0.1:19825/status" # loopback, side-effect-free
|
|
31
|
+
|
|
32
|
+
# Login platforms we route through OpenCLI. Keyless platforms (hackernews, etc.)
|
|
33
|
+
# stay in the engine even though OpenCLI also offers them.
|
|
34
|
+
WALLED_SITES = (
|
|
35
|
+
"twitter",
|
|
36
|
+
"x",
|
|
37
|
+
"reddit",
|
|
38
|
+
"instagram",
|
|
39
|
+
"facebook",
|
|
40
|
+
"bilibili",
|
|
41
|
+
"xiaohongshu",
|
|
42
|
+
"zhihu",
|
|
43
|
+
)
|
|
44
|
+
# Commands that mean "search this platform for a query", most to least preferred.
|
|
45
|
+
_SEARCH_COMMANDS = ("search", "query", "feed")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OpenCLIUnavailable(RuntimeError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def binary() -> str | None:
|
|
53
|
+
return shutil.which(OPENCLI_BIN)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def daemon_status(timeout: int = 2) -> dict | None:
|
|
57
|
+
"""Read OpenCLI's loopback status without starting the CLI (which has side
|
|
58
|
+
effects). None when the daemon is not reachable."""
|
|
59
|
+
try:
|
|
60
|
+
req = urllib.request.Request(_DAEMON_STATUS_URL, headers={"X-OpenCLI": "1"})
|
|
61
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
62
|
+
return json.loads(r.read(64 * 1024))
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def available() -> bool:
|
|
68
|
+
"""True when the binary exists, the daemon answers, and a browser is connected."""
|
|
69
|
+
if not binary():
|
|
70
|
+
return False
|
|
71
|
+
st = daemon_status()
|
|
72
|
+
if not st:
|
|
73
|
+
return False
|
|
74
|
+
return bool(st.get("extension_connected") or st.get("connected") or st.get("ready"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def status() -> str:
|
|
78
|
+
"""One-line, secret-free summary for the doctor."""
|
|
79
|
+
if not binary():
|
|
80
|
+
return "opencli: not installed (npm i -g @jackwener/opencli, or OpenCLIApp)"
|
|
81
|
+
st = daemon_status()
|
|
82
|
+
if not st:
|
|
83
|
+
return "opencli: installed, daemon not running (open Chrome with the OpenCLI extension)"
|
|
84
|
+
if available():
|
|
85
|
+
return "opencli: connected"
|
|
86
|
+
return "opencli: daemon up, browser/extension not connected (open Chrome, enable the extension)"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run(
|
|
90
|
+
site: str,
|
|
91
|
+
command: str,
|
|
92
|
+
*args: str,
|
|
93
|
+
fmt: str = "json",
|
|
94
|
+
limit: int | None = None,
|
|
95
|
+
timeout: int = 90,
|
|
96
|
+
):
|
|
97
|
+
"""Run ``opencli <site> <command> [args] -f json`` and return parsed output."""
|
|
98
|
+
exe = binary()
|
|
99
|
+
if not exe:
|
|
100
|
+
raise OpenCLIUnavailable("opencli binary not found")
|
|
101
|
+
cmd = [exe, site, command, *args, "-f", fmt]
|
|
102
|
+
if limit is not None:
|
|
103
|
+
cmd += ["--limit", str(limit)]
|
|
104
|
+
try:
|
|
105
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
106
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
107
|
+
raise OpenCLIUnavailable(f"opencli invocation failed: {e}") from e
|
|
108
|
+
if proc.returncode != 0:
|
|
109
|
+
raise OpenCLIUnavailable(
|
|
110
|
+
f"opencli {site} {command} exit {proc.returncode}: {proc.stderr.strip()[:200]}"
|
|
111
|
+
)
|
|
112
|
+
out = proc.stdout.strip()
|
|
113
|
+
if not out:
|
|
114
|
+
return []
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(out)
|
|
117
|
+
except ValueError as e:
|
|
118
|
+
raise OpenCLIUnavailable(f"opencli {site} {command}: non-JSON output") from e
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def discover() -> dict[str, list[str]]:
|
|
122
|
+
"""Map site -> [commands] from ``opencli list``. Empty when unavailable."""
|
|
123
|
+
exe = binary()
|
|
124
|
+
if not exe:
|
|
125
|
+
return {}
|
|
126
|
+
try:
|
|
127
|
+
proc = subprocess.run(
|
|
128
|
+
[exe, "list", "-f", "json"], capture_output=True, text=True, timeout=30
|
|
129
|
+
)
|
|
130
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
131
|
+
return _parse_list_json(proc.stdout)
|
|
132
|
+
except (OSError, subprocess.SubprocessError):
|
|
133
|
+
pass
|
|
134
|
+
# Fall back to the plain-text listing shape ("site command - description").
|
|
135
|
+
try:
|
|
136
|
+
proc = subprocess.run([exe, "list"], capture_output=True, text=True, timeout=30)
|
|
137
|
+
return _parse_list_text(proc.stdout) if proc.returncode == 0 else {}
|
|
138
|
+
except (OSError, subprocess.SubprocessError):
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_list_json(text: str) -> dict[str, list[str]]:
|
|
143
|
+
try:
|
|
144
|
+
data = json.loads(text)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return {}
|
|
147
|
+
out: dict[str, list[str]] = {}
|
|
148
|
+
items = data if isinstance(data, list) else data.get("commands", [])
|
|
149
|
+
for it in items:
|
|
150
|
+
if not isinstance(it, dict):
|
|
151
|
+
continue
|
|
152
|
+
site = it.get("site") or it.get("namespace") or it.get("group")
|
|
153
|
+
command = it.get("command") or it.get("name") or it.get("action")
|
|
154
|
+
if site and command:
|
|
155
|
+
out.setdefault(site, []).append(command)
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_list_text(text: str) -> dict[str, list[str]]:
|
|
160
|
+
out: dict[str, list[str]] = {}
|
|
161
|
+
for line in text.splitlines():
|
|
162
|
+
line = line.strip()
|
|
163
|
+
if not line or line.startswith("#"):
|
|
164
|
+
continue
|
|
165
|
+
parts = line.split()
|
|
166
|
+
if len(parts) >= 2 and parts[0].isidentifier():
|
|
167
|
+
out.setdefault(parts[0], []).append(parts[1])
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- normalization --------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
_ID_KEYS = ("id", "rest_id", "note_id", "aweme_id", "pk", "objectID", "url")
|
|
174
|
+
_URL_KEYS = ("url", "link", "permalink", "share_url", "webpage_url", "href")
|
|
175
|
+
_TEXT_KEYS = (
|
|
176
|
+
"text",
|
|
177
|
+
"full_text",
|
|
178
|
+
"content",
|
|
179
|
+
"caption",
|
|
180
|
+
"caption_text",
|
|
181
|
+
"body",
|
|
182
|
+
"desc",
|
|
183
|
+
"description",
|
|
184
|
+
"summary",
|
|
185
|
+
"title",
|
|
186
|
+
)
|
|
187
|
+
_CREATED_KEYS = (
|
|
188
|
+
"created_at",
|
|
189
|
+
"createdAt",
|
|
190
|
+
"created_utc",
|
|
191
|
+
"timestamp",
|
|
192
|
+
"taken_at",
|
|
193
|
+
"published",
|
|
194
|
+
"date",
|
|
195
|
+
"time",
|
|
196
|
+
)
|
|
197
|
+
_ENGAGEMENT = {
|
|
198
|
+
"likes": ("likes", "like_count", "favorite_count", "digg_count", "favoriteCount"),
|
|
199
|
+
"comments": ("comments", "comment_count", "reply_count", "replies", "commentCount"),
|
|
200
|
+
"reposts": ("reposts", "retweet_count", "share_count", "shares", "forwards"),
|
|
201
|
+
"views": ("views", "play_count", "view_count", "viewCount"),
|
|
202
|
+
"score": ("score", "points", "upvotes", "ups"),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _first(d: dict, keys) -> object:
|
|
207
|
+
for k in keys:
|
|
208
|
+
v = d.get(k)
|
|
209
|
+
if v not in (None, "", [], {}):
|
|
210
|
+
return v
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _author(d: dict) -> str | None:
|
|
215
|
+
for k in ("author", "username", "screen_name", "handle", "uploader", "nickname", "name"):
|
|
216
|
+
v = d.get(k)
|
|
217
|
+
if isinstance(v, str) and v:
|
|
218
|
+
return v
|
|
219
|
+
for k in ("user", "owner", "account"):
|
|
220
|
+
u = d.get(k)
|
|
221
|
+
if isinstance(u, dict):
|
|
222
|
+
for kk in ("screen_name", "username", "name", "nickname", "unique_id"):
|
|
223
|
+
if u.get(kk):
|
|
224
|
+
return u[kk]
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _created(d: dict) -> str | None:
|
|
229
|
+
v = _first(d, _CREATED_KEYS)
|
|
230
|
+
if v is None:
|
|
231
|
+
return None
|
|
232
|
+
if isinstance(v, (int, float)) and v > 1_000_000_000:
|
|
233
|
+
return datetime.fromtimestamp(v, timezone.utc).isoformat()
|
|
234
|
+
if isinstance(v, str):
|
|
235
|
+
return v
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _text(d: dict) -> str:
|
|
240
|
+
parts = []
|
|
241
|
+
for k in ("title",):
|
|
242
|
+
if isinstance(d.get(k), str) and d[k]:
|
|
243
|
+
parts.append(d[k])
|
|
244
|
+
body = _first(d, [k for k in _TEXT_KEYS if k != "title"])
|
|
245
|
+
if isinstance(body, dict):
|
|
246
|
+
body = body.get("text") or ""
|
|
247
|
+
if isinstance(body, str) and body and body not in parts:
|
|
248
|
+
parts.append(body)
|
|
249
|
+
return "\n".join(parts)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def normalize(item: dict, site: str) -> Record | None:
|
|
253
|
+
if not isinstance(item, dict):
|
|
254
|
+
return None
|
|
255
|
+
ident = _first(item, _ID_KEYS)
|
|
256
|
+
text = _text(item)
|
|
257
|
+
if ident is None and not text:
|
|
258
|
+
return None
|
|
259
|
+
extra = {}
|
|
260
|
+
for field, keys in _ENGAGEMENT.items():
|
|
261
|
+
v = _first(item, keys)
|
|
262
|
+
if isinstance(v, (int, float)):
|
|
263
|
+
extra[field] = v
|
|
264
|
+
return rec(
|
|
265
|
+
site,
|
|
266
|
+
ident or abs(hash(text)) % 10**12,
|
|
267
|
+
_first(item, _URL_KEYS) or "",
|
|
268
|
+
_author(item),
|
|
269
|
+
text,
|
|
270
|
+
_created(item),
|
|
271
|
+
extra,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _items(payload) -> list[dict]:
|
|
276
|
+
"""Find the list of result objects in whatever envelope the adapter returns."""
|
|
277
|
+
if isinstance(payload, list):
|
|
278
|
+
return [x for x in payload if isinstance(x, dict)]
|
|
279
|
+
if isinstance(payload, dict):
|
|
280
|
+
for k in ("results", "data", "items", "notes", "tweets", "posts", "list"):
|
|
281
|
+
v = payload.get(k)
|
|
282
|
+
if isinstance(v, list):
|
|
283
|
+
return [x for x in v if isinstance(x, dict)]
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def make_source(site: str, command: str):
|
|
288
|
+
"""Build an engine source that searches one walled platform via OpenCLI."""
|
|
289
|
+
|
|
290
|
+
def source(query, since, until, budget):
|
|
291
|
+
payload = run(site, command, query, limit=min(budget, 100))
|
|
292
|
+
out: list[Record] = []
|
|
293
|
+
for item in _items(payload)[:budget]:
|
|
294
|
+
r = normalize(item, site)
|
|
295
|
+
if not r:
|
|
296
|
+
continue
|
|
297
|
+
when = parse_iso(r.get("created_at"))
|
|
298
|
+
if when and since and when < since:
|
|
299
|
+
continue
|
|
300
|
+
if when and until and when > until:
|
|
301
|
+
continue
|
|
302
|
+
out.append(r)
|
|
303
|
+
return out, False
|
|
304
|
+
|
|
305
|
+
source.__name__ = f"opencli_{site}"
|
|
306
|
+
return source
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def walled_sources(sites=WALLED_SITES) -> dict[str, object]:
|
|
310
|
+
"""Discover installed walled adapters and build a source per searchable site."""
|
|
311
|
+
if not available():
|
|
312
|
+
return {}
|
|
313
|
+
catalog = discover()
|
|
314
|
+
out: dict[str, object] = {}
|
|
315
|
+
for site in sites:
|
|
316
|
+
cmds = catalog.get(site) or []
|
|
317
|
+
command = next((c for c in _SEARCH_COMMANDS if c in cmds), None)
|
|
318
|
+
if command:
|
|
319
|
+
out[site] = make_source(site, command)
|
|
320
|
+
return out
|
net_sift/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""net-sift command line: serve the MCP, run the doctor, print status, and install
|
|
2
|
+
into Claude Code and Codex.
|
|
3
|
+
|
|
4
|
+
The install writers are small, idempotent, and take explicit paths so they can be
|
|
5
|
+
tested against a temp home without touching a real config.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from . import doctor as doctor_mod
|
|
17
|
+
from . import search as search_mod
|
|
18
|
+
from . import status as status_mod
|
|
19
|
+
|
|
20
|
+
MCP_ENTRY = {"command": "net-sift", "args": ["serve"]}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- install writers (pure, testable) -------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def merge_json_mcp(path: Path) -> str:
|
|
27
|
+
"""Add the net-sift MCP server to a Claude-style ~/.claude.json."""
|
|
28
|
+
data = {}
|
|
29
|
+
if path.exists():
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
+
except ValueError:
|
|
33
|
+
return f"skipped {path} (not valid JSON; add net-sift manually)"
|
|
34
|
+
servers = data.setdefault("mcpServers", {})
|
|
35
|
+
if servers.get("net-sift") == MCP_ENTRY:
|
|
36
|
+
return f"already present in {path}"
|
|
37
|
+
servers["net-sift"] = MCP_ENTRY
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
40
|
+
return f"registered MCP in {path}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_statusline(path: Path) -> str:
|
|
44
|
+
"""Set the Claude Code status bar to net-sift in settings.json."""
|
|
45
|
+
data = {}
|
|
46
|
+
if path.exists():
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except ValueError:
|
|
50
|
+
return f"skipped {path} (not valid JSON; add statusLine manually)"
|
|
51
|
+
line = {"type": "command", "command": "net-sift status --line", "padding": 0}
|
|
52
|
+
if data.get("statusLine") == line:
|
|
53
|
+
return f"status bar already set in {path}"
|
|
54
|
+
data["statusLine"] = line
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
57
|
+
return f"status bar set in {path}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_codex(path: Path) -> str:
|
|
61
|
+
"""Add [mcp_servers.net-sift] to a Codex config.toml without rewriting the file."""
|
|
62
|
+
block = '\n[mcp_servers.net-sift]\ncommand = "net-sift"\nargs = ["serve"]\n'
|
|
63
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
64
|
+
if "[mcp_servers.net-sift]" in existing:
|
|
65
|
+
return f"already present in {path}"
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path.write_text(existing + block, encoding="utf-8")
|
|
68
|
+
return f"registered MCP in {path}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- commands -------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_install(args) -> int:
|
|
75
|
+
home = Path.home()
|
|
76
|
+
print("Installing net-sift for Claude Code and Codex...\n")
|
|
77
|
+
for msg in (
|
|
78
|
+
merge_json_mcp(home / ".claude.json"),
|
|
79
|
+
ensure_statusline(home / ".claude" / "settings.json"),
|
|
80
|
+
ensure_codex(home / ".codex" / "config.toml"),
|
|
81
|
+
):
|
|
82
|
+
print(" " + msg)
|
|
83
|
+
|
|
84
|
+
print("\nDependency check:")
|
|
85
|
+
print(" node :", doctor_mod._node_version())
|
|
86
|
+
print(
|
|
87
|
+
" opencli:",
|
|
88
|
+
"found"
|
|
89
|
+
if shutil.which("opencli")
|
|
90
|
+
else "missing (npm i -g @jackwener/opencli, or install OpenCLIApp)",
|
|
91
|
+
)
|
|
92
|
+
print("\n" + doctor_mod.render())
|
|
93
|
+
print("\nRestart your client so it picks up the MCP server.")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cmd_serve(args) -> int:
|
|
98
|
+
from .mcp_server import main as serve
|
|
99
|
+
|
|
100
|
+
serve()
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_doctor(args) -> int:
|
|
105
|
+
print(doctor_mod.render(probe=args.probe))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_status(args) -> int:
|
|
110
|
+
if args.line:
|
|
111
|
+
print(status_mod.line())
|
|
112
|
+
else:
|
|
113
|
+
print(json.dumps(status_mod.snapshot(), indent=2))
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_search(args) -> int:
|
|
118
|
+
platforms = args.platforms.split(",") if args.platforms else None
|
|
119
|
+
res = search_mod.deep_search(
|
|
120
|
+
args.query, platforms=platforms, since=args.since, until=args.until, max_budget=args.max
|
|
121
|
+
)
|
|
122
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main(argv: list | None = None) -> int:
|
|
127
|
+
ap = argparse.ArgumentParser(
|
|
128
|
+
prog="net-sift", description="Coverage-first social and web deep-search (MCP)."
|
|
129
|
+
)
|
|
130
|
+
ap.add_argument("--version", action="version", version=f"net-sift {__version__}")
|
|
131
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
132
|
+
|
|
133
|
+
sub.add_parser(
|
|
134
|
+
"install", help="register the MCP + status bar in Claude Code and Codex"
|
|
135
|
+
).set_defaults(func=_cmd_install)
|
|
136
|
+
sub.add_parser("serve", help="run the MCP server (stdio)").set_defaults(func=_cmd_serve)
|
|
137
|
+
|
|
138
|
+
d = sub.add_parser("doctor", help="what can net-sift reach right now")
|
|
139
|
+
d.add_argument("--probe", action="store_true", help="also live-check keyless sources")
|
|
140
|
+
d.set_defaults(func=_cmd_doctor)
|
|
141
|
+
|
|
142
|
+
s = sub.add_parser("status", help="connectivity snapshot")
|
|
143
|
+
s.add_argument("--line", action="store_true", help="one-line form for a status bar")
|
|
144
|
+
s.set_defaults(func=_cmd_status)
|
|
145
|
+
|
|
146
|
+
q = sub.add_parser("search", help="run a search locally (prints the summary)")
|
|
147
|
+
q.add_argument("query")
|
|
148
|
+
q.add_argument("--platforms", help="comma-separated source names")
|
|
149
|
+
q.add_argument("--since", help="YYYY-MM-DD")
|
|
150
|
+
q.add_argument("--until", help="YYYY-MM-DD")
|
|
151
|
+
q.add_argument("--max", type=int, default=200, help="per-source budget")
|
|
152
|
+
q.set_defaults(func=_cmd_search)
|
|
153
|
+
|
|
154
|
+
args = ap.parse_args(argv)
|
|
155
|
+
return args.func(args)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
raise SystemExit(main())
|
net_sift/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Runtime configuration. Everything is resolved from the environment so the tool
|
|
2
|
+
stays portable; no OS keychain, no secrets on disk in this repo."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
#: Root for session storage and caches. Override with NET_SIFT_HOME.
|
|
10
|
+
HOME = Path(os.environ.get("NET_SIFT_HOME", "~/.net-sift")).expanduser()
|
|
11
|
+
SESSIONS_DIR = HOME / "sessions"
|
|
12
|
+
|
|
13
|
+
#: Engine defaults.
|
|
14
|
+
DEFAULT_BUDGET = 2000 # per-source, per-window record cap
|
|
15
|
+
DEFAULT_NEAR_DUP = 0.85 # same-source near-duplicate collapse threshold
|
|
16
|
+
DEFAULT_MIN_REL = 0.1 # relevance floor for ranking
|
|
17
|
+
|
|
18
|
+
#: Optional environment keys the tool will use if present (never required).
|
|
19
|
+
OPTIONAL_ENV = {
|
|
20
|
+
"GITHUB_TOKEN": "lifts the GitHub Search API rate limit",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_dirs() -> None:
|
|
25
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
net_sift/doctor.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Health check: what can net-sift reach right now, and what to do if nothing.
|
|
2
|
+
|
|
3
|
+
Keyless sources are listed (optionally probed live). Walled platforms report
|
|
4
|
+
through OpenCLI without spending or writing. The scaffolding mirrors the pattern
|
|
5
|
+
in Agent Reach (MIT).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
from .access import opencli
|
|
16
|
+
from .engine import sources
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def report(probe: bool = False) -> dict:
|
|
20
|
+
"""Structured health report. probe=True live-checks keyless sources (slower)."""
|
|
21
|
+
walled = opencli.walled_sources()
|
|
22
|
+
data = {
|
|
23
|
+
"opencli": opencli.status(),
|
|
24
|
+
"node": _node_version(),
|
|
25
|
+
"walled_available": sorted(walled.keys()),
|
|
26
|
+
"walled_supported": list(opencli.WALLED_SITES),
|
|
27
|
+
"keyless_sources": list(sources.SOURCES.keys()),
|
|
28
|
+
"optional_env": {
|
|
29
|
+
k: ("set" if os.environ.get(k) else "unset") + f" ({desc})"
|
|
30
|
+
for k, desc in config.OPTIONAL_ENV.items()
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
if probe:
|
|
34
|
+
data["keyless_reachable"] = _probe_keyless()
|
|
35
|
+
data["guidance"] = _guidance(walled)
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _node_version() -> str:
|
|
40
|
+
exe = shutil.which("node")
|
|
41
|
+
if not exe:
|
|
42
|
+
return "not installed (OpenCLI needs Node >= 20.18.1)"
|
|
43
|
+
import subprocess
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
v = subprocess.run(
|
|
47
|
+
[exe, "--version"], capture_output=True, text=True, timeout=5
|
|
48
|
+
).stdout.strip()
|
|
49
|
+
return v or "installed"
|
|
50
|
+
except (OSError, subprocess.SubprocessError):
|
|
51
|
+
return "installed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _probe_keyless() -> dict:
|
|
55
|
+
now = datetime.now(timezone.utc)
|
|
56
|
+
out = {}
|
|
57
|
+
for name, fn in sources.SOURCES.items():
|
|
58
|
+
if name == "telegram": # needs channels; skip in a generic probe
|
|
59
|
+
out[name] = "skipped (needs --channels)"
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
recs, _ = fn("test", now - timedelta(days=30), now, 3)
|
|
63
|
+
out[name] = f"ok ({len(recs)})"
|
|
64
|
+
except Exception as e:
|
|
65
|
+
out[name] = f"unreachable: {type(e).__name__}"
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _guidance(walled: dict) -> str:
|
|
70
|
+
if walled:
|
|
71
|
+
return f"Walled platforms ready via OpenCLI: {', '.join(sorted(walled))}."
|
|
72
|
+
if not opencli.binary():
|
|
73
|
+
return (
|
|
74
|
+
"No walled platform reachable. Install OpenCLI (npm i -g @jackwener/opencli "
|
|
75
|
+
"or the OpenCLIApp), open a Chromium browser with the OpenCLI extension, and log "
|
|
76
|
+
"into the platforms you want (X, Reddit, Instagram, Facebook, Bilibili, Xiaohongshu)."
|
|
77
|
+
)
|
|
78
|
+
return (
|
|
79
|
+
"OpenCLI is installed but no browser session is connected. Open a Chromium browser "
|
|
80
|
+
"with the OpenCLI extension enabled and log into the platforms you want."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render(probe: bool = False) -> str:
|
|
85
|
+
d = report(probe=probe)
|
|
86
|
+
lines = ["net-sift doctor", "=" * 40]
|
|
87
|
+
lines.append(f"opencli : {d['opencli']}")
|
|
88
|
+
lines.append(f"node : {d['node']}")
|
|
89
|
+
lines.append(f"walled : {', '.join(d['walled_available']) or 'none connected'}")
|
|
90
|
+
lines.append(f"keyless : {', '.join(d['keyless_sources'])}")
|
|
91
|
+
lines.append("env :")
|
|
92
|
+
for k, v in d["optional_env"].items():
|
|
93
|
+
lines.append(f" {k}: {v}")
|
|
94
|
+
if "keyless_reachable" in d:
|
|
95
|
+
lines.append("keyless reachability:")
|
|
96
|
+
for k, v in d["keyless_reachable"].items():
|
|
97
|
+
lines.append(f" {k:14} {v}")
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append(d["guidance"])
|
|
100
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Net-Sift engine: sources, ranking, dedup, coverage. Stdlib-only."""
|