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 ADDED
@@ -0,0 +1,3 @@
1
+ """Net-Sift: coverage-first social and web deep-search as an MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ """Access layer: walled login platforms reached through the user's logged-in
2
+ Chromium browser via OpenCLI. Keyless sources live in net_sift.engine.sources."""
@@ -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."""