@gajae-code/coding-agent 0.7.1 → 0.7.3
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/CHANGELOG.md +57 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/notify-cli.d.ts +2 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/mcp.d.ts +70 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/settings-schema.d.ts +39 -2
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
- package/dist/types/modes/theme/defaults/index.d.ts +99 -0
- package/dist/types/notifications/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -0
- package/dist/types/notifications/managed-daemon.d.ts +48 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/telegram-daemon.d.ts +73 -16
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/notifications/topic-registry.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/tools/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +25 -0
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +10 -8
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/notify-cli.ts +152 -5
- package/src/cli.ts +6 -2
- package/src/commands/mcp.ts +117 -0
- package/src/commands/team.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/settings-schema.ts +30 -1
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +4 -3
- package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +17 -3
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-runtime.ts +2 -2
- package/src/gjc-runtime/tmux-common.ts +3 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +7 -2
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +14 -11
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/custom-editor.ts +2 -0
- package/src/modes/components/footer.ts +2 -3
- package/src/modes/components/model-selector.ts +12 -0
- package/src/modes/components/status-line/git-utils.ts +25 -0
- package/src/modes/components/status-line.ts +10 -11
- package/src/modes/components/welcome.ts +2 -3
- package/src/modes/controllers/event-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +3 -0
- package/src/modes/interactive-mode.ts +48 -3
- package/src/modes/shared/agent-wire/scopes.ts +1 -1
- package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/utils/context-usage.ts +2 -2
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/index.ts +180 -38
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/telegram-daemon.ts +553 -236
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/notifications/topic-registry.ts +5 -0
- package/src/session/agent-session.ts +82 -51
- package/src/slash-commands/helpers/parse.ts +2 -1
- package/src/tools/bash.ts +9 -0
- package/src/tools/composer-bash-policy.ts +96 -0
- package/src/tools/fetch.ts +94 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/telegram-send.ts +137 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +159 -0
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""U5: lightweight per-host self-learning store (`observations/learned.json`).
|
|
2
|
+
|
|
3
|
+
Records which fetch route (impersonate × referer × url-transform × phase) last
|
|
4
|
+
SUCCEEDED for a host, so the next visit promotes it to the probe / front of the
|
|
5
|
+
grid instead of rediscovering it from scratch. The store is bounded and
|
|
6
|
+
self-pruning so it can never grow without limit:
|
|
7
|
+
|
|
8
|
+
* eviction on failure — a learned route that fails on a REAL block
|
|
9
|
+
(`exhausted` / `challenge` / `blocked`) earns a strike; after
|
|
10
|
+
``EVICT_AFTER_FAILS`` consecutive real failures the entry is deleted.
|
|
11
|
+
Transient outcomes (429 rate-limit, network/unknown error, budget cut) and
|
|
12
|
+
URL-level outcomes (404/401) never strike — they are not the route's fault.
|
|
13
|
+
* TTL — an entry unused for ``TTL_DAYS`` is pruned the next time the store is
|
|
14
|
+
loaded (default 30 days).
|
|
15
|
+
* cap — at most ``MAX_ENTRIES`` (default 500); on overflow the
|
|
16
|
+
least-recently-used entries are dropped.
|
|
17
|
+
|
|
18
|
+
This is a DATA file, never code, so the No-Site-Name Rule (R3) holds: per-site
|
|
19
|
+
knowledge lives in JSON that both the engine and the agent can read, while the
|
|
20
|
+
fetch chain itself stays site-agnostic.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from datetime import datetime, timezone, timedelta
|
|
27
|
+
from typing import Optional
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
TTL_DAYS = int(os.environ.get("INSANE_LEARN_TTL_DAYS", "30"))
|
|
31
|
+
MAX_ENTRIES = int(os.environ.get("INSANE_LEARN_MAX", "500"))
|
|
32
|
+
EVICT_AFTER_FAILS = 2
|
|
33
|
+
|
|
34
|
+
# stop_reason values that mean the bypass ROUTE genuinely failed (→ strike).
|
|
35
|
+
# Everything else (rate_limited / unknown / budget / auth_required / not_found /
|
|
36
|
+
# success / "") is transient or URL-level and never strikes the route.
|
|
37
|
+
PENALIZE_REASONS = frozenset({"exhausted", "challenge", "blocked"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def enabled() -> bool:
|
|
41
|
+
return os.environ.get("INSANE_LEARN", "1") not in ("0", "false", "no")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_path() -> str:
|
|
45
|
+
p = os.environ.get("INSANE_LEARNED_PATH")
|
|
46
|
+
if p:
|
|
47
|
+
return p
|
|
48
|
+
return os.path.join(os.path.expanduser("~"), ".insane_search", "learned.json")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_real_failure(stop_reason: str) -> bool:
|
|
52
|
+
"""True when `stop_reason` means the route itself was blocked (→ strike)."""
|
|
53
|
+
return (stop_reason or "") in PENALIZE_REASONS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def key_for(url: str, device_class: str) -> str:
|
|
57
|
+
host = (urlsplit(url).netloc or "").lower()
|
|
58
|
+
dev = "mobile" if device_class == "mobile" else "desktop"
|
|
59
|
+
return f"{host}::{dev}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now() -> datetime:
|
|
63
|
+
return datetime.now(timezone.utc)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse(ts: str) -> Optional[datetime]:
|
|
67
|
+
try:
|
|
68
|
+
dt = datetime.fromisoformat(ts)
|
|
69
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _prune(data: dict, now: Optional[datetime] = None) -> dict:
|
|
75
|
+
"""Drop TTL-expired entries, then enforce the LRU cap. Pure (in-memory)."""
|
|
76
|
+
now = now or _now()
|
|
77
|
+
cutoff = now - timedelta(days=TTL_DAYS)
|
|
78
|
+
kept = {}
|
|
79
|
+
for k, v in data.items():
|
|
80
|
+
lu = _parse(v.get("last_used", "")) if isinstance(v, dict) else None
|
|
81
|
+
if lu is None or lu >= cutoff:
|
|
82
|
+
kept[k] = v
|
|
83
|
+
if len(kept) > MAX_ENTRIES:
|
|
84
|
+
# keep the MAX_ENTRIES most-recently-used
|
|
85
|
+
ordered = sorted(
|
|
86
|
+
kept.items(),
|
|
87
|
+
key=lambda kv: _parse(kv[1].get("last_used", "")) or now,
|
|
88
|
+
reverse=True,
|
|
89
|
+
)
|
|
90
|
+
kept = dict(ordered[:MAX_ENTRIES])
|
|
91
|
+
return kept
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load(path: Optional[str] = None) -> dict:
|
|
95
|
+
"""Load the store, pruning TTL-expired + over-cap entries in memory.
|
|
96
|
+
|
|
97
|
+
Pruning is not persisted here (write-on-read is wasteful); the next
|
|
98
|
+
`record_*` save writes the pruned set back, so the file converges."""
|
|
99
|
+
path = path or default_path()
|
|
100
|
+
try:
|
|
101
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
102
|
+
data = json.load(f)
|
|
103
|
+
if not isinstance(data, dict):
|
|
104
|
+
return {}
|
|
105
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
106
|
+
return {}
|
|
107
|
+
return _prune(data)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def save(data: dict, path: Optional[str] = None) -> None:
|
|
111
|
+
path = path or default_path()
|
|
112
|
+
try:
|
|
113
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
114
|
+
tmp = f"{path}.tmp"
|
|
115
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
116
|
+
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
|
117
|
+
os.replace(tmp, path)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass # learning is best-effort; never break a fetch on a write error
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lookup(url: str, device_class: str, path: Optional[str] = None,
|
|
123
|
+
data: Optional[dict] = None) -> Optional[dict]:
|
|
124
|
+
"""Return the learned route dict for this host, or None."""
|
|
125
|
+
data = load(path) if data is None else data
|
|
126
|
+
entry = data.get(key_for(url, device_class))
|
|
127
|
+
if isinstance(entry, dict):
|
|
128
|
+
route = entry.get("route")
|
|
129
|
+
if isinstance(route, dict):
|
|
130
|
+
return route
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def record_success(url: str, device_class: str, route: dict,
|
|
135
|
+
path: Optional[str] = None) -> None:
|
|
136
|
+
"""Upsert the winning route for this host (resets the failure strike)."""
|
|
137
|
+
path = path or default_path()
|
|
138
|
+
data = load(path)
|
|
139
|
+
k = key_for(url, device_class)
|
|
140
|
+
now = _now().isoformat()
|
|
141
|
+
raw = data.get(k)
|
|
142
|
+
entry = raw if isinstance(raw, dict) else {}
|
|
143
|
+
same = entry.get("route") == route
|
|
144
|
+
data[k] = {
|
|
145
|
+
"route": route,
|
|
146
|
+
"wins": int(entry.get("wins", 0)) + 1 if same else 1,
|
|
147
|
+
"consecutive_fails": 0,
|
|
148
|
+
"last_used": now,
|
|
149
|
+
"last_success": now,
|
|
150
|
+
}
|
|
151
|
+
save(_prune(data), path)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def record_failure(url: str, device_class: str, penalize: bool,
|
|
155
|
+
path: Optional[str] = None) -> None:
|
|
156
|
+
"""Record that the learned route did not win this run.
|
|
157
|
+
|
|
158
|
+
`penalize=True` (a real block) strikes the entry and deletes it after
|
|
159
|
+
EVICT_AFTER_FAILS consecutive strikes. `penalize=False` (transient / URL
|
|
160
|
+
issue) just refreshes `last_used` so an actively-retried host is not
|
|
161
|
+
TTL-pruned. No-op when nothing was learned for this host."""
|
|
162
|
+
path = path or default_path()
|
|
163
|
+
data = load(path)
|
|
164
|
+
k = key_for(url, device_class)
|
|
165
|
+
entry = data.get(k)
|
|
166
|
+
if not isinstance(entry, dict):
|
|
167
|
+
return
|
|
168
|
+
if penalize:
|
|
169
|
+
entry["consecutive_fails"] = int(entry.get("consecutive_fails", 0)) + 1
|
|
170
|
+
entry["last_used"] = _now().isoformat()
|
|
171
|
+
if entry["consecutive_fails"] >= EVICT_AFTER_FAILS:
|
|
172
|
+
del data[k]
|
|
173
|
+
else:
|
|
174
|
+
entry["last_used"] = _now().isoformat()
|
|
175
|
+
save(_prune(data), path)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Phase 0 — official public-API router (the SANCTIONED exception to No-Site-Name).
|
|
2
|
+
|
|
3
|
+
Per SKILL.md R5, platforms that publish official no-auth public endpoints get a
|
|
4
|
+
deterministic route tried BEFORE the generic WAF grid. This is the *enforced,
|
|
5
|
+
in-engine* version of what used to be agent-driven curl snippets in SKILL.md —
|
|
6
|
+
so the agent can no longer silently skip it (which is exactly how Reddit/X were
|
|
7
|
+
wrongly declared "blocked": the grid 403'd on `.json` and nobody tried `.rss`).
|
|
8
|
+
|
|
9
|
+
This file is the ONLY engine/ module allowed to name platform hosts; it is
|
|
10
|
+
exempted in `bias_check.EXPLICIT_ALLOW_FILES`. Do NOT add per-site logic to any
|
|
11
|
+
other engine file — generic WAF handling stays site-agnostic.
|
|
12
|
+
|
|
13
|
+
Contract:
|
|
14
|
+
route(url) -> Optional[dict]
|
|
15
|
+
None → url is not a recognised Phase-0 platform; caller runs
|
|
16
|
+
the generic grid as usual.
|
|
17
|
+
{"platform","ok","route","content","final_url","attempts":[...]}
|
|
18
|
+
→ recognised platform. `ok` says whether an official
|
|
19
|
+
route succeeded. Even on ok=False the caller should
|
|
20
|
+
fall through to the grid, but `attempts` is recorded
|
|
21
|
+
so failure is never silent.
|
|
22
|
+
|
|
23
|
+
Each attempt dict: {"route","platform","ok","status","bytes","note"}.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
from typing import Optional
|
|
30
|
+
from urllib.parse import urlsplit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- low-level helpers -------------------------------------------------------
|
|
34
|
+
def _cffi_get(url: str, *, impersonate: str = "safari", timeout: int = 15):
|
|
35
|
+
from curl_cffi import requests as r # lazy: engine works even if missing
|
|
36
|
+
from . import safety
|
|
37
|
+
|
|
38
|
+
allow_private = safety.allow_private_default()
|
|
39
|
+
ok, reason = safety.classify_url(url, allow_private)
|
|
40
|
+
if not ok:
|
|
41
|
+
raise RuntimeError(f"ssrf_blocked:{reason}")
|
|
42
|
+
|
|
43
|
+
headers = {
|
|
44
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
45
|
+
"Accept-Language": "en-US,en;q=0.9,ko;q=0.8",
|
|
46
|
+
}
|
|
47
|
+
cur = url
|
|
48
|
+
for _ in range(safety.DEFAULT_MAX_REDIRECTS + 1):
|
|
49
|
+
resp = r.get(
|
|
50
|
+
cur,
|
|
51
|
+
impersonate=impersonate, # type: ignore[arg-type]
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
headers=headers,
|
|
54
|
+
allow_redirects=False,
|
|
55
|
+
)
|
|
56
|
+
if not safety.is_redirect(resp):
|
|
57
|
+
return resp
|
|
58
|
+
loc = safety.location_of(resp)
|
|
59
|
+
if not loc:
|
|
60
|
+
return resp
|
|
61
|
+
nxt = safety.resolve_redirect(cur, loc)
|
|
62
|
+
ok, reason = safety.classify_url(nxt, allow_private)
|
|
63
|
+
if not ok:
|
|
64
|
+
raise RuntimeError(f"ssrf_redirect_blocked:{reason}")
|
|
65
|
+
cur = nxt
|
|
66
|
+
raise RuntimeError("too_many_redirects")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _host(url: str) -> str:
|
|
70
|
+
h = (urlsplit(url).hostname or "").lower()
|
|
71
|
+
return h[4:] if h.startswith("www.") else h # strip the literal "www." prefix only
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _attempt(platform: str, route: str, ok: bool, status: int, body: str, note: str = "") -> dict:
|
|
75
|
+
return {"platform": platform, "route": route, "ok": ok, "status": status,
|
|
76
|
+
"bytes": len(body or ""), "note": note}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- platform detectors ------------------------------------------------------
|
|
80
|
+
def _detect(url: str) -> Optional[str]:
|
|
81
|
+
h = _host(url)
|
|
82
|
+
if not h:
|
|
83
|
+
return None
|
|
84
|
+
if "reddit.com" in h or h == "redd.it":
|
|
85
|
+
return "reddit"
|
|
86
|
+
if h in ("x.com", "twitter.com") or h.endswith(".x.com") or h.endswith(".twitter.com"):
|
|
87
|
+
return "x"
|
|
88
|
+
if "youtube.com" in h or h == "youtu.be":
|
|
89
|
+
return "youtube"
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- reddit ------------------------------------------------------------------
|
|
94
|
+
def _reddit(url: str, timeout: int) -> dict:
|
|
95
|
+
attempts: list[dict] = []
|
|
96
|
+
base = url.split("?", 1)[0].rstrip("/")
|
|
97
|
+
# Build an .rss / .json target from the path (works for /r/<sub> and post URLs).
|
|
98
|
+
rss_url = base + ("/.rss" if "/comments/" not in base else ".rss")
|
|
99
|
+
json_url = base + ("/.json" if "/comments/" not in base else ".json")
|
|
100
|
+
|
|
101
|
+
# Route 1: RSS (the route that actually survives — Reddit gates the JSON API).
|
|
102
|
+
try:
|
|
103
|
+
x = _cffi_get(rss_url, timeout=timeout)
|
|
104
|
+
ok = x.status_code == 200 and ("<rss" in x.text or "<feed" in x.text)
|
|
105
|
+
attempts.append(_attempt("reddit", "rss", ok, x.status_code, x.text,
|
|
106
|
+
"feed" if ok else "no-feed-markers"))
|
|
107
|
+
if ok:
|
|
108
|
+
return {"platform": "reddit", "ok": True, "route": "rss",
|
|
109
|
+
"content": x.text, "final_url": rss_url, "attempts": attempts}
|
|
110
|
+
except Exception as e:
|
|
111
|
+
attempts.append(_attempt("reddit", "rss", False, 0, "", f"{type(e).__name__}"))
|
|
112
|
+
|
|
113
|
+
# Route 2: JSON via curl_cffi (often 403 now, but try — cheap).
|
|
114
|
+
try:
|
|
115
|
+
x = _cffi_get(json_url, timeout=timeout)
|
|
116
|
+
ok = x.status_code == 200 and x.text.lstrip().startswith(("{", "["))
|
|
117
|
+
attempts.append(_attempt("reddit", "json", ok, x.status_code, x.text,
|
|
118
|
+
"json" if ok else f"status={x.status_code}"))
|
|
119
|
+
if ok:
|
|
120
|
+
return {"platform": "reddit", "ok": True, "route": "json",
|
|
121
|
+
"content": x.text, "final_url": json_url, "attempts": attempts}
|
|
122
|
+
except Exception as e:
|
|
123
|
+
attempts.append(_attempt("reddit", "json", False, 0, "", f"{type(e).__name__}"))
|
|
124
|
+
|
|
125
|
+
return {"platform": "reddit", "ok": False, "route": None, "content": "",
|
|
126
|
+
"final_url": url, "attempts": attempts}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- x / twitter -------------------------------------------------------------
|
|
130
|
+
_TWEET_ID_RE = re.compile(r"/status(?:es)?/(\d+)")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _x(url: str, timeout: int) -> dict:
|
|
134
|
+
attempts: list[dict] = []
|
|
135
|
+
m = _TWEET_ID_RE.search(url)
|
|
136
|
+
|
|
137
|
+
if m: # single tweet → tweet-result + oembed (both no-auth, reliable)
|
|
138
|
+
tid = m.group(1)
|
|
139
|
+
try:
|
|
140
|
+
x = _cffi_get(f"https://cdn.syndication.twimg.com/tweet-result?id={tid}&token=a", timeout=timeout)
|
|
141
|
+
d = x.json() if x.status_code == 200 else {}
|
|
142
|
+
ok = bool(d.get("text"))
|
|
143
|
+
attempts.append(_attempt("x", "tweet-result", ok, x.status_code, x.text,
|
|
144
|
+
"has-text" if ok else f"status={x.status_code}"))
|
|
145
|
+
if ok:
|
|
146
|
+
return {"platform": "x", "ok": True, "route": "tweet-result",
|
|
147
|
+
"content": x.text, "final_url": url, "attempts": attempts}
|
|
148
|
+
except Exception as e:
|
|
149
|
+
attempts.append(_attempt("x", "tweet-result", False, 0, "", f"{type(e).__name__}"))
|
|
150
|
+
try:
|
|
151
|
+
ourl = f"https://publish.twitter.com/oembed?url=https://twitter.com/i/status/{tid}&omit_script=1"
|
|
152
|
+
x = _cffi_get(ourl, timeout=timeout)
|
|
153
|
+
d = x.json() if x.status_code == 200 else {}
|
|
154
|
+
ok = bool(d.get("html"))
|
|
155
|
+
attempts.append(_attempt("x", "oembed", ok, x.status_code, x.text,
|
|
156
|
+
"has-html" if ok else f"status={x.status_code}"))
|
|
157
|
+
if ok:
|
|
158
|
+
return {"platform": "x", "ok": True, "route": "oembed",
|
|
159
|
+
"content": x.text, "final_url": ourl, "attempts": attempts}
|
|
160
|
+
except Exception as e:
|
|
161
|
+
attempts.append(_attempt("x", "oembed", False, 0, "", f"{type(e).__name__}"))
|
|
162
|
+
else: # profile timeline → syndication (rate-limit-prone; retry once)
|
|
163
|
+
handle = urlsplit(url).path.strip("/").split("/")[0]
|
|
164
|
+
_reserved = {"i", "search", "home", "explore", "messages", "notifications", "settings", "hashtag"}
|
|
165
|
+
if handle and handle.lower() not in _reserved:
|
|
166
|
+
surl = f"https://syndication.twitter.com/srv/timeline-profile/screen-name/{handle}"
|
|
167
|
+
for attempt_no in range(2):
|
|
168
|
+
try:
|
|
169
|
+
x = _cffi_get(surl, timeout=timeout)
|
|
170
|
+
ok = x.status_code == 200 and "__NEXT_DATA__" in x.text
|
|
171
|
+
attempts.append(_attempt("x", f"syndication-timeline#{attempt_no+1}", ok,
|
|
172
|
+
x.status_code, x.text,
|
|
173
|
+
"timeline" if ok else f"status={x.status_code}"))
|
|
174
|
+
if ok:
|
|
175
|
+
return {"platform": "x", "ok": True, "route": "syndication-timeline",
|
|
176
|
+
"content": x.text, "final_url": surl, "attempts": attempts}
|
|
177
|
+
except Exception as e:
|
|
178
|
+
attempts.append(_attempt("x", f"syndication-timeline#{attempt_no+1}", False, 0, "", f"{type(e).__name__}"))
|
|
179
|
+
|
|
180
|
+
return {"platform": "x", "ok": False, "route": None, "content": "",
|
|
181
|
+
"final_url": url, "attempts": attempts}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- youtube -----------------------------------------------------------------
|
|
185
|
+
def _youtube(url: str, timeout: int) -> dict:
|
|
186
|
+
attempts: list[dict] = []
|
|
187
|
+
try:
|
|
188
|
+
p = subprocess.run(
|
|
189
|
+
["yt-dlp", "--dump-json", "--skip-download", url],
|
|
190
|
+
capture_output=True, text=True, timeout=max(timeout, 60),
|
|
191
|
+
)
|
|
192
|
+
ok = p.returncode == 0 and p.stdout.strip().startswith("{")
|
|
193
|
+
note = "json" if ok else (p.stderr or "").strip()[:80]
|
|
194
|
+
attempts.append(_attempt("youtube", "yt-dlp", ok, 200 if ok else 0, p.stdout, note))
|
|
195
|
+
if ok:
|
|
196
|
+
return {"platform": "youtube", "ok": True, "route": "yt-dlp",
|
|
197
|
+
"content": p.stdout, "final_url": url, "attempts": attempts}
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
attempts.append(_attempt("youtube", "yt-dlp", False, 0, "", "yt-dlp not installed"))
|
|
200
|
+
except Exception as e:
|
|
201
|
+
attempts.append(_attempt("youtube", "yt-dlp", False, 0, "", f"{type(e).__name__}"))
|
|
202
|
+
return {"platform": "youtube", "ok": False, "route": None, "content": "",
|
|
203
|
+
"final_url": url, "attempts": attempts}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_ROUTERS = {"reddit": _reddit, "x": _x, "youtube": _youtube}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- public entrypoint -------------------------------------------------------
|
|
210
|
+
def route(url: str, *, timeout: int = 15) -> Optional[dict]:
|
|
211
|
+
platform = _detect(url)
|
|
212
|
+
if platform is None:
|
|
213
|
+
return None
|
|
214
|
+
return _ROUTERS[platform](url, timeout)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""SSRF / redirect safety guard for an agent-facing fetcher.
|
|
2
|
+
|
|
3
|
+
curl_cffi follows redirects but does NOT validate the destination (confirmed
|
|
4
|
+
against the official docs: there is no built-in private-IP/safe-redirect
|
|
5
|
+
option). Since this engine fetches attacker-influenced URLs and follows their
|
|
6
|
+
redirects, a hostile page could redirect to loopback, RFC-1918, link-local, or
|
|
7
|
+
the cloud metadata endpoint (169.254.169.254) to exfiltrate internal data.
|
|
8
|
+
|
|
9
|
+
This module provides a pure, deterministic classifier and a redirect resolver.
|
|
10
|
+
Default-deny for private/internal targets; opt in with allow_private=True
|
|
11
|
+
(env INSANE_ALLOW_PRIVATE=1) for local testing.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
from urllib.parse import urljoin, urlsplit
|
|
19
|
+
|
|
20
|
+
ALLOWED_SCHEMES = {"http", "https"}
|
|
21
|
+
DEFAULT_MAX_REDIRECTS = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def allow_private_default() -> bool:
|
|
25
|
+
return os.environ.get("INSANE_ALLOW_PRIVATE", "") in ("1", "true", "yes")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ip_blocked(ip_str: str) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
ip = ipaddress.ip_address(ip_str)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
33
|
+
return (ip.is_private or ip.is_loopback or ip.is_link_local
|
|
34
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify_url(url: str, allow_private: bool = False) -> tuple[bool, str]:
|
|
38
|
+
"""(is_safe, reason). Blocks non-http(s) schemes and hosts that are — or
|
|
39
|
+
DNS-resolve to — private/loopback/link-local/reserved/metadata addresses."""
|
|
40
|
+
try:
|
|
41
|
+
p = urlsplit(url)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return False, f"parse_error:{e}"
|
|
44
|
+
if p.scheme not in ALLOWED_SCHEMES:
|
|
45
|
+
return False, f"scheme:{p.scheme or 'none'}"
|
|
46
|
+
host = p.hostname
|
|
47
|
+
if not host:
|
|
48
|
+
return False, "no_host"
|
|
49
|
+
if allow_private:
|
|
50
|
+
return True, "allow_private"
|
|
51
|
+
|
|
52
|
+
# IP literal host → check directly (covers cloud metadata, loopback, …) # NOTE-BIAS-OK
|
|
53
|
+
try:
|
|
54
|
+
ipaddress.ip_address(host)
|
|
55
|
+
return (False, f"ip_blocked:{host}") if _ip_blocked(host) else (True, "public_ip")
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# Hostname → resolve and check every A/AAAA (DNS-rebinding defense).
|
|
60
|
+
try:
|
|
61
|
+
port = p.port or (443 if p.scheme == "https" else 80)
|
|
62
|
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
63
|
+
ips = {info[4][0] for info in infos}
|
|
64
|
+
except Exception:
|
|
65
|
+
# Don't hard-fail on resolver hiccups — the real request will error out
|
|
66
|
+
# naturally; we only need to stop redirects INTO internal space.
|
|
67
|
+
return True, "resolve_failed_allow"
|
|
68
|
+
for ip in ips:
|
|
69
|
+
if _ip_blocked(str(ip)):
|
|
70
|
+
return False, f"resolves_internal:{host}->{ip}"
|
|
71
|
+
return True, "public"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def location_of(resp) -> str | None:
|
|
75
|
+
"""Case-insensitive Location header from a curl_cffi/requests response."""
|
|
76
|
+
try:
|
|
77
|
+
headers = {k.lower(): v for k, v in dict(getattr(resp, "headers", {}) or {}).items()}
|
|
78
|
+
return headers.get("location")
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_redirect(resp) -> bool:
|
|
84
|
+
try:
|
|
85
|
+
return int(getattr(resp, "status_code", 0) or 0) in (301, 302, 303, 307, 308)
|
|
86
|
+
except Exception:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_redirect(base_url: str, location: str) -> str:
|
|
91
|
+
return urljoin(base_url, location)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "insane-search-templates",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Local deps for Playwright real-Chrome templates. npm install && npx playwright install chrome",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"playwright": "^1.58.2",
|
|
8
|
+
"playwright-extra": "^4.3.6",
|
|
9
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generic Playwright mobile fetcher — real Chrome + device emulation.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* echo '{"url":"...", "device":"iPhone 13 Pro"}' | node playwright_mobile_chrome.js
|
|
7
|
+
*
|
|
8
|
+
* Device name must match playwright `devices[...]` keys (Pixel 7, iPhone 13 Pro,
|
|
9
|
+
* iPad Pro 11, etc.). When in doubt, omit `device` — default is iPhone 13 Pro.
|
|
10
|
+
*
|
|
11
|
+
* NO-SITE-NAME RULE: same as playwright_real_chrome.js — no hostname branches.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const dns = require('dns').promises;
|
|
15
|
+
const net = require('net');
|
|
16
|
+
|
|
17
|
+
function writeStdoutAsync(payload) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
process.stdout.write(payload, (err) => (err ? reject(err) : resolve()));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function buildEnvelope(ctx, page, html, resp, automation) {
|
|
24
|
+
let cookies = [];
|
|
25
|
+
try { cookies = (await ctx.cookies()).map((c) => ({ name: c.name, value: c.value, domain: c.domain })); } catch (_e) {}
|
|
26
|
+
let userAgent = '';
|
|
27
|
+
try { userAgent = await page.evaluate(() => navigator.userAgent); } catch (_e) {}
|
|
28
|
+
let finalUrl = '';
|
|
29
|
+
try { finalUrl = page.url(); } catch (_e) {}
|
|
30
|
+
let status = 0;
|
|
31
|
+
try { status = resp ? resp.status() : 0; } catch (_e) {}
|
|
32
|
+
return JSON.stringify({ html, finalUrl, status, cookies, userAgent, automation });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnsafeUrlError extends Error {
|
|
37
|
+
constructor(reason) {
|
|
38
|
+
super(`unsafe_url:${reason}`);
|
|
39
|
+
this.name = 'UnsafeUrlError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isBlockedHostname(hostname) {
|
|
44
|
+
const h = (hostname || '').toLowerCase().replace(/\.$/, '');
|
|
45
|
+
return !h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.home.arpa');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPrivateIPv4(address) {
|
|
49
|
+
const parts = address.split('.').map((part) => Number.parseInt(part, 10));
|
|
50
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
|
51
|
+
const [a, b] = parts;
|
|
52
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
|
|
53
|
+
(a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
|
|
54
|
+
(a === 192 && (b === 0 || b === 168)) || (a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
|
55
|
+
(a === 203 && b === 0) || a >= 224;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeIPv4MappedIPv6(address) {
|
|
59
|
+
const lower = address.toLowerCase();
|
|
60
|
+
return lower.startsWith('::ffff:') ? lower.slice(7) : lower;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPrivateIPv6(address) {
|
|
64
|
+
const lower = address.toLowerCase();
|
|
65
|
+
const mapped = normalizeIPv4MappedIPv6(lower);
|
|
66
|
+
if (mapped !== lower && net.isIP(mapped) === 4) return isPrivateIPv4(mapped);
|
|
67
|
+
return lower === '::' || lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') ||
|
|
68
|
+
lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb') ||
|
|
69
|
+
lower.startsWith('ff') || lower.startsWith('2001:db8') || lower.startsWith('::ffff:');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isPrivateOrSpecialAddress(address) {
|
|
73
|
+
const normalized = normalizeIPv4MappedIPv6(address);
|
|
74
|
+
const family = net.isIP(normalized);
|
|
75
|
+
if (family === 4) return isPrivateIPv4(normalized);
|
|
76
|
+
if (family === 6) return isPrivateIPv6(normalized);
|
|
77
|
+
if (net.isIP(address) === 6) return isPrivateIPv6(address);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function assertPublicHttpUrl(rawUrl) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try { parsed = new URL(rawUrl); } catch (_e) { throw new UnsafeUrlError('invalid_url'); }
|
|
84
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new UnsafeUrlError(`scheme:${parsed.protocol || 'none'}`);
|
|
85
|
+
if (parsed.username || parsed.password) throw new UnsafeUrlError('credentials');
|
|
86
|
+
if (isBlockedHostname(parsed.hostname)) throw new UnsafeUrlError('internal_host');
|
|
87
|
+
if (net.isIP(parsed.hostname)) {
|
|
88
|
+
if (isPrivateOrSpecialAddress(parsed.hostname)) throw new UnsafeUrlError(`ip_blocked:${parsed.hostname}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let records;
|
|
92
|
+
try { records = await dns.lookup(parsed.hostname, { all: true, verbatim: true }); }
|
|
93
|
+
catch (_e) { throw new UnsafeUrlError('resolve_failed'); }
|
|
94
|
+
if (!records.length) throw new UnsafeUrlError('resolve_empty');
|
|
95
|
+
const blocked = records.find((record) => isPrivateOrSpecialAddress(record.address));
|
|
96
|
+
if (blocked) throw new UnsafeUrlError(`resolves_internal:${parsed.hostname}->${blocked.address}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function assertPagePublic(page, label) {
|
|
100
|
+
let current = '';
|
|
101
|
+
try { current = page.url(); } catch (_e) {}
|
|
102
|
+
await assertPublicHttpUrl(current);
|
|
103
|
+
return current;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readStdinJson() {
|
|
107
|
+
return await new Promise((resolve, reject) => {
|
|
108
|
+
let data = '';
|
|
109
|
+
process.stdin.on('data', (c) => (data += c));
|
|
110
|
+
process.stdin.on('end', () => {
|
|
111
|
+
try { resolve(JSON.parse(data || '{}')); }
|
|
112
|
+
catch (e) { reject(e); }
|
|
113
|
+
});
|
|
114
|
+
process.stdin.on('error', reject);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
const args = await readStdinJson();
|
|
120
|
+
const url = args.url;
|
|
121
|
+
if (!url) { process.stderr.write('missing url\n'); process.exit(2); }
|
|
122
|
+
await assertPublicHttpUrl(url);
|
|
123
|
+
|
|
124
|
+
const profileDir = args.profileDir || '/tmp/.insane_pw_mobile_profile';
|
|
125
|
+
const deviceName = args.device || 'iPhone 13 Pro';
|
|
126
|
+
const waitSelector = args.waitSelector || null;
|
|
127
|
+
const timeoutMs = args.timeout || 60000;
|
|
128
|
+
const headless = args.headless ?? false;
|
|
129
|
+
|
|
130
|
+
let chromium, devices;
|
|
131
|
+
let automation = 'playwright';
|
|
132
|
+
try {
|
|
133
|
+
// Patchright drop-in (additive; absent → previous behaviour unchanged).
|
|
134
|
+
({ chromium, devices } = require('patchright'));
|
|
135
|
+
automation = 'patchright';
|
|
136
|
+
} catch (_e0) {
|
|
137
|
+
try {
|
|
138
|
+
({ chromium, devices } = require('playwright-extra'));
|
|
139
|
+
const stealth = require('puppeteer-extra-plugin-stealth')();
|
|
140
|
+
chromium.use(stealth);
|
|
141
|
+
automation = 'playwright-extra+stealth';
|
|
142
|
+
} catch (_e) {
|
|
143
|
+
({ chromium, devices } = require('playwright'));
|
|
144
|
+
automation = 'playwright';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const dev = devices[deviceName];
|
|
149
|
+
if (!dev) {
|
|
150
|
+
process.stderr.write(`unknown device: ${deviceName}\n`);
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let ctx;
|
|
155
|
+
try {
|
|
156
|
+
ctx = await chromium.launchPersistentContext(profileDir, {
|
|
157
|
+
channel: 'chrome',
|
|
158
|
+
headless,
|
|
159
|
+
...dev,
|
|
160
|
+
});
|
|
161
|
+
const page = await ctx.newPage();
|
|
162
|
+
const deadline = Date.now() + timeoutMs;
|
|
163
|
+
const rem = (cap) => Math.max(1000, Math.min(cap || timeoutMs, deadline - Date.now()));
|
|
164
|
+
const mainResp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: rem(90000) });
|
|
165
|
+
await assertPagePublic(page, 'main');
|
|
166
|
+
|
|
167
|
+
if (waitSelector) {
|
|
168
|
+
try {
|
|
169
|
+
await page.waitForSelector(waitSelector, { timeout: rem(20000) });
|
|
170
|
+
} catch (_e) {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await assertPagePublic(page, 'content');
|
|
174
|
+
const html = await page.content();
|
|
175
|
+
const payload = await buildEnvelope(ctx, page, html, mainResp, automation);
|
|
176
|
+
await writeStdoutAsync(payload); // flush fully before any exit
|
|
177
|
+
process.exitCode = 0;
|
|
178
|
+
return; // let finally close ctx, then exit naturally
|
|
179
|
+
} catch (e) {
|
|
180
|
+
process.stderr.write(`${e.name || 'Error'}: ${e.message || e}\n`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
} finally {
|
|
184
|
+
try { if (ctx) await ctx.close(); } catch (_e) {}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main();
|