ultimate-browsing
Escalation skill for blocked or hard-to-reach web access — load it when a normal browse/fetch is blocked (WAF, 403, Cloudflare, JS-only render, login-gated, or a platform a generic fetcher cannot read). Tiered router: TIER 1 insane-search (headless extraction + WAF bypass via cur
Install
npx skills add https://github.com/code-yeongyu/oh-my-openagent/tree/dev/packages/shared-skills/skills/ultimate-browsing
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install code-yeongyu-oh-my-openagent@llmmart
git clone https://github.com/code-yeongyu/oh-my-openagent.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole code-yeongyu/oh-my-openagent collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Ultimate Browsing
Web access for everything a plain fetch cannot finish: a page that renders in JS, a click or a form, a screenshot, a login that must persist across pages, or a host that blocks generic fetchers (WAF / 403 / Cloudflare). Start at the cheapest tier that can do the job and climb only when it cannot:
Tier 1 — insane-search (headless extraction + WAF bypass) -> Tier 1.5 — agent-reach (platform-native APIs, esp. Chinese platforms) -> Tier 2 — a real browser through omowright from js eval: 2a the owned engine (a browser your code launches, CloakBrowser for stealth), 2b the attached engine (the user's own signed-in browser).
PHASE 0 — ROUTE FIRST (MANDATORY)
User request
|
+- extract text/data from a URL --------------------- TIER 1 insane-search
+- URL blocked / 403 / Cloudflare / WAF ------------- TIER 1 insane-search
+- YouTube/Vimeo/TikTok subtitles or metadata ------- TIER 1 insane-search (yt-dlp)
+- read an article / blog / Reddit / HN / arXiv ----- TIER 1 insane-search
|
+- Chinese platform (xhs/douyin/weibo/bilibili/v2ex/wechat) TIER 1.5 agent-reach
+- podcast transcript / stock forum ----------------- TIER 1.5 agent-reach
+- Twitter feed / LinkedIn profile / GitHub via CLI - TIER 1.5 agent-reach
|
+- Tier 1/1.5 returned empty or partial ------------- TIER 2 2a owned engine -> 2b attached engine
+- click / fill form / scroll / interact ------------ TIER 2 2a owned engine -> 2b attached engine
+- screenshot / render / play video ----------------- TIER 2 2a owned engine -> 2b attached engine
+- login session across pages / the user's account --- TIER 2 2b attached engine (their browser)
+- test web app / QA / dogfood ---------------------- TIER 2 2a owned engine -> 2b attached engine
|
+- simple search query ------------------------------ NOT this skill (use web-search)
Read the matching reference before acting: references/insane-search/README.md, references/agent-reach/README.md, or references/chrome-stealth.md.
Tier 1 — insane-search (headless extraction)
When: content extraction, blocked-URL bypass, media metadata — no browser UI needed.
Why first: ~10x faster than a browser, no process spin-up; handles most "fetch this blocked page" requests via curl_cffi TLS impersonation, yt-dlp (1858 sites), official public APIs, mobile URL transforms, Phase-2.5 surrogate archives (Wayback / archive.today snapshots, provenance-tagged — see references/insane-search/cache-archive.md), a key-gated Jina Reader (JINA_API_KEY), and a Playwright real-Chrome fallback. The engine lives inside this skill at engine/ and is invoked as a module. Surrogate results are dated COPIES: a result whose provenance is snapshot must be reported with its snapshot_timestamp, never presented as the live page.
# Core command — auto-detects WAF, runs the full fetch grid (run from the skill dir):
python3 -m engine "https://example.com/blocked-page"
# add --selector "<CSS>" for positive-proof validation, --device auto|desktop|mobile,
# --trace to inspect every attempt, --json for machine-readable output.
# YouTube subtitles / metadata (no browser):
yt-dlp --write-sub --write-auto-sub --sub-lang "en,ko" --skip-download -o "/tmp/%(id)s" "<URL>"
# Reddit / HN / Bluesky / arXiv etc. use official public endpoints — see the Phase 0 index in
# references/insane-search/README.md (Twitter syndication, Reddit .json, HN Firebase, ...).
The full engine harness (rules R1-R7, the Phase 0 official-API index, the no-site-name rule, and the references/insane-search/*.md deep-dives for TLS, Playwright routing, Naver, media, etc.) is in references/insane-search/README.md. Read it before tuning the engine or adding a WAF profile.
Escalate to Tier 1.5 or Tier 2 when
- The target is a Chinese / social platform with a native reader -> Tier 1.5.
- insane-search returns empty/partial, or the page needs JS interaction, a screenshot, a persistent login, or media playback -> Tier 2.
Tier 1.5 — agent-reach (platform-native readers)
When: the target is a platform with a first-class API/CLI that beats generic fetching — especially Chinese platforms that stealth browsers still cannot reach cleanly. Several channels are zero-config (Douyin, V2EX, Reddit, RSS, YouTube); others need a one-time auth you supply via environment variables if you have access (JINA_API_KEY for Jina Reader — anonymous access is dead, see references/insane-search/jina.md; TWITTER_* for X; a transcription key for podcasts).
| Category | Platforms | Entry |
|---|---|---|
| social | xhs (Xiaohongshu), douyin, weibo, bilibili, V2EX, Reddit, Twitter/X | references/agent-reach/social.md |
| web | Jina Reader, WeChat articles, RSS | references/agent-reach/web.md |
| video | YouTube, Bilibili, podcast transcripts, Douyin video | references/agent-reach/video.md |
| career | references/agent-reach/career.md | |
| dev | GitHub (gh CLI) | references/agent-reach/dev.md |
| search | Exa AI | references/agent-reach/search.md |
mcporter call 'douyin.parse_douyin_video_info(url: "<URL>")' # douyin, zero-config
curl -s "https://r.jina.ai/https://weibo.com/<uid>/<pid>" # weibo via Jina
yt-dlp --dump-json "<bilibili-url>" # Bilibili (overseas: add --cookies-from-browser)
curl -s "https://www.v2ex.com/api/topics/hot.json" # V2EX public API
Routing table, per-platform auth (set TWITTER_* env vars, gh auth login, a transcription key — only if you have access), rate-limit notes, and known version quirks are in references/agent-reach/README.md.
Tier 2 — a real browser (real interaction)
When: real interaction is needed (clicks, forms, screenshots, video, persistent login), or Tier 1/1.5 failed.
Both tiers are omowright, staged inside the browser skill and loaded from js eval:
const { loadOmowright } = await import("<browser-skill-root>/scripts/omowright.mjs")
const { omowright } = await loadOmowright()
Tier 2a — owned engine (default)
A browser your code launches with a task-owned profile. connectPipe opens no listening port; connectCloakProfile launches CloakBrowser with a pinned fingerprint seed and is the path for WAF, Cloudflare and bot-scored pages.
const browser = await omowright.connectPipe({ browserPath, browserArgs: ["--headless", `--user-data-dir=${profile}`], storageRoot: profile })
try {
const page = await browser.newTab(url)
const tree = omowright.compactSnapshot(await page.snapshot()) // the read; refs come from it
const snoop = omowright.createNetworkSnoop(page) // read the API JSON instead of the DOM when there is one
await page.locator("e3").click()
await Bun.write(pngPath, await page.screenshot())
} finally {
await browser.close() // then rm -rf the profile
}
The rest of the surface (CUA coordinates, captcha solving, routes, traces, frames, human handoff) is in the browser skill's references/owned-engine/. A stealth binary is not proof of access: inspect the rendered result and report challenges that remain.
Tier 2b — attached engine (logged-in pages)
When the page needs the user's account, drive the browser they are already signed into instead of cloning their profile: connectBrowserSkill() → session.navigate → bskSnapshot(session) / session.observe() → session.click → session.stop(). NEVER launch against or clear cookies/cache/site data from the user's live profile, and never fall back to the owned engine for an authenticated criterion: if no extension is connected, run the browser skill's onboarding script and relay its one human step. The full loop is the browser skill.
Cookie login (cross-platform)
scripts/extract_cookies.py reads cookies from a local Chromium-family or Firefox-family browser and optionally injects them into the running CDP session. It resolves browser profile paths and decrypts cookie values per-OS (macOS Keychain, Linux libsecret, Windows DPAPI):
# Extract cookies to a file:
mkdir -p ~/.local/state/omo-cookies
python3 scripts/extract_cookies.py --browser chrome --domain youtube.com --output ~/.local/state/omo-cookies/youtube.cookies.json
# Extract and inject into the running CDP session:
python3 scripts/extract_cookies.py --browser chrome --domain youtube.com --inject --cdp 9242
Cookie export files are written with owner-only 0600 permissions. Do not place live auth cookies in shared temp directories or commit them to a repo. Cookie injection sends values to CDP over stdin rather than argv. Cookies apply on next navigation — reload after injecting. Google services use fingerprint-bound tokens that may not transfer across browser profiles. Limits in references/chrome-stealth.md.
Reference docs
| File | When to read |
|---|---|
| references/insane-search/README.md | Tier-1 engine harness (R1-R7, Phase 0 API index, no-site-name rule) + its *.md deep-dives |
| references/agent-reach/README.md | Tier-1.5 routing table, platform auth, per-category *.md |
| references/chrome-stealth.md | Tier-2 stealth through omowright + CloakBrowser, cookie login limits |
Environment variables
# agent-reach auth: set the channel-specific env vars from each tool's docs only if you have access
# insane-search needs no env vars — it auto-installs deps on first run
Anti-patterns
- Do NOT launch Chrome stealth for plain text extraction — use Tier 1.
- Use stealth plugins only in an explicitly installed script environment, not injected into WebView.
- Close every WebView/browser context when done and remove only task-owned profile clones.
- Do NOT inject cookies without reloading the page.
- Do NOT hardcode site domains/selectors into
engine/**orwaf_profiles.yaml— runtime hints only (see the no-site-name rule in the insane-search reference).
Files (oh-my-openagent)
-
engine
-
templates
-
package.json 349 B
{ "name": "insane-search-templates", "version": "0.1.0", "private": true, "description": "Script-only dependencies for local Chrome templates driven from js eval; Chrome must already be installed.", "dependencies": { "playwright-core": "^1.63.0", "playwright-extra": "^4.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2" } } -
playwright_mobile_chrome.js 3.6 KB
#!/usr/bin/env node /** * Generic Playwright mobile fetcher — real Chrome + device emulation. * * Usage: * echo '{"url":"...", "device":"iPhone 13 Pro"}' | node playwright_mobile_chrome.js * * Device name must match playwright `devices[...]` keys (Pixel 7, iPhone 13 Pro, * iPad Pro 11, etc.). When in doubt, omit `device` — default is iPhone 13 Pro. * * NO-SITE-NAME RULE: same as playwright_real_chrome.js — no hostname branches. */ async function readStdinJson() { return await new Promise((resolve, reject) => { let data = ''; process.stdin.on('data', (c) => (data += c)); process.stdin.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch (e) { reject(e); } }); process.stdin.on('error', reject); }); } function describeError(error) { if (error instanceof Error) { return `${error.name}: ${error.message}`; } return String(error); } function warnBestEffort(action, error) { process.stderr.write(`best-effort ${action} failed: ${describeError(error)}\n`); } function isMissingTopLevelModule(error, moduleName) { return ( error instanceof Error && error.code === 'MODULE_NOT_FOUND' && typeof error.message === 'string' && error.message.includes(`Cannot find module '${moduleName}'`) ); } function requireOptionalModule(moduleName) { let resolvedModule; try { resolvedModule = require.resolve(moduleName); } catch (e) { if (isMissingTopLevelModule(e, moduleName)) { warnBestEffort(`optional module ${moduleName}`, e); return null; } throw e; } return require(resolvedModule); } async function main() { const args = await readStdinJson(); const url = args.url; if (!url) { process.stderr.write('missing url\n'); process.exitCode = 2; return; } const profileDir = args.profileDir || '/tmp/.insane_pw_mobile_profile'; const deviceName = args.device || 'iPhone 13 Pro'; const waitSelector = args.waitSelector || null; const timeoutMs = args.timeout || 60000; const headless = args.headless ?? false; let chromium, devices; const playwrightExtra = requireOptionalModule('playwright-extra'); const stealthPlugin = playwrightExtra ? requireOptionalModule('puppeteer-extra-plugin-stealth') : null; if (playwrightExtra && stealthPlugin) { ({ devices } = require('playwright-core')); chromium = playwrightExtra.addExtra(require('playwright-core').chromium); const stealth = stealthPlugin(); chromium.use(stealth); } else { ({ chromium, devices } = require('playwright-core')); } const dev = devices[deviceName]; if (!dev) { process.stderr.write(`unknown device: ${deviceName}\n`); process.exitCode = 2; return; } let ctx; try { ctx = await chromium.launchPersistentContext(profileDir, { channel: 'chrome', args: ['--disable-blink-features=AutomationControlled'], ignoreDefaultArgs: ['--enable-automation'], headless, ...dev, }); const page = await ctx.newPage(); const navTimeout = Math.min(timeoutMs, 90000); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: navTimeout }); if (waitSelector) { try { await page.waitForSelector(waitSelector, { timeout: Math.min(timeoutMs, 20000) }); } catch (e) { warnBestEffort('waitSelector', e); } } const html = await page.content(); process.stdout.write(html); process.exitCode = 0; return; } catch (e) { process.stderr.write(`${describeError(e)}\n`); process.exitCode = 1; return; } finally { try { if (ctx) await ctx.close(); } catch (e) { warnBestEffort('browser context close', e); } } } main(); -
playwright_real_chrome.js 5.6 KB
#!/usr/bin/env node /** * Generic Playwright fetcher — real Chrome channel (not bundled Chromium). * * Usage (driven by engine/executor.py): * echo '{"url":"...", "profileDir":"/tmp/.p", "waitSelector":"article"}' | node playwright_real_chrome.js * * Outputs page HTML to stdout on success; errors to stderr with non-zero exit. * * NO-SITE-NAME RULE: this file must never branch on specific hostnames. * All site specifics come from the JSON input (url, waitSelector). * * User setup: ../../references/chrome-stealth.md (engine-local dependencies). * Chrome must already be installed; optional stealth plugins run only in this script. * Execute this script from js eval; profileDir must be task-owned or a CLONED profile. */ const fs = require('fs'); async function readStdinJson() { return await new Promise((resolve, reject) => { let data = ''; process.stdin.on('data', (c) => (data += c)); process.stdin.on('end', () => { try { resolve(JSON.parse(data || '{}')); } catch (e) { reject(e); } }); process.stdin.on('error', reject); }); } function describeError(error) { if (error instanceof Error) { return `${error.name}: ${error.message}`; } return String(error); } function warnBestEffort(action, error) { process.stderr.write(`best-effort ${action} failed: ${describeError(error)}\n`); } function isMissingTopLevelModule(error, moduleName) { return ( error instanceof Error && error.code === 'MODULE_NOT_FOUND' && typeof error.message === 'string' && error.message.includes(`Cannot find module '${moduleName}'`) ); } function requireOptionalModule(moduleName) { let resolvedModule; try { resolvedModule = require.resolve(moduleName); } catch (e) { if (isMissingTopLevelModule(e, moduleName)) { warnBestEffort(`optional module ${moduleName}`, e); return null; } throw e; } return require(resolvedModule); } async function main() { const args = await readStdinJson(); const url = args.url; if (!url) { process.stderr.write('missing url\n'); process.exitCode = 2; return; } const profileDir = args.profileDir || '/tmp/.insane_pw_profile'; const waitSelector = args.waitSelector || null; const timeoutMs = args.timeout || 60000; const headless = args.headless ?? false; // Akamai/etc detect headless const viewport = args.viewport || { width: 1366, height: 900 }; let chromium; const playwrightExtra = requireOptionalModule('playwright-extra'); const stealthPlugin = playwrightExtra ? requireOptionalModule('puppeteer-extra-plugin-stealth') : null; if (playwrightExtra && stealthPlugin) { chromium = playwrightExtra.addExtra(require('playwright-core').chromium); const stealth = stealthPlugin(); chromium.use(stealth); } else { // Core controls installed Chrome without downloading a managed browser. ({ chromium } = require('playwright-core')); } let ctx; try { ctx = await chromium.launchPersistentContext(profileDir, { channel: 'chrome', // real Chrome, not bundled Chromium args: ['--disable-blink-features=AutomationControlled'], ignoreDefaultArgs: ['--enable-automation'], headless, viewport, }); const page = await ctx.newPage(); const navTimeout = Math.min(timeoutMs, 90000); // Warmup hop: visit the site root first so Akamai-style bot managers // can run their JS sensor and set a resolved session cookie. Direct // landing on a search/deep URL is the classic first-hit rejection pattern. // Use domcontentloaded (not networkidle) — many SPAs keep analytics/xhr // open indefinitely and would hit the 90s timeout. try { const urlObj = new URL(url); const rootUrl = `${urlObj.protocol}//${urlObj.host}/`; if (rootUrl !== url) { await page.goto(rootUrl, { waitUntil: 'domcontentloaded', timeout: navTimeout }); await page.waitForTimeout(3500); // let sensor JS finish } } catch (e) { warnBestEffort('warmup navigation', e); // warmup is best-effort; continue even if it hiccups } // Main page — DOM loaded then give the sensor a moment. await page.goto(url, { waitUntil: 'domcontentloaded', timeout: navTimeout }); await page.waitForTimeout(2500); if (waitSelector) { try { await page.waitForSelector(waitSelector, { timeout: Math.min(timeoutMs, 20000) }); } catch (e) { warnBestEffort('waitSelector', e); // Selector still missing — try one hard reload in case the first hit // landed on a challenge page and the sensor has just cleared. try { await page.reload({ waitUntil: 'domcontentloaded', timeout: navTimeout }); await page.waitForTimeout(2000); try { await page.waitForSelector(waitSelector, { timeout: 10000 }); } catch (e2) { warnBestEffort('retry waitSelector', e2); // Still no luck — caller validates HTML anyway. } } catch (e3) { warnBestEffort('selector recovery reload', e3); // reload failed — proceed with whatever we have } } } else { // Without a positive-proof selector, give the sensor a couple more seconds. await page.waitForTimeout(2000); } const html = await page.content(); process.stdout.write(html); process.exitCode = 0; return; } catch (e) { process.stderr.write(`${describeError(e)}\n`); process.exitCode = 1; return; } finally { try { if (ctx) await ctx.close(); } catch (e) { warnBestEffort('browser context close', e); } } } main();
-
-
tests
-
fixtures
-
amp_redirect_stub.html 323 B · in bundle
-
search_interstitial.html 90.4 KB · in bundle
-
wayback_available.json 246 B
{"url": "en.wikipedia.org/wiki/Web_archiving", "archived_snapshots": {"closest": {"status": "200", "available": true, "url": "http://web.archive.org/web/20260807063120/https://en.wikipedia.org/wiki/Web_archiving", "timestamp": "20260807063120"}}} -
wayback_snapshot.html 181.4 KB · in bundle
-
-
test_fetch_chain.py 2.7 KB
from __future__ import annotations import sys import unittest from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from engine.fetch_chain import fetch # noqa: E402 from engine.result_schema import Attempt # noqa: E402 from engine.validators import Verdict # noqa: E402 class _Resp: def __init__(self, text: str = "<article>ok</article>", url: str = "https://example.com/"): self.text = text self.url = url class _Hit: profile_id = "cloudflare_turnstile" confidence = 1.0 signals = ["test"] class FetchChain(unittest.TestCase): def test_probe_success_returns_without_grid(self) -> None: attempt = Attempt( phase="probe", executor="curl_cffi", url="https://example.com", url_transform="original", impersonate="safari", referer="self_root", verdict=Verdict.WEAK_OK.value, ) with patch("engine.fetch_chain._load_profiles", return_value={}), \ patch("engine.fetch_chain.last_load_error", return_value=None), \ patch("engine.fetch_chain.run_attempt", return_value=(attempt, _Resp())) as run_attempt: result = fetch("https://example.com", enable_playwright=False) self.assertTrue(result.ok) self.assertEqual(result.verdict, Verdict.WEAK_OK.value) self.assertEqual(len(result.trace), 1) self.assertEqual(run_attempt.call_count, 1) def test_max_attempts_stops_after_probe_before_grid(self) -> None: attempt = Attempt( phase="probe", executor="curl_cffi", url="https://example.com", url_transform="original", impersonate="safari", referer="self_root", verdict=Verdict.CHALLENGE.value, ) with patch("engine.fetch_chain._load_profiles", return_value={}), \ patch("engine.fetch_chain.last_load_error", return_value=None), \ patch("engine.fetch_chain.run_attempt", return_value=(attempt, _Resp("blocked"))), \ patch("engine.fetch_chain.detect", return_value=[_Hit()]), \ patch("engine.fetch_chain.load_profile", return_value={ "tls_impersonate_candidates": [["chrome"]], "referer_strategies": ["self_root"], "url_transform_order": ["original"], }): result = fetch("https://example.com", max_attempts=1, enable_playwright=False) self.assertFalse(result.ok) self.assertEqual(len(result.trace), 1) self.assertEqual(result.trace[0].phase, "probe") if __name__ == "__main__": unittest.main() -
test_playwright_stealth.py 3.9 KB
from __future__ import annotations import json import os import subprocess import tempfile import unittest from pathlib import Path from test_playwright_templates import ( TEMPLATE_NAMES, TEMPLATES_DIR, _install_fake_playwright, _write_file, ) class PlaywrightTemplateStealth(unittest.TestCase): def test_extracts_html_and_closes_wrapped_chrome_when_stealth_is_installed(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name), tempfile.TemporaryDirectory() as tmp: # Given: core has no plugin API; only addExtra supplies a registered wrapper. root = Path(tmp) modules = root / "node_modules" profile = root / "profile" receipt = root / "launch.json" _install_fake_playwright(modules) _write_file(modules / "puppeteer-extra-plugin-stealth/index.js", """ module.exports = () => ({ name: 'stealth' }); """) _write_file(modules / "playwright-extra/index.js", """ const assert = require('node:assert/strict'); const fs = require('node:fs'); exports.addExtra = (core) => { assert.equal(core, require('playwright-core').chromium); assert.equal(Object.hasOwn(core, 'use'), false); const events = ['wrap']; let registered = false; return { use(plugin) { assert.equal(plugin.name, 'stealth'); registered = true; events.push(plugin.name); }, async launchPersistentContext(profileDir, options) { assert.equal(registered, true); events.push({ profileDir, channel: options.channel, viewport: options.viewport }); const context = await core.launchPersistentContext(profileDir, options); return { ...context, async close() { await context.close(); events.push('close'); fs.writeFileSync(process.env.STEALTH_RECEIPT, JSON.stringify(events)); }, }; }, }; }; """) script = root / template_name script.write_bytes((TEMPLATES_DIR / template_name).read_bytes()) env = {key: value for key, value in os.environ.items() if key != "NODE_PATH"} env["STEALTH_RECEIPT"] = str(receipt) # When: the actual bundled entry point consumes its normal stdin protocol. result = subprocess.run( ["node", str(script)], input=json.dumps({"url": "https://example.com/", "profileDir": str(profile), "headless": True}), text=True, capture_output=True, timeout=5, env=env, check=False, ) # Then: HTML arrives through the registered wrapper and the context is closed. self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, "<html><article>ok</article></html>") self.assertEqual(result.stderr, "") viewport = {"width": 390, "height": 844} if "mobile" in template_name else {"width": 1366, "height": 900} self.assertEqual(json.loads(receipt.read_text(encoding="utf-8")), [ "wrap", "stealth", {"profileDir": str(profile), "channel": "chrome", "viewport": viewport}, "close", ]) if __name__ == "__main__": unittest.main() -
test_playwright_templates.py 10.7 KB
from __future__ import annotations import json import os import shutil import subprocess import tempfile import textwrap import unittest from pathlib import Path TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "templates" TEMPLATE_NAMES = ("playwright_real_chrome.js", "playwright_mobile_chrome.js") JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] def _write_file(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") def _install_fake_playwright(node_modules: Path) -> None: _write_file( node_modules / "playwright-core" / "index.js", """ const page = { async goto() {}, async waitForTimeout() {}, async waitForSelector(selector) { if (process.env.PW_FAKE_SELECTOR_FAIL === '1') { throw new Error(`missing selector ${selector}`); } }, async reload() {}, async content() { return '<html><article>ok</article></html>'; }, }; const context = { async newPage() { return page; }, async close() { if (process.env.PW_FAKE_CLOSE_FAIL === '1') { throw new Error('close failed'); } }, }; exports.chromium = { async launchPersistentContext() { return context; }, }; exports.devices = { 'iPhone 13 Pro': { viewport: { width: 390, height: 844 }, userAgent: 'fake-mobile' }, }; """, ) def _install_broken_playwright_extra(node_modules: Path) -> None: _write_file( node_modules / "playwright-extra" / "index.js", """ const error = new Error('stealth init failed'); error.code = 'EACCES'; throw error; """, ) _write_file( node_modules / "puppeteer-extra-plugin-stealth" / "index.js", """ module.exports = function stealth() { return {}; }; """, ) def _install_working_playwright_extra(node_modules: Path) -> None: _write_file( node_modules / "playwright-extra" / "index.js", """ exports.addExtra = (browserType) => browserType; """, ) def _install_playwright_extra_with_internal_missing_dependency(node_modules: Path) -> None: _write_file( node_modules / "playwright-extra" / "index.js", """ require('transitive-stealth-runtime'); """, ) def _install_self_missing_optional_module(node_modules: Path, module_name: str) -> None: _write_file( node_modules / module_name / "index.js", f""" const error = new Error("Cannot find module '{module_name}'"); error.code = 'MODULE_NOT_FOUND'; throw error; """, ) def _run_template( template_name: str, payload: dict[str, JsonValue], *, include_broken_extra: bool = False, include_working_extra: bool = False, include_internal_missing_extra: bool = False, include_self_missing_module: str | None = None, env_overrides: dict[str, str] | None = None, ) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory(prefix="ultimate-browsing-template-test-") as tmp: tmp_path = Path(tmp) node_modules = tmp_path / "node_modules" _install_fake_playwright(node_modules) if include_working_extra: _install_working_playwright_extra(node_modules) if include_broken_extra: _install_broken_playwright_extra(node_modules) if include_internal_missing_extra: _install_playwright_extra_with_internal_missing_dependency(node_modules) if include_self_missing_module: _install_self_missing_optional_module(node_modules, include_self_missing_module) script_path = tmp_path / template_name script_path.write_text((TEMPLATES_DIR / template_name).read_text(encoding="utf-8"), encoding="utf-8") env = os.environ.copy() env.pop("NODE_PATH", None) if env_overrides: env.update(env_overrides) return subprocess.run( ["node", str(script_path)], input=json.dumps(payload), text=True, capture_output=True, timeout=5, env=env, check=False, ) @unittest.skipUnless(shutil.which("node"), "node is required for Playwright template tests") class PlaywrightTemplateErrorHandling(unittest.TestCase): def test_missing_playwright_extra_warns_and_falls_back_to_playwright_core(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, }, ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("<article>ok</article>", result.stdout) self.assertIn("best-effort optional module playwright-extra failed:", result.stderr) self.assertIn("Cannot find module 'playwright-extra'", result.stderr) def test_missing_stealth_plugin_warns_and_falls_back_to_playwright_core(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, }, include_working_extra=True, ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("<article>ok</article>", result.stdout) self.assertIn("best-effort optional module puppeteer-extra-plugin-stealth failed:", result.stderr) self.assertIn("Cannot find module 'puppeteer-extra-plugin-stealth'", result.stderr) def test_non_missing_stealth_dependency_error_is_not_swallowed(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, }, include_broken_extra=True, ) self.assertNotEqual(result.returncode, 0) self.assertIn("stealth init failed", result.stderr) self.assertNotIn("best-effort optional module", result.stderr) self.assertEqual(result.stdout, "") def test_internal_module_resolution_error_is_not_treated_as_optional_missing_dependency(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, }, include_internal_missing_extra=True, ) self.assertNotEqual(result.returncode, 0) self.assertIn("Cannot find module 'transitive-stealth-runtime'", result.stderr) self.assertNotIn("best-effort optional module", result.stderr) self.assertEqual(result.stdout, "") def test_present_optional_module_self_missing_error_is_not_swallowed(self) -> None: cases = (("playwright-extra", False), ("puppeteer-extra-plugin-stealth", True)) for template_name in TEMPLATE_NAMES: for module_name, include_working_extra in cases: with self.subTest(template_name=template_name, module_name=module_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, }, include_working_extra=include_working_extra, include_self_missing_module=module_name, ) self.assertNotEqual(result.returncode, 0) self.assertIn(f"Cannot find module '{module_name}'", result.stderr) self.assertNotIn("best-effort optional module", result.stderr) self.assertEqual(result.stdout, "") def test_selector_failures_are_reported_as_best_effort_warnings(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, "waitSelector": "article.ready", }, env_overrides={"PW_FAKE_SELECTOR_FAIL": "1"}, ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("<article>ok</article>", result.stdout) self.assertIn("best-effort waitSelector failed:", result.stderr) self.assertNotIn("waitSelector article.ready", result.stderr) def test_context_close_failures_are_reported_after_successful_html_output(self) -> None: for template_name in TEMPLATE_NAMES: with self.subTest(template_name=template_name): result = _run_template( template_name, { "url": "https://example.com/article", "profileDir": "/tmp/ultimate-browsing-test-profile", "headless": True, }, env_overrides={"PW_FAKE_CLOSE_FAIL": "1"}, ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("<article>ok</article>", result.stdout) self.assertIn("best-effort browser context close failed:", result.stderr) if __name__ == "__main__": unittest.main() -
test_surrogate.py 11.3 KB
"""Surrogate registry/fetch/ordering tests; registry entries and responses are faked, never live network.""" from __future__ import annotations import json import os import sys import unittest from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from engine.result_schema import Attempt # noqa: E402 from engine.validators import Verdict # noqa: E402 FIXTURES = Path(__file__).resolve().parent / "fixtures" TARGET = "https://en.wikipedia.org/wiki/Web_archiving" # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site def _fixture(name: str) -> str: return (FIXTURES / name).read_text(encoding="utf-8", errors="replace") class _Resp: def __init__(self, text: str = "", status: int = 200, url: str = "", payload: dict | None = None): self.text = text self.status_code = status self.url = url self.cookies = {} self.headers = {} self._payload = payload def json(self): if self._payload is None: raise ValueError("no json payload") return self._payload def _fail_attempt(phase: str) -> Attempt: return Attempt( phase=phase, executor="curl_cffi", url=TARGET, url_transform="original", impersonate="safari", referer="self_root", verdict=Verdict.CHALLENGE.value, ) class SurrogateRegistry(unittest.TestCase): def test_default_registry_contains_wayback(self) -> None: from engine import surrogate reg = surrogate.load_surrogates() self.assertIn("wayback", reg) self.assertEqual(reg["wayback"]["kind"], "archive") self.assertEqual(reg["wayback"]["trust"], "archive") def test_stale_entries_detected(self) -> None: from engine import surrogate self.assertTrue(surrogate.is_stale({"last_verified": "2025-01-01"}, max_age_days=90)) self.assertFalse(surrogate.is_stale({"last_verified": "2999-01-01"}, max_age_days=90)) class SurrogateFetch(unittest.TestCase): def test_wayback_success_sets_snapshot_provenance(self) -> None: from engine import surrogate discovery = json.loads(_fixture("wayback_available.json")) def fake_http(u: str, **kw): if "wayback/available" in u: return _Resp(json.dumps(discovery), 200, u, payload=discovery) return _Resp(_fixture("wayback_snapshot.html"), 200, u) with patch.object(surrogate, "_http_get", side_effect=fake_http): atts, meta, content = surrogate.run_surrogate(TARGET, registry=surrogate.load_surrogates(), timeout=5) att = atts[-1] self.assertEqual(att.phase, "surrogate") self.assertEqual(att.executor, "surrogate_wayback") self.assertEqual(att.verdict, Verdict.WEAK_OK.value) self.assertEqual(meta["provenance"], "snapshot") self.assertTrue(meta["snapshot_timestamp"]) self.assertEqual(meta["trust"], "archive") self.assertGreater(len(content), 3000) def test_stub_snapshot_is_not_accepted(self) -> None: from engine import surrogate disc = {"archived_snapshots": {"closest": {"available": True, "url": "https://web.archive.org/web/2024/x", "timestamp": "20240101000000"}}} # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site def fake_http(u: str, **kw): if "wayback/available" in u: return _Resp(json.dumps(disc), 200, u, payload=disc) return _Resp(_fixture("amp_redirect_stub.html"), 200, u) with patch.object(surrogate, "_http_get", side_effect=fake_http): atts, _, _ = surrogate.run_surrogate(TARGET, registry={"wayback": surrogate.DEFAULT_SURROGATES["wayback"]}, timeout=5) att = atts[-1] self.assertNotIn(att.verdict, (Verdict.STRONG_OK.value, Verdict.WEAK_OK.value)) def test_proxy_skips_without_allow_flag(self) -> None: from engine import surrogate reg = {"anon_relay": {"kind": "proxy", "trust": "untrusted", "enabled": False, "last_verified": "2999-01-01", "fetch": "https://relay.invalid/{target}"}} # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site called: list[str] = [] with patch.object(surrogate, "_http_get", side_effect=lambda u, **kw: (called.append(u), _Resp("<html>" + "y" * 4000 + "</html>", 200, u))[1]): atts, _, _ = surrogate.run_surrogate(TARGET, registry=reg, allow_proxy=False, timeout=5) self.assertEqual(len(called), 0) with patch.object(surrogate, "_http_get", side_effect=lambda u, **kw: (called.append(u), _Resp("<html>" + "y" * 4000 + "</html>", 200, u))[1]): atts, meta, _ = surrogate.run_surrogate(TARGET, registry=reg, allow_proxy=True, timeout=5) self.assertEqual(len(called), 1) att = atts[-1] self.assertEqual(att.executor, "surrogate_anon_relay") self.assertEqual(meta["trust"], "untrusted") def test_proxy_never_carries_credentials(self) -> None: from engine import surrogate seen: list[dict] = [] def fake_http(u: str, **kw): seen.append(kw.get("headers", {})) return _Resp("<html>" + "z" * 4000 + "</html>", 200, u) reg = {"anon_relay": {"kind": "proxy", "trust": "untrusted", "enabled": False, "last_verified": "2999-01-01", "fetch": "https://relay.invalid/{target}"}} # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site os.environ["OMOB_TEST_TOKEN"] = "secret" try: with patch.object(surrogate, "_http_get", side_effect=fake_http): surrogate.run_surrogate(TARGET, registry=reg, allow_proxy=True, timeout=5) finally: os.environ.pop("OMOB_TEST_TOKEN", None) self.assertEqual(len(seen), 1) joined = json.dumps(seen[0]).lower() self.assertNotIn("secret", joined) self.assertNotIn("authorization", joined) self.assertNotIn("cookie", joined) class ChainOrdering(unittest.TestCase): def _profile(self) -> dict: return { "tls_impersonate_candidates": [[]], "referer_strategies": [], "url_transform_order": ["original"], "fallback_when_challenge": ["surrogate_wayback", "playwright_real_chrome"], } def test_surrogate_success_short_circuits_playwright(self) -> None: from engine.fetch_chain import fetch from engine import surrogate from engine import executor as ex def fake_surrogate(*a, **kw): att = Attempt( phase="surrogate", executor="surrogate_wayback", url=TARGET, url_transform="original", impersonate=None, referer="", verdict=Verdict.WEAK_OK.value, body_size=5000, ) return [att], {"provenance": "snapshot", "snapshot_timestamp": "20240101000000", "trust": "archive"}, "<html>content</html>" def fail_playwright(*a, **kw): raise AssertionError("playwright must not run after surrogate success") with patch("engine.fetch_chain._load_profiles", return_value={}), \ patch("engine.fetch_chain.last_load_error", return_value=None), \ patch("engine.fetch_chain.run_attempt", side_effect=lambda *a, **kw: (_fail_attempt(str(kw.get("phase", "grid"))), None)), \ patch("engine.fetch_chain.detect", return_value=[]), \ patch("engine.fetch_chain.load_profile", side_effect=lambda *a, **kw: self._profile()), \ patch.object(surrogate, "run_surrogate", side_effect=fake_surrogate), \ patch.object(ex, "run_playwright_fallback", side_effect=fail_playwright): result = fetch(TARGET, enable_playwright=True, max_attempts=1) self.assertTrue(result.ok) self.assertEqual(result.provenance, "snapshot") self.assertEqual(result.snapshot_timestamp, "20240101000000") def test_surrogate_still_runs_when_playwright_disabled(self) -> None: from engine.fetch_chain import fetch from engine import surrogate calls: list[str] = [] def fake_surrogate(*a, **kw): calls.append("surrogate") att = Attempt(phase="surrogate", executor="surrogate_wayback", url=TARGET, url_transform="original", impersonate=None, referer="", verdict=Verdict.WEAK_OK.value, body_size=5000) return [att], {"provenance": "snapshot", "snapshot_timestamp": "20240101000000", "trust": "archive"}, "<html>snap</html>" with patch("engine.fetch_chain._load_profiles", return_value={}), \ patch("engine.fetch_chain.last_load_error", return_value=None), \ patch("engine.fetch_chain.run_attempt", side_effect=lambda *a, **kw: (_fail_attempt(str(kw.get("phase", "grid"))), None)), \ patch("engine.fetch_chain.detect", return_value=[]), \ patch("engine.fetch_chain.load_profile", side_effect=lambda *a, **kw: self._profile()), \ patch.object(surrogate, "run_surrogate", side_effect=fake_surrogate): result = fetch(TARGET, enable_playwright=False, max_attempts=1) self.assertEqual(calls, ["surrogate"]) self.assertTrue(result.ok) self.assertEqual(result.provenance, "snapshot") def test_playwright_runs_when_surrogate_fails(self) -> None: from engine.fetch_chain import fetch from engine import surrogate from engine import executor as ex calls: list[str] = [] def fake_surrogate(*a, **kw): calls.append("surrogate") att = Attempt(phase="surrogate", executor="surrogate_wayback", url=TARGET, url_transform="original", impersonate=None, referer="", verdict=Verdict.UNKNOWN.value) return [att], {"provenance": "live", "snapshot_timestamp": None, "trust": "origin"}, "" def fake_playwright(*a, **kw): calls.append("playwright") att = Attempt(phase="fallback", executor="playwright_real_chrome", url=TARGET, url_transform="original", impersonate=None, referer="", verdict=Verdict.WEAK_OK.value, body_size=5000) return att, "<html>chrome content</html>" with patch("engine.fetch_chain._load_profiles", return_value={}), \ patch("engine.fetch_chain.last_load_error", return_value=None), \ patch("engine.fetch_chain.run_attempt", side_effect=lambda *a, **kw: (_fail_attempt(str(kw.get("phase", "grid"))), None)), \ patch("engine.fetch_chain.detect", return_value=[]), \ patch("engine.fetch_chain.load_profile", side_effect=lambda *a, **kw: self._profile()), \ patch.object(surrogate, "run_surrogate", side_effect=fake_surrogate), \ patch.object(ex, "run_playwright_fallback", side_effect=fake_playwright): result = fetch(TARGET, enable_playwright=True, max_attempts=1) self.assertTrue(result.ok) self.assertEqual(result.provenance, "live") self.assertEqual(calls, ["surrogate", "playwright"]) if __name__ == "__main__": unittest.main() -
test_surrogate_validators.py 3.5 KB
"""Surrogate-tier validation tests; fixtures only, never live network, so the suite cannot flake.""" from __future__ import annotations import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from engine.result_schema import FetchResult # noqa: E402 from engine.validators import Verdict, validate # noqa: E402 FIXTURES = Path(__file__).resolve().parent / "fixtures" def _fixture(name: str) -> str: return (FIXTURES / name).read_text(encoding="utf-8", errors="replace") class _Resp: def __init__(self, text: str = "", status: int = 200, url: str = ""): self.text = text self.status_code = status self.url = url self.cookies = {} self.headers = {} class RedirectStubRejection(unittest.TestCase): def test_amp_redirect_stub_is_not_ok(self) -> None: body = _fixture("amp_redirect_stub.html") self.assertLess(len(body), 1000) resp = _Resp(body, 200, "https://en-wikipedia-org.cdn.ampproject.org/c/s/x") # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site vr = validate(resp, target_url="https://en.wikipedia.org/wiki/Web_archiving") # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site self.assertIn(vr.verdict, (Verdict.CHALLENGE, Verdict.UNKNOWN)) self.assertFalse(vr.ok) def test_real_snapshot_still_passes(self) -> None: resp = _Resp(_fixture("wayback_snapshot.html"), 200, "https://web.archive.org/web/2024/x") # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site vr = validate(resp, target_url="https://en.wikipedia.org/wiki/Web_archiving") # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site self.assertTrue(vr.ok) def test_plain_page_still_weak_ok(self) -> None: resp = _Resp("<html><body>" + ("x" * 4000) + "</body></html>", 200, "https://example.com/") vr = validate(resp, target_url="https://example.com/") self.assertTrue(vr.ok) class InterstitialRejection(unittest.TestCase): def test_search_engine_interstitial_is_not_ok(self) -> None: body = _fixture("search_interstitial.html") resp = _Resp(body, 200, "https://webcache.googleusercontent.com/search?q=cache:x") # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site vr = validate(resp, target_url="https://en.wikipedia.org/wiki/Web_archiving") # NOTE-BIAS-OK — fixture URL from a third-party surrogate service, not a target site self.assertIn(vr.verdict, (Verdict.CHALLENGE, Verdict.UNKNOWN)) self.assertFalse(vr.ok) class ResultSchemaProvenance(unittest.TestCase): def test_defaults_are_live_origin(self) -> None: r = FetchResult(ok=True) self.assertEqual(r.provenance, "live") self.assertEqual(r.trust, "origin") self.assertIsNone(r.snapshot_timestamp) d = r.to_dict() self.assertEqual(d["provenance"], "live") self.assertEqual(d["trust"], "origin") self.assertIn("snapshot_timestamp", d) def test_snapshot_fields_roundtrip(self) -> None: r = FetchResult(ok=True, provenance="snapshot", snapshot_timestamp="20241227132135", trust="archive") d = r.to_dict() self.assertEqual(d["provenance"], "snapshot") self.assertEqual(d["snapshot_timestamp"], "20241227132135") self.assertEqual(d["trust"], "archive") if __name__ == "__main__": unittest.main()
-
-
AGENTS.md 10.4 KB
# ultimate-browsing/engine — Generic WAF-Profile Fetch Chain (Python) **Generated:** 2026-08-10 / 38d268995 ## UPSTREAM BASELINE AND VERSION POLICY **READ THIS BEFORE TOUCHING `engine/**` OR PROPOSING AN UPSTREAM SYNC.** This engine is NOT project-original code. It is a vendored-and-modified snapshot of [fivetaku/insane-search](https://github.com/fivetaku/insane-search), and the version we run on is a deliberate choice, not an accident of neglect. ### The pin | Fact | Value | |---|---| | Upstream project | `https://github.com/fivetaku/insane-search` | | Vendoring commit | `a4e4ed797` (2026-06-21) `feat(ultimate-browsing): vendor insane-search engine (junk-excluded)` | | De-personalization | `4743199a5` (2026-06-21) | | Pinned upstream baseline | upstream state as of 2026-06-21, **pre-0.7.0** (0.7.0 is dated 2026-06-22) | | Re-vendors since | none — every later change here is ours | We intentionally track a **pinned baseline plus local divergence**, not upstream HEAD. There is no submodule and no automated drift check for this engine (unlike the `frontend` skill's upstream submodules): the vendored files ARE the source of truth, and upstream is a reference we port FROM, deliberately, file by file. ### Why we do not blind-rebase onto upstream HEAD 1. **Upstream reset its public history.** On 2026-08-06 upstream published a single squashed commit, `019ee16 refactor: reset public history at 0.14.0`, discarding the prior public history through 0.13.x. There is no upstream commit graph to rebase onto and no way to cherry-pick an individual upstream change by sha — only whole-file diffing against a moving HEAD. 2. **Upstream 0.14.0 REMOVED capability.** The endpoint-mining / internal-API auto-derivation / site-recipe subsystems that upstream carried publicly between 0.12.0 and 0.14.0 are gone from upstream HEAD. Syncing to HEAD is therefore not strictly an upgrade: parts of it are a downgrade relative to the intermediate versions, and none of it is recoverable from the reset history. 3. **Our tree diverged on purpose.** The KEEP list below is functionality upstream never had. A wholesale overwrite with upstream HEAD would silently delete it. 4. **Different threat model.** Our engine ships inside a published npm package and a public marketplace mirror, under a CI no-site-name gate and a de-personalization deny-list. Upstream carries neither constraint, so upstream code is not drop-in-shippable here. ### KEEP — our divergences a re-vendor MUST NOT regress These exist only in our tree. Any upstream sync that removes or bypasses one of them is a regression, not an upgrade: - **Phase 2.5 surrogate retrieval** (`surrogate.py`, `surrogates.yaml`) — archive / reader / proxy routes tried before paying for a browser spin-up, with per-entry `last_verified` staleness handling and `--allow-proxy` gating. - **Provenance / trust contract** (`result_schema.py`) — `Provenance` and `Trust` literals on every result, so a snapshot can never be reported as the live page. - **Surrogate dead-end validation** (L1.5 in `validators.py`) — interstitial titles and AMP-style redirect stubs rejected instead of returned as content. - **The no-site-name rule and its CI gate** (`bias_check.py`) — zero hard-coded site names, brands, or target domains in `engine/**`. - **Module split of the fetch chain** — `curl_probe` / `referers` / `url_transforms` / `waf_detector` / `validators` / `executor` / `summary` as separate modules rather than one monolith. - **The Python test suite** under `engine/tests/` with its HTML/JSON fixtures. - **De-personalization** — no personal absolute paths, no personal auth token literals, no personal browser choice; enforced by `depersonalization-gate.test.ts`. - **Skill-level layering** — the engine is Tier 1 under a router that also owns Tier 1.5 (agent-reach) and Tier 2 (omowright from js eval: owned and attached engines via the `browser` skill). Upstream has no such tiering. ### WANT — upstream improvements worth porting forward Our snapshot predates these; they are wanted, and each must be ported as a reviewed, site-agnostic change that preserves every KEEP item above. Port individually; never as a tree overwrite: - **Content quality**: dedicated markdown conversion of fetched HTML, main-content extraction, PDF text extraction, and JSON-LD rescue when the HTML body is thin. - **Transient-failure retry** and **render-merge** of statically fetched HTML with the browser-rendered DOM. - **Differential block classification** — distinguishing a bot-detection block from an infrastructure or authentication failure, instead of collapsing both into `challenge`. - **Additional stealth fetch backends** beyond the current Playwright templates, and additional WAF vendor profiles. - **Per-host route learning** — remembering which route succeeded for a host, with a TTL and a bounded store. Must stay runtime state, never committed site knowledge (R4). - **Engine-level Phase 0 routing** — the official-public-API preference is currently only a documented rule (R5) the agent can skip; upstream moved it into code so it cannot be skipped. Worth adopting. ### OUT OF SCOPE - **The removed upstream endpoint-mining / internal-API auto-derivation / site-recipe subsystems.** They are absent from upstream HEAD and are not reconstructed here. They also sit against R3/R4 and R7's anti-bias rule: discovered internal endpoints are runtime findings, never committed engine knowledge. - **Any upstream code carrying site-specific selectors, domains, or brand names.** It fails `bias_check.py` at the door; re-derive it site-agnostically or leave it out. - **Automated upstream tracking.** No submodule, no drift check, no auto-bump. Syncing is a deliberate, reviewed, human-initiated act. ### THE SYNC RULE Any future upstream sync preserves BOTH sides. Concretely: 1. Diff the specific upstream capability you want against our tree — do not overwrite files wholesale, and never `git checkout` upstream over `engine/`. 2. Port it as its own reviewed change, keeping every KEEP item intact. 3. Re-run `python3 engine/bias_check.py` and the `engine/tests/` suite; a port that introduces a site name or breaks a fixture does not ship. 4. Update the pin table above (baseline, date, what was ported) in the same change, plus the provenance section of [`../ATTRIBUTION.md`](../ATTRIBUTION.md). 5. If a port must drop a KEEP item, say so explicitly in the PR and get it agreed first — silent regressions of the KEEP list are the failure mode this policy exists to prevent. ## OVERVIEW A 17-module Python package embedded in the `ultimate-browsing` skill: a site-agnostic fetch chain that escalates from a cheap curl probe to a real browser, with declarative WAF and surrogate registries. Not "optional scripts" — it has its own CLI entry (`python3 -m engine URL`), two YAML config schemas, a 4-file test suite, and a standalone CI guard. Package exports (`__init__.py`): `fetch`, `FetchResult`, `Attempt`, `Verdict`, `ValidationResult`, `validate`, `CHALLENGE_MARKERS`, `detect`, `TRANSFORMS`, `apply_transform`. ## THE NO-SITE-NAME RULE (enforced in CI) `engine/**` must contain **zero** hard-coded site names, brands, or target domains. Site specifics belong to runtime hints or observations, never to code. `bias_check.py` is a standalone scanner enforcing this: a brand denylist, a URL regex scan, an allowlist for genuine infrastructure hosts (archive.org, r.jina.ai, google.com, httpbin.org, relay.invalid), and a `# NOTE-BIAS-OK` comment convention for legitimate exemptions such as test fixtures. ```bash python3 engine/bias_check.py # fails on any site-specific leak ``` ## FETCH CHAIN PHASES ``` fetch(url, ...) # fetch_chain.py Phase 1 curl_probe.py — curl_cffi TLS-impersonation probe Phase 2 grid — referer/transform/device attempt grid Phase 2.5 surrogate.py — third-party archive/reader/proxy routes Phase 3 executor.py — capability-matched Playwright fallback ``` Ordering is **not** hardcoded: each `waf_profiles.yaml` profile carries a `fallback_when_challenge` list that drives the ladder. `surrogate_wayback` precedes browser executors in every profile, so archives are tried before paying for a browser spin-up. ## PROVENANCE / TRUST CONTRACT `result_schema.py` puts two literals on every `FetchResult`: - `Provenance = "live" | "snapshot" | "proxy"` - `Trust = "origin" | "archive" | "untrusted"` A `snapshot` result carries `snapshot_timestamp` and **must** be cited with that timestamp — never presented as the live page. `surrogates.yaml` `kind` fixes these values: `archive` -> snapshot/archive, `reader` -> live, `proxy` -> proxy/untrusted. ## SURROGATE REGISTRY (`surrogates.yaml`) Site-agnostic infrastructure only. Every entry carries `last_verified` (ISO date); entries older than 90 days are deprioritized and flagged, because surrogate routes rot (a 2026-08 probe found 4 of 6 known routes dead or stubbed). `proxy` routes are MITM by construction: they require the explicit `--allow-proxy` flag and never receive `Cookie` or `Authorization` headers. Every surrogate response is re-validated with `target_url` set, so an interstitial or a redirect stub is rejected instead of returned as content. ## VALIDATOR LAYERS (`validators.py`) ``` L1 challenge markers (CHALLENGE_MARKERS) L1.5 surrogate dead ends — interstitial titles + AMP-style redirect stubs (is_redirect_stub(), needs target_url) L2 size/shape fingerprints L3+ content checks ``` ## CLI ```bash python3 -m engine URL [--selector S] [--device auto|desktop|mobile] [--timeout 25] [--max-attempts 12] [--no-playwright] [--allow-proxy] [--json] [--trace] ``` ## TESTS `tests/` — `test_surrogate.py` (staleness, proxy gating, short-circuit), `test_surrogate_validators.py`, `test_fetch_chain.py`, `test_playwright_templates.py`, plus HTML/JSON fixtures under `tests/fixtures/`. ## NOTES - `summary.py` emits an **R7 API-first hint** after >=3 challenge verdicts against a known WAF profile: look for `/api/`, `/graphql`, or `.json` endpoints, which usually carry weaker WAF protection than the HTML surface. - `templates/` holds the Playwright JS templates (`playwright_real_chrome.js`, `playwright_mobile_chrome.js`) the executor drives. - `url_transforms.py` transforms stay domain-agnostic (`mobile_subdomain`, `am_prefix`, `drop_www`). - Parent: [`packages/shared-skills/AGENTS.md`](../../../AGENTS.md). -
bias_check.py 7.1 KB
#!/usr/bin/env python3 """No-Site-Name Rule checker. Run in CI / pre-commit. Scans engine/** for hard-coded site names or domains that would bias the generic fetch chain toward one site. Exit code 0 if clean, 1 if violations found. python3 engine/bias_check.py python3 engine/bias_check.py --strict # also check references/*.md (usually off) """ from __future__ import annotations import argparse import os import re import sys from pathlib import Path # Known brand / domain substrings that should NOT appear in engine code. # This is a non-exhaustive deny list. CI should treat hits as warnings that # require human review; false positives (e.g. "github" in comments) can be # whitelisted via EXPLICIT_ALLOW. BRAND_SUBSTRINGS = [ "coupang", "11st", "11번가", "musinsa", "무신사", "fmkorea", "에펨코리아", "dcinside", "디시인사이드", "ohou", "오늘의집", "kurly", "마켓컬리", "daangn", "당근", # Naver is allowed in Phase 0 references (official APIs) but not in engine code. "naver.com", "blog.naver", "shopping.naver", # Korean portal brand names "daum.net", "kakao.com", ] # Regex for bare URLs / domains. Used as a secondary pass to flag hardcoded # site hosts that slipped past the brand denylist. URL_PATTERN = re.compile( r"https?://[\w\.-]+|[\w-]+\.(?:com|net|org|co\.kr|kr|io)\b", re.IGNORECASE, ) # Generic / neutral hosts that are allowed anywhere (examples, specs, stdlib, # and domains that legitimately appear as non-site-specific referrers / test # fixtures — Google search as a generic Referer strategy, httpbin for transport # tests, etc.). Anything in this set must be provably unrelated to a specific # target-site preference. URL_ALLOWLIST = { "example.com", "example.org", "example.net", "localhost", "127.0.0.1", # Official API / documentation sources cited in code comments. "curl.se", "playwright.dev", "nodejs.org", "npmjs.com", # Generic Referer strategy target (used as a neutral off-site referer). "www.google.com", "google.com", # Generic HTTP test endpoint for infrastructure / transport tests. "httpbin.org", # Surrogate-tier infrastructure (engine/surrogates.yaml): archives/relays # that serve a copy of ANY target URL. Same no-site-preference category as # the google.com Referer — they are not target sites. "archive.org", "web.archive.org", "r.jina.ai", # Placeholder host in the proxy-entry shape example; "canonical non-routable # doc domain" (.invalid TLD), can never be a real target. "relay.invalid", } # Files / dirs that must be clean. SCAN_ROOTS_STRICT_OFF = ["engine"] SCAN_ROOTS_STRICT_ON = ["engine", "references"] # Directory names skipped during scan (third-party code, build artefacts). EXCLUDED_DIR_NAMES = { "node_modules", "__pycache__", ".git", ".venv", "dist", "build", } # Comment markers within which a brand mention is OK (explanation). # Keyed per-extension; any line containing these is skipped. COMMENT_OK_MARKERS = { ".py": ("# NOTE-BIAS-OK", "# EXAMPLE-ONLY"), ".js": ("// NOTE-BIAS-OK", "// EXAMPLE-ONLY"), ".yaml": ("# NOTE-BIAS-OK", "# EXAMPLE-ONLY"), ".yml": ("# NOTE-BIAS-OK", "# EXAMPLE-ONLY"), ".md": ("<!-- NOTE-BIAS-OK -->", "<!-- EXAMPLE-ONLY -->"), } # File paths explicitly exempted (full match against relative path from scan root). EXPLICIT_ALLOW_FILES = { # None right now — add if needed with justification. } # NOTE-BIAS-OK convention for surrogate tests: fixture URLs (stub/interstitial # captures from third-party services) carry the marker on the flagged line. def _line_is_exempt(line: str, ext: str) -> bool: markers = COMMENT_OK_MARKERS.get(ext, ()) return any(m in line for m in markers) def _scan_file(path: Path, root: Path) -> list[str]: """Return list of violation strings for this file.""" rel = path.relative_to(root.parent) if str(rel) in EXPLICIT_ALLOW_FILES: return [] ext = path.suffix.lower() try: text = path.read_text(encoding="utf-8", errors="ignore") except Exception as e: return [f"{rel}:0 — read error: {e}"] violations: list[str] = [] for lineno, line in enumerate(text.splitlines(), start=1): if _line_is_exempt(line, ext): continue lowered = line.lower() # 1) Brand / domain denylist hit_brand = None for brand in BRAND_SUBSTRINGS: if brand.lower() in lowered: hit_brand = brand break if hit_brand: violations.append(f"{rel}:{lineno} — brand `{hit_brand}` in: {line.strip()[:120]}") continue # one violation per line # 2) URL/domain regex scan — catches hosts that aren't in the denylist. for match in URL_PATTERN.finditer(line): host = match.group(0).lower() host = host.split("//", 1)[-1].split("/", 1)[0] if host in URL_ALLOWLIST: continue if host.endswith(".example.com") or host.endswith(".example.org"): continue violations.append(f"{rel}:{lineno} — hardcoded host `{host}` in: {line.strip()[:120]}") break return violations def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Scan engine for site-name bias") parser.add_argument("--strict", action="store_true", help="Also scan references/*.md (usually noisy — off by default)") parser.add_argument("--root", default=None, help="Skill root directory. Defaults to parent of this file.") args = parser.parse_args(argv) skill_root = Path(args.root) if args.root else Path(__file__).parent.parent scan_roots = SCAN_ROOTS_STRICT_ON if args.strict else SCAN_ROOTS_STRICT_OFF total_violations: list[str] = [] scanned = 0 for name in scan_roots: root = skill_root / name if not root.exists(): continue for dirpath, dirnames, filenames in os.walk(root): # In-place filter so os.walk skips these subtrees. dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIR_NAMES] for fname in filenames: p = Path(dirpath) / fname if p.suffix.lower() not in (".py", ".js", ".yaml", ".yml", ".md", ".ts", ".mjs"): continue if p.name == "bias_check.py": continue # self-exempt (this file lists the brands) scanned += 1 total_violations.extend(_scan_file(p, skill_root)) print(f"[bias-check] scanned {scanned} files under {skill_root}") if total_violations: print(f"[bias-check] ❌ {len(total_violations)} violation(s):") for v in total_violations: print(f" - {v}") print() print("Fix options:") print(" 1) Remove the brand name (preferred)") print(" 2) If genuinely explanatory, add '# NOTE-BIAS-OK' on the same line") print(" 3) If this is a Phase 0 official API reference, move it to references/*.md and rerun without --strict") return 1 print("[bias-check] ✅ clean") return 0 if __name__ == "__main__": sys.exit(main()) -
curl_probe.py 2.4 KB
from __future__ import annotations import time from typing import Iterable, Mapping, Protocol from .referers import REFERER_STRATEGIES from .result_schema import Attempt from .validators import Verdict, validate class _CookieItem(Protocol): name: str value: str class _CookieJar(Protocol): jar: Iterable[_CookieItem] class ProbeResponse(Protocol): status_code: int text: str url: str cookies: _CookieJar | Mapping[str, str] def _curl_probe( url: str, *, impersonate: str, referer: str, timeout: int = 20 ) -> tuple[ProbeResponse | None, str | None]: try: from curl_cffi import requests as cffi_requests except ImportError: return None, "curl_cffi not installed" headers = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", } if referer: headers["Referer"] = referer try: resp = cffi_requests.get( url, impersonate=impersonate, headers=headers, timeout=timeout, allow_redirects=True, ) return resp, None except cffi_requests.exceptions.RequestException as e: return None, f"{type(e).__name__}:{str(e)[:200]}" def run_attempt( url: str, *, transform_name: str, impersonate: str, referer_name: str, success_selectors: list[str] | None, known_bad_sizes: list[int] | None, timeout: int, phase: str, ) -> tuple[Attempt, ProbeResponse | None]: referer_url = REFERER_STRATEGIES.get(referer_name, REFERER_STRATEGIES["none"])(url) started_at = time.time() resp, err = _curl_probe(url, impersonate=impersonate, referer=referer_url, timeout=timeout) elapsed = round(time.time() - started_at, 3) att = Attempt( phase=phase, executor="curl_cffi", url=url, url_transform=transform_name, impersonate=impersonate, referer=referer_name, elapsed_s=elapsed, ) if err or resp is None: att.error = err or "no response" att.verdict = Verdict.UNKNOWN.value return att, None vr = validate(resp, success_selectors=success_selectors, known_bad_sizes=known_bad_sizes) att.status = vr.status att.body_size = vr.body_size att.verdict = vr.verdict.value att.reasons = vr.reasons return att, resp -
executor.py 6.3 KB
"""Capability-matched executor for fallback attempts. The fetch_chain's probe/grid phase uses curl_cffi directly. When curl can't punch through (JS challenge, real-TLS detection), this module routes to the right browser executor based on the profile's `capabilities_needed` tags: needs_real_tls_stack + needs_js_exec → playwright_real_chrome.js needs_js_exec only → Playwright MCP (if available) needs_mobile_context (+ real_tls) → playwright_mobile_chrome.js The JS templates live in `engine/templates/` and accept only generic parameters ({{url}}, {{waitSelector}}, {{profileDir}}, {{device}}). No site-specific logic. Playwright MCP invocation requires caller's tool access; this module provides the subprocess path for local JS templates but only stubs the MCP path (MCP must be driven from the Claude session itself). """ from __future__ import annotations import json import os import shutil import subprocess import tempfile import time from typing import Optional from .validators import Verdict, validate from .waf_detector import load_profile from .result_schema import Attempt TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") def _node_available() -> bool: return shutil.which("node") is not None def _chrome_channel_available() -> bool: """Heuristic: try `node -e` to import playwright. Fallback to True, let script fail loudly.""" if not _node_available(): return False if shutil.which("npx") is None: return False return True def _pick_executor(capabilities: list[str], device_class: str) -> str: caps = set(capabilities or []) if device_class == "mobile" or "needs_mobile_context" in caps: if "needs_real_tls_stack" in caps: return "playwright_mobile_chrome" return "playwright_mcp_mobile" if "needs_real_tls_stack" in caps: return "playwright_real_chrome" if "needs_js_exec" in caps: return "playwright_mcp" return "playwright_real_chrome" # safest general fallback def _run_node_template(template: str, args: dict, timeout: int = 90) -> tuple[int, str, str]: """Run a Node.js template with args as JSON on stdin. Template convention: reads `process.stdin` → JSON → runs fetch → writes HTML to stdout; errors go to stderr with non-zero exit code. """ path = os.path.join(TEMPLATES_DIR, template) if not os.path.isfile(path): return 127, "", f"template not found: {path}" try: proc = subprocess.run( ["node", path], input=json.dumps(args), cwd=TEMPLATES_DIR, capture_output=True, text=True, timeout=timeout, ) return proc.returncode, proc.stdout, proc.stderr except subprocess.TimeoutExpired: return 124, "", f"timeout after {timeout}s" except Exception as e: return 1, "", f"{type(e).__name__}:{e}" class _FakeResp: """Minimal response shim so validators.validate() works on Playwright HTML.""" def __init__(self, html: str, status: int = 200, final_url: str = ""): self.text = html self.status_code = status self.url = final_url self.cookies = _FakeCookies() self.headers = {} class _FakeCookies: class _Jar: def __iter__(self): return iter([]) def __init__(self): self.jar = self._Jar() def __iter__(self): return iter([]) def run_playwright_fallback( url: str, *, profile_id: str, success_selectors: Optional[list[str]] = None, device_class: str = "auto", timeout: int = 90, profile_dir: Optional[str] = None, force_executor: Optional[str] = None, ) -> tuple[Attempt, str]: """Invoke the appropriate Playwright executor. force_executor: caller-specified executor name (from a profile's `fallback_when_challenge` list). When set, it overrides capability-based inference. Recognized values: "playwright_real_chrome", "playwright_mobile_chrome", "playwright_mcp". Returns (Attempt, html_content). Attempt.verdict reflects validation. """ profile = load_profile(profile_id) capabilities = profile.get("capabilities_needed") or [] choice = force_executor or _pick_executor(capabilities, device_class) t0 = time.time() att = Attempt( phase="fallback", executor=choice, url=url, url_transform="original", impersonate=None, referer="", ) if choice.startswith("playwright_mcp"): att.error = ( "Playwright MCP must be invoked from the Claude session — " "call mcp__playwright__* tools directly instead of fetch_chain." ) att.verdict = Verdict.UNKNOWN.value att.elapsed_s = round(time.time() - t0, 3) return att, "" if not _chrome_channel_available(): att.error = "node/npx not available for local Playwright template" att.verdict = Verdict.UNKNOWN.value att.elapsed_s = round(time.time() - t0, 3) return att, "" template_map = { "playwright_real_chrome": "playwright_real_chrome.js", "playwright_mobile_chrome": "playwright_mobile_chrome.js", } template = template_map.get(choice) if template is None: att.error = f"no template for executor {choice}" att.verdict = Verdict.UNKNOWN.value att.elapsed_s = round(time.time() - t0, 3) return att, "" args: dict = { "url": url, "profileDir": profile_dir or os.path.join(tempfile.gettempdir(), ".insane_pw_profile"), "timeout": timeout * 1000, } if choice == "playwright_mobile_chrome": args["device"] = "iPhone 13 Pro" if success_selectors: args["waitSelector"] = success_selectors[0] rc, stdout, stderr = _run_node_template(template, args, timeout=timeout + 10) att.elapsed_s = round(time.time() - t0, 3) if rc != 0 or not stdout: att.error = (stderr or "no stdout")[:300] att.verdict = Verdict.UNKNOWN.value return att, "" # stdout carries HTML. Validate with a shim. resp = _FakeResp(stdout) vr = validate(resp, success_selectors=success_selectors) att.status = 200 att.body_size = len(stdout) att.verdict = vr.verdict.value att.reasons = vr.reasons return att, stdout -
fetch_chain.py 13 KB
"""Single entrypoint: insane-search generic fetch chain. from insane_search.engine import fetch result = fetch("https://example.com/path", success_selectors=["article"]) Public contract: * One function: `fetch(url, ...) -> FetchResult`. * Internal structure preserved as explicit phases so tests & debug logs can target each stage: probe → validate → detect → plan → execute → report. * `FetchResult.trace` exposes every attempt (transform × impersonate × referer × executor) — callers can diagnose without re-running. No site-specific branching. Site knowledge enters only via: * `success_selectors` (caller-supplied positive proof) * `user_hint` (optional runtime hints; never persisted by this module) * `observations/*.jsonl` (append-only log; separate concern) """ from __future__ import annotations import json import os import random import time from .curl_probe import run_attempt from .result_schema import Attempt, FetchResult from .summary import format_summary from .validators import Verdict from .waf_detector import DetectionHit, detect, load_profile, _load_profiles, last_load_error from .url_transforms import iter_transformed # --- Main entrypoint --------------------------------------------------------- def fetch( url: str, *, success_selectors: list[str] | None = None, device_class: str = "auto", # "auto" | "desktop" | "mobile" user_hint: dict | None = None, timeout: int = 25, max_attempts: int = 12, enable_playwright: bool = True, # hook left for executor module allow_surrogate_proxy: bool = False, # opt-in: kind=proxy registry entries ) -> FetchResult: """Fetch `url` using the generic grid. Parameters ---------- success_selectors Positive-proof CSS selectors. Presence of ≥1 match promotes verdict to STRONG_OK. Without them, best outcome is WEAK_OK. device_class "desktop" pins curl impersonate to desktop targets (safari/chrome/firefox). "mobile" pins to mobile targets (safari_ios/chrome_android) AND enables mobile URL transforms. "auto" (default) follows profile advice; tries desktop first, mobile on persistent failure. user_hint Optional runtime hints, e.g. `{"impersonate_first": "safari", "referer": "..."}`. Never stored. Only influences current call. timeout Per-attempt timeout in seconds. max_attempts Hard upper bound on total attempts across all phases. enable_playwright Placeholder — Playwright fallback invocation is delegated to `engine/executor.py` (separate module, capability-matched). """ user_hint = user_hint or {} profiles = _load_profiles() trace: list[Attempt] = [] last_resp = None last_attempt: Attempt | None = None profile_used: str | None = None # Surface profile-loader failures as a trace entry so callers can see # that we're running on the in-code default (YAML missing / invalid / # PyYAML not installed). Never fatal by itself. load_err = last_load_error() if load_err: trace.append(Attempt( phase="probe", executor="profile_loader", url=url, url_transform="original", impersonate=None, referer="", verdict=Verdict.UNKNOWN.value, error=f"profiles_fallback: {load_err}", )) # -------- Phase 1: probe with safe defaults ------------------------------ base_impersonate = user_hint.get("impersonate_first") or "safari" if device_class == "mobile": base_impersonate = user_hint.get("impersonate_first") or "safari_ios" probe_attempt, probe_resp = run_attempt( url, transform_name="original", impersonate=base_impersonate, referer_name=user_hint.get("referer_strategy") or "self_root", success_selectors=success_selectors, known_bad_sizes=None, timeout=timeout, phase="probe", ) trace.append(probe_attempt) if probe_resp is not None: last_resp = probe_resp last_attempt = probe_attempt if probe_attempt.verdict in (Verdict.STRONG_OK.value, Verdict.WEAK_OK.value): return _build_result(probe_resp, probe_attempt, trace, profile_used=None) # -------- Phase 2: detect WAF, plan grid --------------------------------- if last_resp is not None: hits = detect(last_resp, profiles=profiles) else: hits = [DetectionHit(profile_id="unknown_challenge", confidence=0.1, signals=["no_probe_response"])] # Try top profiles by confidence. attempts_used = len(trace) for hit in hits[:3]: # top 3 candidates if attempts_used >= max_attempts: break profile_id = hit.profile_id profile_used = profile_id profile = load_profile(profile_id, profiles=profiles) tls_groups: list[list[str]] = profile.get("tls_impersonate_candidates") or [["safari", "chrome"]] tls_flat: list[str] = [t for group in tls_groups for t in group] avoid = set((profile.get("tls_impersonate_avoid") or [])) tls_flat = [t for t in tls_flat if t not in avoid] referer_order = profile.get("referer_strategies") or ["self_root"] transform_order = profile.get("url_transform_order") or ["original"] # device_class override if device_class == "mobile": tls_flat = [t for t in tls_flat if "ios" in t or "android" in t] or tls_flat if "mobile_subdomain" not in transform_order: transform_order = transform_order + ["mobile_subdomain"] elif device_class == "desktop": tls_flat = [t for t in tls_flat if "ios" not in t and "android" not in t] or tls_flat known_bad_sizes = profile.get("known_bad_sizes") or None for t_name, t_url in iter_transformed(url, transform_order): for tls in tls_flat: for ref in referer_order: if attempts_used >= max_attempts: break # Skip exact duplicate of probe. if (t_name == "original" and tls == base_impersonate and ref == (user_hint.get("referer_strategy") or "self_root")): continue att, resp = run_attempt( t_url, transform_name=t_name, impersonate=tls, referer_name=ref, success_selectors=success_selectors, known_bad_sizes=known_bad_sizes, timeout=timeout, phase="grid", ) trace.append(att) attempts_used += 1 # Jitter: politeness + IP-reputation guard. Tunable via # INSANE_JITTER_MS_MIN / INSANE_JITTER_MS_MAX env vars. _jmin = int(os.environ.get("INSANE_JITTER_MS_MIN", "150")) _jmax = int(os.environ.get("INSANE_JITTER_MS_MAX", "400")) time.sleep(random.uniform(_jmin/1000.0, _jmax/1000.0)) if resp is None: continue last_resp, last_attempt = resp, att if att.verdict in (Verdict.STRONG_OK.value, Verdict.WEAK_OK.value): return _build_result(resp, att, trace, profile_used=profile_id) # -------- Phase 2.5 + 3: fallback ladder (profile-driven order) --------- # Surrogate routes are NOT browser work, so they run even when the caller # disabled Playwright; only browser executors honour `enable_playwright`. try: # Honour profile's `fallback_when_challenge` list — iterate the # caller-declared order instead of capability-inferred single pick. fb_profile = load_profile(profile_used or "unknown_challenge", profiles=profiles) fb_order = fb_profile.get("fallback_when_challenge") or ["playwright_real_chrome"] pw_attempt = None pw_content = "" for fb_name in fb_order: if fb_name == "curl_grid_exhaust": # Already performed in Phase 2; nothing more to do here. continue if fb_name.startswith("surrogate_"): # Phase 2.5: archive/reader/proxy routes serving a copy of # the target. Cheaper than a browser; provenance-labeled. from . import surrogate as surrogate_mod s_atts, s_meta, s_content = surrogate_mod.run_surrogate( url, registry=surrogate_mod.load_surrogates(), allow_proxy=allow_surrogate_proxy, timeout=timeout, success_selectors=success_selectors, ) trace.extend(s_atts) s_att = s_atts[-1] if s_att.verdict in (Verdict.STRONG_OK.value, Verdict.WEAK_OK.value): return FetchResult( ok=True, content=s_content, final_url=s_att.url, verdict=s_att.verdict, profile_used=profile_used, trace=trace, summary=( f"surrogate {s_meta.get('surrogate')} succeeded" f" (provenance={s_meta.get('provenance')}" + (f" snapshot_ts={s_meta.get('snapshot_timestamp')})" if s_meta.get('snapshot_timestamp') else ")") ), provenance=str(s_meta.get("provenance", "live")), snapshot_timestamp=s_meta.get("snapshot_timestamp"), trust=str(s_meta.get("trust", "origin")), ) continue if not enable_playwright: continue from .executor import run_playwright_fallback # lazy import pw_attempt, pw_content = run_playwright_fallback( url, profile_id=profile_used or "unknown_challenge", success_selectors=success_selectors, device_class=device_class, force_executor=fb_name, ) trace.append(pw_attempt) if pw_attempt.verdict in (Verdict.STRONG_OK.value, Verdict.WEAK_OK.value): return FetchResult( ok=True, content=pw_content, final_url=pw_attempt.url, verdict=pw_attempt.verdict, profile_used=profile_used, trace=trace, summary=f"Playwright fallback succeeded via {fb_name}", ) # Synthesize a placeholder only when the profile genuinely offers no # fallback route. Entries skipped because the caller disabled the # browser are a caller choice, not a profile defect — no trace noise. actionable = [n for n in fb_order if n != "curl_grid_exhaust"] if pw_attempt is None and not actionable: pw_attempt = Attempt( phase="fallback", executor="none", url=url, url_transform="original", impersonate=None, referer="", verdict=Verdict.UNKNOWN.value, error="profile has empty fallback_when_challenge", ) trace.append(pw_attempt) except ImportError: trace.append(Attempt( phase="fallback", executor="playwright", url=url, url_transform="original", impersonate=None, referer="", verdict=Verdict.UNKNOWN.value, error="executor module not available", )) except (RuntimeError, OSError) as e: trace.append(Attempt( phase="fallback", executor="playwright", url=url, url_transform="original", impersonate=None, referer="", verdict=Verdict.UNKNOWN.value, error=f"{type(e).__name__}:{str(e)[:200]}", )) # -------- Give up, return best we have ---------------------------------- summary = format_summary(trace, profile_used) return FetchResult( ok=False, content=getattr(last_resp, "text", "") if last_resp is not None else "", final_url=getattr(last_resp, "url", url) if last_resp is not None else url, verdict=last_attempt.verdict if last_attempt else Verdict.UNKNOWN.value, profile_used=profile_used, trace=trace, summary=summary, ) def _build_result(resp, attempt: Attempt, trace: list[Attempt], profile_used: str | None) -> FetchResult: return FetchResult( ok=True, content=getattr(resp, "text", "") or "", final_url=str(getattr(resp, "url", attempt.url)), verdict=attempt.verdict, profile_used=profile_used, trace=trace, summary=f"{attempt.executor} {attempt.impersonate} + {attempt.url_transform} + referer:{attempt.referer} → {attempt.verdict}", ) -
referers.py 327 B
from __future__ import annotations from urllib.parse import urlsplit def _self_root(url: str) -> str: parsed = urlsplit(url) return f"{parsed.scheme}://{parsed.netloc}/" REFERER_STRATEGIES = { "self_root": _self_root, "google_search": lambda _url: "https://www.google.com/", "none": lambda _url: "", } -
result_schema.py 1.5 KB
from __future__ import annotations from dataclasses import asdict, dataclass, field from typing import Literal, Optional Provenance = Literal["live", "snapshot", "proxy"] Trust = Literal["origin", "archive", "untrusted"] @dataclass class Attempt: phase: str executor: str url: str url_transform: str impersonate: Optional[str] referer: str status: int = 0 body_size: int = 0 verdict: str = "" reasons: list[str] = field(default_factory=list) elapsed_s: float = 0.0 error: Optional[str] = None def to_dict(self) -> dict: return asdict(self) @dataclass class FetchResult: ok: bool content: str = "" final_url: str = "" verdict: str = "" profile_used: Optional[str] = None trace: list[Attempt] = field(default_factory=list) summary: str = "" provenance: str = "live" # live | snapshot | proxy snapshot_timestamp: Optional[str] = None # archive's own timestamp, when snapshot trust: str = "origin" # origin | archive | untrusted def to_dict(self) -> dict: return { "ok": self.ok, "final_url": self.final_url, "verdict": self.verdict, "profile_used": self.profile_used, "trace": [a.to_dict() for a in self.trace], "summary": self.summary, "provenance": self.provenance, "snapshot_timestamp": self.snapshot_timestamp, "trust": self.trust, "content_length": len(self.content), } -
summary.py 1.2 KB
from __future__ import annotations from typing import Optional from .result_schema import Attempt from .validators import Verdict _R7_ELIGIBLE_PROFILES = frozenset({ "akamai_bot_manager", "cloudflare_turnstile", "datadome_probable", "perimeterx_human", "f5_big_ip", "aws_waf", }) R7_HINT = ( "💡 R7 API-first 권장: WAF가 HTML 경로를 차단 중. " "Playwright MCP 사용 → browser_navigate → browser_network_requests " "→ `/api/`·`/graphql`·`\\.json` 필터로 내부 엔드포인트 탐지 → " "해당 URL을 `python3 -m engine <API_URL>`로 재호출. 대부분 API 레이어는 " "WAF 방어가 얕아 curl_cffi만으로 수집됨." ) def format_summary(trace: list[Attempt], profile: Optional[str]) -> str: n = len(trace) verdicts = [a.verdict for a in trace] challenge_count = sum(1 for v in verdicts if v == Verdict.CHALLENGE.value) base = ( f"failed after {n} attempts; profile={profile}; " f"verdicts={','.join(v for v in verdicts[:5])}" + ("..." if n > 5 else "") ) if profile in _R7_ELIGIBLE_PROFILES and challenge_count >= 3: return base + "\n" + R7_HINT return base -
surrogate.py 7.9 KB
"""Phase 2.5 surrogate retrieval: archive / reader / proxy routes for blocked origins. When the curl grid cannot fetch the live page, this module reads a declarative registry (engine/surrogates.yaml) and tries third-party routes that serve a copy of the target instead. Provenance semantics are contractual: kind=archive -> provenance="snapshot" (+ archive's own timestamp), trust=archive kind=reader -> provenance="live" (server-side re-render of the live page) kind=proxy -> provenance="proxy", trust=untrusted; requires allow_proxy=True and never sends Cookie/Authorization headers (relay = MITM) Every response body passes through validators.validate() with target_url set, so AMP-style redirect stubs and search-engine interstitials are rejected rather than recorded as wins. Entries whose `last_verified` is older than MAX_STALE_DAYS are tried after fresh ones (routes rot faster than they are maintained). """ from __future__ import annotations import json import os import time from datetime import date try: import yaml except ImportError: yaml = None from .result_schema import Attempt from .validators import Verdict, validate SURROGATES_PATH = os.path.join(os.path.dirname(__file__), "surrogates.yaml") MAX_STALE_DAYS = 90 DEFAULT_SURROGATES: dict = { "wayback": { "kind": "archive", "trust": "archive", "enabled": True, "last_verified": "2026-08-09", "discovery": { "url": "https://archive.org/wayback/available?url={target}", "flag_pointer": "archived_snapshots.closest.available", "snapshot_url_pointer": "archived_snapshots.closest.url", "snapshot_timestamp_pointer": "archived_snapshots.closest.timestamp", }, "min_body_bytes": 3072, }, } def load_surrogates(path: str = SURROGATES_PATH) -> dict: if yaml is None: return dict(DEFAULT_SURROGATES) try: with open(path, encoding="utf-8") as fh: loaded = yaml.safe_load(fh) or {} except (OSError, yaml.YAMLError): return dict(DEFAULT_SURROGATES) if not isinstance(loaded, dict): return dict(DEFAULT_SURROGATES) usable = {k: v for k, v in loaded.items() if isinstance(v, dict) and k} return usable or dict(DEFAULT_SURROGATES) def is_stale(entry: dict, max_age_days: int = MAX_STALE_DAYS, today: date | None = None) -> bool: verified = entry.get("last_verified") if isinstance(verified, date): parsed = verified elif isinstance(verified, str): try: parsed = date.fromisoformat(verified) except ValueError: return True else: return True return ((today or date.today()) - parsed).days > max_age_days def _http_get(url: str, *, headers: dict | None = None, timeout: int = 25): try: from curl_cffi import requests as cffi_requests except ImportError as exc: raise RuntimeError("curl_cffi not installed") from exc return cffi_requests.get( url, impersonate="chrome", headers=headers or {"Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8"}, timeout=timeout, allow_redirects=True, ) def _json_pointer(obj, dotted: str): current = obj for part in dotted.split("."): if not isinstance(current, dict): return None current = current.get(part) return current def _provenance_of(kind: str) -> str: if kind == "archive": return "snapshot" if kind == "proxy": return "proxy" return "live" def _skip_reason(name: str, entry: dict, allow_proxy: bool) -> str | None: kind = entry.get("kind", "archive") if kind == "proxy" and not allow_proxy: return "proxy_requires_allow_proxy_flag" required_env = entry.get("enabled_env") if required_env and not os.environ.get(str(required_env)): return f"missing_env:{required_env}" if entry.get("enabled") is False and kind != "proxy": return "disabled" return None def _attempt(name: str, url: str, verdict: Verdict, error=None, status: int = 0, size: int = 0, elapsed: float = 0.0) -> Attempt: return Attempt( phase="surrogate", executor=f"surrogate_{name}", url=url, url_transform="original", impersonate=None, referer="", status=status, body_size=size, verdict=verdict.value, error=(str(error)[:200] if error else None), elapsed_s=round(elapsed, 3), ) def _resolve_target(name: str, entry: dict, target: str, timeout: int) -> tuple[str | None, str | None]: discovery = entry.get("discovery") if not discovery: host_rotation = entry.get("host_rotation") or [None] host = host_rotation[0] template = entry.get("fetch") if not template or host is None and "{host}" in template: return None, None return template.format(host=host, target=target), None discovery_url = discovery.get("url", "").format(target=target) resp = _http_get(discovery_url, timeout=timeout) payload = resp.json() if discovery.get("flag_pointer") and not _json_pointer(payload, discovery["flag_pointer"]): return None, None snap_url = _json_pointer(payload, discovery.get("snapshot_url_pointer", "")) timestamp = _json_pointer(payload, discovery.get("snapshot_timestamp_pointer", "")) or None return snap_url, timestamp def run_surrogate( target: str, *, registry: dict | None = None, allow_proxy: bool = False, timeout: int = 25, success_selectors: list[str] | None = None, ) -> tuple[list[Attempt], dict, str]: registry = dict(registry) if registry else load_surrogates() ordered = sorted(registry.items(), key=lambda kv: int(is_stale(kv[1]))) live_meta = {"provenance": "live", "snapshot_timestamp": None, "trust": "origin", "surrogate": None} attempts: list[Attempt] = [] for name, entry in ordered: kind = entry.get("kind", "archive") reason = _skip_reason(name, entry, allow_proxy) if reason: attempts.append(_attempt(name, target, Verdict.UNKNOWN, error=f"skipped:{reason}")) continue started = time.time() try: fetch_url, timestamp = _resolve_target(name, entry, target, timeout) except Exception as exc: attempts.append(_attempt(name, target, Verdict.UNKNOWN, error=f"{type(exc).__name__}:{exc}", elapsed=time.time() - started)) continue if not fetch_url: attempts.append(_attempt(name, target, Verdict.UNKNOWN, error="no snapshot available", elapsed=time.time() - started)) continue try: resp = _http_get(fetch_url, timeout=timeout) except Exception as exc: attempts.append(_attempt(name, fetch_url, Verdict.UNKNOWN, error=f"{type(exc).__name__}:{exc}", elapsed=time.time() - started)) continue body = getattr(resp, "text", "") or "" status = int(getattr(resp, "status_code", 0) or 0) min_bytes = int(entry.get("min_body_bytes", 3072)) att = _attempt(name, fetch_url, Verdict.UNKNOWN, status=status, size=len(body), elapsed=time.time() - started) attempts.append(att) if status >= 400 or len(body) < min_bytes: att.error = f"surrogate_unusable:status={status},size={len(body)}" continue vr = validate(resp, success_selectors=success_selectors, target_url=target) att.verdict = vr.verdict.value att.reasons = vr.reasons if vr.ok: meta = { "provenance": _provenance_of(kind), "snapshot_timestamp": timestamp if kind == "archive" else None, "trust": str(entry.get("trust", "archive")), "surrogate": name, } return attempts, meta, body if not attempts: attempts.append(_attempt("none", target, Verdict.UNKNOWN, error="no surrogate entries")) return attempts, live_meta, "" -
surrogates.yaml 2.3 KB
# Surrogate routes — third-party services serving a COPY of a page when the # live origin blocks the fetch chain (Phase 2.5, between grid and Playwright). # # Rules (same discipline as waf_profiles.yaml): # * Site-agnostic infrastructure ONLY (No-Site-Name Rule): every host here # must work for any target URL. No target-site domains, selectors, brand # names. # * Surrogate routes ROT — the 2026-08 probe found 4 of 6 known routes dead # or returning stubs. Entries must carry `last_verified` (ISO date) and a # passing liveness record in the PR that adds them; refresh quarterly. # Entries older than 90 days are tried after fresh ones and flagged. # * kind semantics: # archive — serves a dated snapshot; provenance=snapshot, trust=archive. # reader — re-renders the LIVE page server-side; provenance=live. # proxy — raw forwarding relay; MITM by construction, trust=untrusted, # skipped unless the caller passes --allow-proxy, and never # receives Cookie/Authorization headers. # * keys: # discovery.json_pointer — dotted path into the discovery JSON payload. # host_rotation — try hostile-domain cousins in order (one may be # blocked while another answers). # enabled_env — entry is inert until that env var is set (key-gated). # min_body_bytes — response floor; below it the body is a stub/error. wayback: kind: archive trust: archive enabled: true last_verified: 2026-08-09 discovery: url: "https://archive.org/wayback/available?url={target}" flag_pointer: "archived_snapshots.closest.available" snapshot_url_pointer: "archived_snapshots.closest.url" snapshot_timestamp_pointer: "archived_snapshots.closest.timestamp" min_body_bytes: 3072 archive_today: kind: archive trust: archive enabled: true last_verified: 2026-08-09 host_rotation: ["archive.ph", "archive.md", "archive.li", "archive.is"] fetch: "https://{host}/newest/{target}" min_body_bytes: 3072 jina_reader: kind: reader trust: archive enabled: true enabled_env: JINA_API_KEY last_verified: 2026-08-09 fetch: "https://r.jina.ai/{target}" min_body_bytes: 512 anon_relay_example: kind: proxy trust: untrusted enabled: false last_verified: 2026-08-09 fetch: "https://relay.invalid/?u={target}" min_body_bytes: 3072 -
url_transforms.py 3.2 KB
"""Generic URL transforms for the fetch grid. Transforms are domain-agnostic *rules*. They never reference a specific site by name. A transform either applies (returns a new URL) or is skipped (returns None). Callers iterate transforms in order. Empirically useful transforms (see observations/): * mobile_subdomain — `www.example.com` → `m.example.com` Strong win on SSR sites with mobile-first serving. Loss on SPA shells (some mobile sites return tiny bootstrap HTML). * am_prefix — `example.com` (no www) → `m.example.com` * drop_www — occasionally unblocks hosts that gate www but not apex. Adding new transforms: prove they help on ≥2 unrelated sites first (cross-site validation — bias check). """ from __future__ import annotations from typing import Callable, Optional from urllib.parse import urlsplit, urlunsplit def _replace_host(url: str, new_host: str) -> str: parts = urlsplit(url) return urlunsplit(parts._replace(netloc=new_host)) def _original(url: str) -> Optional[str]: return url def _mobile_subdomain(url: str) -> Optional[str]: """`https://www.example.com/a` → `https://m.example.com/a` (only if host starts with www.).""" parts = urlsplit(url) host = parts.hostname or "" if not host.startswith("www."): return None new_host = "m." + host[4:] if parts.port: new_host = f"{new_host}:{parts.port}" return _replace_host(url, new_host) def _am_prefix(url: str) -> Optional[str]: """`https://example.com/a` → `https://m.example.com/a` (only if host has no subdomain).""" parts = urlsplit(url) host = parts.hostname or "" if not host or host.startswith("m."): return None # Only apply to apex-like hosts (≤2 dot-separated labels). if host.count(".") >= 2 and not host.startswith("www."): return None if host.startswith("www."): return None # handled by mobile_subdomain return _replace_host(url, "m." + host) def _drop_www(url: str) -> Optional[str]: parts = urlsplit(url) host = parts.hostname or "" if not host.startswith("www."): return None return _replace_host(url, host[4:]) TRANSFORMS: dict[str, Callable[[str], Optional[str]]] = { "original": _original, "mobile_subdomain": _mobile_subdomain, "am_prefix": _am_prefix, "drop_www": _drop_www, } def apply_transform(name: str, url: str) -> Optional[str]: """Apply one transform by name. Returns transformed URL or None if skipped.""" fn = TRANSFORMS.get(name) if fn is None: raise ValueError(f"Unknown transform: {name!r}. Known: {list(TRANSFORMS)}") return fn(url) def iter_transformed(url: str, order: list[str]) -> list[tuple[str, str]]: """Yield (transform_name, transformed_url) pairs for a given order. Skips transforms that return None (not applicable) and deduplicates URLs (so `original` and `drop_www` of `https://example.com` don't double-run). """ seen: set[str] = set() out: list[tuple[str, str]] = [] for name in order: new_url = apply_transform(name, url) if new_url is None: continue if new_url in seen: continue seen.add(new_url) out.append((name, new_url)) return out -
validators.py 9.1 KB
"""Generic challenge / success validator. Four layers (all generic, never site-specific): 1. Challenge markers (WAF product strings — not site brand names) 2. Size fingerprints (known bad byte sizes hinted by caller) 3. Cookie sensor state (e.g. Akamai `_abck=~-1~`) 4. Caller-supplied success_selectors (strongest positive proof) Layers 1-3 are "negative proof" (fail fast). Layer 4 is "positive proof" — without it, HTTP 200 is only a weak success. """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Optional try: from bs4 import BeautifulSoup except ImportError: # bs4 is a soft dep: only used when selectors given BeautifulSoup = None # Markers are WAF-product strings only. Never include site brand / domain. CHALLENGE_MARKERS: list[str] = [ "Access Denied", "sec-if-cpt-container", "Powered and protected by Akamai", "Just a moment...", "Checking your browser", "cf-chl-bypass", "Attention Required! | Cloudflare", "<title>Bot Challenge</title>", "DataDome", "captcha", "Please enable JS and disable any ad blocker", "The requested URL was rejected", "Request unsuccessful. Incapsula", ] # Minimum body size below which we suspect a stub / challenge page. # Tunable: some legitimate short JSON responses may be smaller, but callers # that know their response type should pass success_selectors instead. SMALL_BODY_THRESHOLD = 3000 # Surrogate-route interstitial: a search-engine front page returned in place # of the cached page (dead cache services fall back to their home page). INTERSTITIAL_TITLE_MARKERS: list[str] = [ "<title>google search</title>", ] def is_interstitial_title(body_lower: str) -> bool: return any(m in body_lower for m in INTERSTITIAL_TITLE_MARKERS) def is_redirect_stub(text: str, *, target_url: Optional[str] = None) -> bool: """True when the body only exists to send the reader back to `target_url`. AMP-cache style stubs answer HTTP 200 with a few hundred bytes whose sole content is a meta-refresh / JS redirect pointing at the origin we failed to fetch — accepting one loops the caller straight back into the block. """ if not text or len(text) >= SMALL_BODY_THRESHOLD: return False lowered = text.lower() has_redirect = ( "http-equiv=\"refresh\"" in lowered or "location.replace" in lowered or "window.location" in lowered ) if not has_redirect: return False if target_url is None: return True host_match = "".join(c for c in target_url.split("//")[-1].split("/")[0]) if not host_match: return True return host_match.lower() in lowered class Verdict(Enum): """Three-level classification (Codex suggestion — avoid binary).""" STRONG_OK = "strong_ok" # passes all layers incl. success_selectors WEAK_OK = "weak_ok" # passes 1-3 but no positive proof available CHALLENGE = "challenge" # fails 1-3 (negative proof triggered) BLOCKED = "blocked" # non-200 status UNKNOWN = "unknown" # exception / malformed response @dataclass class ValidationResult: verdict: Verdict reasons: list[str] = field(default_factory=list) matched_selectors: list[str] = field(default_factory=list) body_size: int = 0 status: int = 0 @property def ok(self) -> bool: """Kept for ergonomic `if vr.ok:` use — weak_ok counts as ok.""" return self.verdict in (Verdict.STRONG_OK, Verdict.WEAK_OK) def to_dict(self) -> dict: return { "verdict": self.verdict.value, "reasons": self.reasons, "matched_selectors": self.matched_selectors, "body_size": self.body_size, "status": self.status, } def _marker_hits(body_lower: str) -> list[str]: return [m for m in CHALLENGE_MARKERS if m.lower() in body_lower] def _abck_unresolved(cookies: dict) -> bool: abck = cookies.get("_abck", "") return bool(abck) and "~-1~" in abck def _selector_hits(body: str, selectors: list[str]) -> Optional[list[str]]: """Return matched-selector list, or None if BS4 is unavailable. Distinguishing None (dependency missing) from [] (nothing matched) lets the caller classify as UNKNOWN vs CHALLENGE correctly (Codex review: do not let dependency failure masquerade as a WAF outcome). """ if BeautifulSoup is None: return None try: soup = BeautifulSoup(body, "html.parser") except Exception: return [] hits: list[str] = [] for sel in selectors: try: if soup.select(sel): hits.append(sel) except Exception: continue return hits def validate( resp, *, success_selectors: Optional[list[str]] = None, known_bad_sizes: Optional[list[int]] = None, size_tolerance: int = 20, target_url: Optional[str] = None, ) -> ValidationResult: """Validate a `curl_cffi` / `requests` response. Parameters ---------- resp Response object with `.status_code`, `.text`, and cookie-like access. success_selectors Caller-supplied CSS selectors. Any match promotes `weak_ok` → `strong_ok`. Absence of selectors still allows `weak_ok` (no positive proof, but no negative proof either). known_bad_sizes Byte sizes that have been empirically observed as challenge-page fingerprints (caller / profile hint). NOTE: these values decay over time — profiles should timestamp or refresh them. """ try: status = int(getattr(resp, "status_code", 0) or 0) text = getattr(resp, "text", "") or "" size = len(text) except Exception as e: return ValidationResult(verdict=Verdict.UNKNOWN, reasons=[f"parse_error:{e}"]) r = ValidationResult(verdict=Verdict.UNKNOWN, body_size=size, status=status) if status == 0 or status >= 400: r.verdict = Verdict.BLOCKED r.reasons.append(f"status={status}") return r # --- Layer 1: challenge markers (product strings, never site brand) --- lowered = text.lower() markers = _marker_hits(lowered) if markers: r.verdict = Verdict.CHALLENGE r.reasons.extend(f"marker:{m}" for m in markers[:3]) return r # --- Layer 1.5: surrogate-route dead ends (interstitial / redirect stub) -- if is_interstitial_title(lowered): r.verdict = Verdict.CHALLENGE r.reasons.append("interstitial_title") return r if is_redirect_stub(text, target_url=target_url): r.verdict = Verdict.CHALLENGE r.reasons.append("redirect_stub") return r # --- Layer 2: size fingerprints (caller hint, tolerant match) --- # Fingerprint match is a strong negative signal — override even selectors. if known_bad_sizes: for bad in known_bad_sizes: if abs(size - bad) <= size_tolerance: r.verdict = Verdict.CHALLENGE r.reasons.append(f"size_fp:{size}~{bad}") return r # --- Layer 4 (early): caller's positive proof overrides size heuristic --- # If caller provided selectors, trust their definition of "content exists". # A small page with the required selector is still success. if success_selectors: hits = _selector_hits(text, success_selectors) if hits is None: # BS4 dependency missing — can't evaluate caller's proof. # Classify as UNKNOWN (not CHALLENGE) so a WAF outcome isn't faked. r.verdict = Verdict.UNKNOWN r.reasons.append("bs4_missing") return r if hits: # Cookie sensor state acts as a true gate when selectors passed: # unresolved `_abck` means Akamai did not accept our session even # though the body has our expected selector — trust is weak. cookies = _extract_cookies(resp) r.matched_selectors = hits if _abck_unresolved(cookies): r.reasons.append("abck_unresolved") r.verdict = Verdict.WEAK_OK # demoted from STRONG_OK return r r.verdict = Verdict.STRONG_OK return r # Selectors requested but none matched → challenge regardless of size. r.verdict = Verdict.CHALLENGE r.reasons.append("no_success_selector") return r # No selectors: fall back to size heuristic. if size < SMALL_BODY_THRESHOLD: r.verdict = Verdict.CHALLENGE r.reasons.append(f"tiny_body:{size}") return r # --- Layer 3: cookie sensor state (only when no selectors to decide on) --- cookies = _extract_cookies(resp) if _abck_unresolved(cookies): r.reasons.append("abck_unresolved") # No positive proof available — weak OK. r.verdict = Verdict.WEAK_OK return r def _extract_cookies(resp) -> dict: try: return {c.name: c.value for c in resp.cookies.jar} except Exception: try: return dict(resp.cookies) if hasattr(resp, "cookies") else {} except Exception: return {} -
waf_detector.py 6.9 KB
"""WAF-product detection from a live response. Returns a *ranking* of (profile_id, confidence) pairs — never a single verdict. Single-answer detectors cause cascading wrong plans when misfiring (Codex's critique). Planner consumes the ranking and tries top candidates in order. All detectors operate on WAF-vendor artifacts (cookies / headers / body strings) — never site hostnames. See engine/waf_profiles.yaml for the profile definitions. """ from __future__ import annotations import fnmatch import os import re from dataclasses import dataclass from typing import Optional try: import yaml # PyYAML except ImportError: yaml = None PROFILES_PATH = os.path.join(os.path.dirname(__file__), "waf_profiles.yaml") # In-code safety net — used when waf_profiles.yaml is missing / invalid # or PyYAML isn't installed. Keeps fetch() working in a degraded-but-sane # mode. Must stay site-agnostic (No-Site-Name Rule). _DEFAULT_PROFILES: dict = { "unknown_challenge": { "detectors": {}, "confidence_rules": {"strong": 0, "weak": 0}, "capabilities_needed": ["needs_js_exec"], "tls_impersonate_candidates": [ ["safari", "chrome", "firefox"], ["safari_ios", "chrome_android"], ], "referer_strategies": ["self_root", "google_search", "none"], "url_transform_order": ["original", "mobile_subdomain"], "fallback_when_challenge": ["surrogate_wayback", "playwright_mcp", "playwright_real_chrome"], "notes": "in-code default — waf_profiles.yaml unavailable", }, } # Module-level sticky error. Readers call `last_load_error()` after each # `_load_profiles()` call to surface YAML problems in FetchResult.trace. _LAST_LOAD_ERROR: Optional[str] = None @dataclass class DetectionHit: profile_id: str confidence: float signals: list[str] def last_load_error() -> Optional[str]: """Return the most recent profile-loader error (or None if clean).""" return _LAST_LOAD_ERROR def _load_profiles(path: str = PROFILES_PATH) -> dict: """Load profiles with graceful fallback. Never raises. On any failure (PyYAML missing, file missing, parse error, unexpected shape) it returns a copy of `_DEFAULT_PROFILES` and stores the reason in `_LAST_LOAD_ERROR` for the caller to surface. """ global _LAST_LOAD_ERROR _LAST_LOAD_ERROR = None if yaml is None: _LAST_LOAD_ERROR = "PyYAML not installed — using in-code default profile" return dict(_DEFAULT_PROFILES) try: with open(path, "r", encoding="utf-8") as f: loaded = yaml.safe_load(f) or {} except FileNotFoundError: _LAST_LOAD_ERROR = f"waf_profiles.yaml not found at {path}" return dict(_DEFAULT_PROFILES) except yaml.YAMLError as e: _LAST_LOAD_ERROR = f"YAML parse error: {type(e).__name__}: {str(e)[:200]}" return dict(_DEFAULT_PROFILES) except Exception as e: _LAST_LOAD_ERROR = f"profile loader: {type(e).__name__}: {str(e)[:200]}" return dict(_DEFAULT_PROFILES) if not isinstance(loaded, dict) or not any(k for k in loaded if not k.startswith("_")): _LAST_LOAD_ERROR = f"waf_profiles.yaml has no usable profiles" return dict(_DEFAULT_PROFILES) return loaded def _cookies_dict(resp) -> dict: try: return {c.name: c.value for c in resp.cookies.jar} except Exception: try: return dict(resp.cookies) if hasattr(resp, "cookies") else {} except Exception: return {} def _headers_dict(resp) -> dict: try: return {k.lower(): v for k, v in dict(resp.headers).items()} except Exception: return {} def _match_patterns(haystack_keys: list[str], patterns: list[str]) -> list[str]: """Match literal names or fnmatch patterns (for wildcards like `X-Akamai-*`).""" hits: list[str] = [] lowered_keys = [k.lower() for k in haystack_keys] for pat in patterns or []: pat_l = pat.lower() if any(c in pat for c in "*?["): for key in lowered_keys: if fnmatch.fnmatchcase(key, pat_l): hits.append(pat) break else: if pat_l in lowered_keys: hits.append(pat) return hits def _score_profile(profile_id: str, profile: dict, resp) -> Optional[DetectionHit]: """Apply profile detectors to resp. Returns hit or None.""" if profile_id.startswith("_"): return None detectors = profile.get("detectors") or {} if not detectors and profile_id != "unknown_challenge": return None cookies = _cookies_dict(resp) headers = _headers_dict(resp) body = (getattr(resp, "text", "") or "").lower() server = headers.get("server", "") signals: list[str] = [] # Cookie detectors cookie_pats = detectors.get("cookie") or [] for hit in _match_patterns(list(cookies.keys()), cookie_pats): signals.append(f"cookie:{hit}") # Header detectors header_pats = detectors.get("header") or [] for hit in _match_patterns(list(headers.keys()), header_pats): signals.append(f"header:{hit}") # Server substring for needle in detectors.get("server_contains") or []: if needle.lower() in server: signals.append(f"server:{needle}") # Body markers for needle in detectors.get("body") or []: if needle.lower() in body: signals.append(f"body:{needle}") if not signals: return None rules = profile.get("confidence_rules") or {"strong": 2, "weak": 1} n = len(signals) if n >= rules.get("strong", 2): conf = 0.9 elif n >= rules.get("weak", 1): conf = 0.6 else: conf = 0.3 return DetectionHit(profile_id=profile_id, confidence=conf, signals=signals) def detect(resp, *, profiles: Optional[dict] = None, min_confidence: float = 0.0) -> list[DetectionHit]: """Return ranked list of detection hits (best first). When nothing fires, the returned list contains a single `unknown_challenge` hit with confidence 0.1 — caller can use its conservative settings. """ if profiles is None: profiles = _load_profiles() hits: list[DetectionHit] = [] for profile_id, profile in profiles.items(): if profile_id.startswith("_"): continue h = _score_profile(profile_id, profile, resp) if h and h.confidence >= min_confidence: hits.append(h) hits.sort(key=lambda x: x.confidence, reverse=True) if not hits: hits.append(DetectionHit( profile_id="unknown_challenge", confidence=0.1, signals=["fallback"], )) return hits def load_profile(profile_id: str, *, profiles: Optional[dict] = None) -> dict: """Get one profile by id, resolving `unknown_challenge` if missing.""" if profiles is None: profiles = _load_profiles() return profiles.get(profile_id) or profiles.get("unknown_challenge") or {} -
waf_profiles.yaml 6.2 KB
# WAF Product Profiles — never site-specific. # # NO-SITE-NAME RULE: # * Key names are WAF products (akamai_bot_manager), not sites. # * Detectors use product artifacts (cookies / headers / vendor strings). # * Field values (markers, cookie names) must appear in any site running # that WAF, not just one. If a value only fits one site, it belongs # to runtime hints / observations, not this file. # # Profiles are *recommendations*, not deterministic recipes. The planner # treats them as priors; attempts always evaluate real responses. # # Timestamp each profile entry. If stale (> 6 months), cross-validate. _meta: schema_version: 1 last_reviewed: "2026-04-21" akamai_bot_manager: detectors: cookie: ["_abck", "bm_sz", "ak_bmsc", "bm_sv", "bm_so"] header: ["X-Akamai-*"] server_contains: ["AkamaiGHost"] body: ["sec-if-cpt-container", "Powered and protected by Akamai"] confidence_rules: # Multi-signal gating — single marker insufficient. strong: 2 # any 2 signals from above → confidence 0.9 weak: 1 # 1 signal → confidence 0.6 capabilities_needed: - needs_real_tls_stack # Playwright Chromium (BoringSSL) is detected - needs_js_exec # 2.6KB challenge requires JS sensor tls_impersonate_candidates: # Every impersonate target curl_cffi supports that historically yielded # at least a challenge page (i.e. IP still alive) rather than an outright # TLS reject. Grouped by family; planner tries top groups first. # Refresh quarterly — vendor WAFs shift which TLS fingerprints they trust. - [safari, safari15_3, safari15_5, safari17_0, safari260, safari2601] - [safari_ios, safari17_2_ios, safari260_ios] - [chrome99, chrome100, chrome101, chrome104, chrome110, chrome116, chrome119, chrome124, chrome131, chrome133a, chrome136, chrome142, chrome145, chrome146] - [chrome_android, chrome131_android] - [edge99, edge101] tls_impersonate_avoid: # Empirically observed to 403 immediately (TLS fingerprint blacklisted). # DO NOT hard-block — planner deprioritizes only. Refresh quarterly. # NOTE: requires curl_cffi >= 0.15.0 for chrome142/145/146 + safari260/2601 + # firefox144/147 targets (added 0.14.0-0.15.0; default aliases resolve to # chrome146/safari2601/firefox147 in 0.16.0+). Older installs raise ImpersonateError. - safari18_0 - chrome107 - chrome120 - chrome123 - firefox133 - firefox135 referer_strategies: - self_root # scheme://host/ url_transform_order: - original - mobile_subdomain # www.* → m.* — strong observational win in SSR sites fallback_when_challenge: - surrogate_wayback # Phase 2.5: archive/reader copy before a browser spin-up - curl_grid_exhaust # try more impersonate × referer × url combos - playwright_real_chrome notes: | DO NOT encode site-specific selectors or byte-size fingerprints here. Those belong to caller's success_selectors param or observations log. cloudflare_turnstile: detectors: cookie: ["cf_clearance", "__cf_bm", "__cfduid"] header: ["cf-ray", "cf-cache-status"] server_contains: ["cloudflare"] body: ["Just a moment...", "Checking your browser", "cf-chl-bypass", "Attention Required! | Cloudflare"] capabilities_needed: - needs_js_exec # MCP Playwright Chromium OK — no real-TLS required tls_impersonate_candidates: - [chrome, chrome_android] referer_strategies: - google_search - self_root fallback_when_challenge: - surrogate_wayback # Phase 2.5: archive/reader copy before a browser spin-up - playwright_mcp # MCP sufficient; Chromium TLS passes CF baseline - playwright_real_chrome f5_big_ip: detectors: cookie: ["BigIPServer", "TS01*", "F5_*"] body: ["The requested URL was rejected", "support ID is:"] capabilities_needed: - needs_real_tls_stack tls_impersonate_candidates: - [safari, chrome] referer_strategies: - self_root aws_waf: detectors: cookie: ["aws-waf-token"] header: ["x-amzn-requestid", "x-amzn-errortype", "x-amzn-waf-*"] capabilities_needed: - needs_real_tls_stack tls_impersonate_candidates: - [chrome] referer_strategies: - self_root datadome_probable: detectors: cookie: ["datadome"] body: ["DataDome"] capabilities_needed: - needs_real_tls_stack - needs_js_exec tls_impersonate_candidates: - [safari, chrome] fallback_when_challenge: - surrogate_wayback # Phase 2.5: archive/reader copy before a browser spin-up - playwright_real_chrome notes: | "_probable" suffix reminds us this is a growing attack surface — mark as tentative until cross-site evidence accumulates in observations/. perimeterx_human: detectors: cookie: ["_px3", "_pxhd", "_px2", "pxcts"] body: ["px-captcha", "Press & Hold to confirm you are a human"] capabilities_needed: - needs_real_tls_stack - needs_js_exec tls_impersonate_candidates: - [safari, chrome] fallback_when_challenge: - surrogate_wayback # Phase 2.5: archive/reader copy before a browser spin-up - playwright_real_chrome notes: | PerimeterX (now HUMAN Bot Defender). Distinct cookie family from DataDome. Keep profiles separate so planner does not pick wrong fallback strategy. # --------------------------------------------------------------------------- # Safety net: always-valid fallback profile. # --------------------------------------------------------------------------- unknown_challenge: detectors: {} # never matches actively — used only when no other profile fires confidence_rules: strong: 0 weak: 0 capabilities_needed: - needs_js_exec # conservative default tls_impersonate_candidates: - [safari, chrome, firefox] - [safari_ios, chrome_android] referer_strategies: - self_root - google_search - none url_transform_order: - original - mobile_subdomain fallback_when_challenge: - surrogate_wayback # Phase 2.5: archive/reader copy before a browser spin-up - playwright_mcp - playwright_real_chrome notes: | When detector returns low-confidence results we land here. Broad, conservative grid. Evidence from these runs should feed observations/ for eventual profile promotion. -
__init__.py 668 B
"""insane-search engine — generic WAF-profile-based fetch chain. No site-specific logic lives here. Site specifics belong to runtime hints or observations, never to code. See `../SKILL.md` for the No-Site-Name Rule. """ from .validators import Verdict, ValidationResult, validate, CHALLENGE_MARKERS from .waf_detector import detect from .url_transforms import TRANSFORMS, apply_transform from .fetch_chain import fetch from .result_schema import Attempt, FetchResult __all__ = [ "Verdict", "ValidationResult", "validate", "CHALLENGE_MARKERS", "detect", "TRANSFORMS", "apply_transform", "fetch", "FetchResult", "Attempt", ] -
__main__.py 4.8 KB
#!/usr/bin/env python3 """CLI entrypoint for the insane-search engine. Usage: python3 -m engine URL [--selector CSS] [--device auto|desktop|mobile] [--timeout N] [--max-attempts N] [--json] [--trace] Examples: python3 -m engine "https://example.com/" --selector "h1" python3 -m engine "https://example.com/" --json python3 -m engine "https://example.com/" --device mobile --trace Exit codes: 0 strong_ok or weak_ok 1 ok=False (all attempts failed) 2 CLI arg error """ from __future__ import annotations import argparse import json import sys from . import fetch def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="python3 -m engine", description="Generic WAF-profile fetch chain.") p.add_argument("url", help="URL to fetch.") p.add_argument("--selector", "-s", action="append", default=None, dest="selectors", metavar="CSS", help="Positive-proof CSS selector. Repeatable.") p.add_argument("--device", choices=("auto", "desktop", "mobile"), default="auto", help="Device class pin.") p.add_argument("--timeout", type=int, default=25, help="Per-attempt timeout seconds (default 25).") p.add_argument("--max-attempts", type=int, default=12, help="Upper bound across all phases (default 12).") p.add_argument("--no-playwright", action="store_true", help="Skip Playwright fallback (curl-only).") p.add_argument("--allow-proxy", action="store_true", help=("Enable kind=proxy surrogate entries (raw relay routes; " "MITM by construction — never citable alone, never " "receives cookies/auth headers). Off by default.")) p.add_argument("--json", action="store_true", help="Emit FetchResult as JSON to stdout (content omitted).") p.add_argument("--trace", action="store_true", help="Print per-attempt trace to stderr.") return p def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: result = fetch( args.url, success_selectors=args.selectors, device_class=args.device, timeout=args.timeout, max_attempts=args.max_attempts, enable_playwright=not args.no_playwright, allow_surrogate_proxy=args.allow_proxy, ) except Exception as e: print(f"engine fatal: {type(e).__name__}: {e}", file=sys.stderr) return 2 if args.trace: print("=== trace ===", file=sys.stderr) for att in result.trace: d = att.to_dict() imp = d.get("impersonate") or "-" ref = d.get("referer") or "-" print( f"[{d['phase']:<8}] {d['executor']:<18} " f"xform={d['url_transform']:<16} imp={imp:<14} ref={ref:<14} " f"status={d['status']:>4} size={d['body_size']:>8} " f"verdict={d['verdict']} {('err=' + d['error'][:60]) if d.get('error') else ''}", file=sys.stderr, ) print(f"=== summary: {result.summary} ===", file=sys.stderr) # Surface R7 hint (API-first route) prominently when summary contains it, # regardless of --trace flag — this is actionable guidance, not noise. if "R7 API-first" in (result.summary or ""): print( "\n════════════════════════════════════════════════════════════════\n" "⚠️ R7 triggered — consider API-first route instead of HTML grid.\n" " See summary below (or re-run with --trace for full attempt log).\n" "════════════════════════════════════════════════════════════════", file=sys.stderr, ) # Also print the full summary (which includes the hint) so caller sees it. print(result.summary, file=sys.stderr) if args.json: payload = result.to_dict() print(json.dumps(payload, ensure_ascii=False, indent=2)) else: # Default: HTML to stdout, status to stderr. print(result.content, end="") print(f"\n[engine] ok={result.ok} verdict={result.verdict} " f"profile={result.profile_used} attempts={len(result.trace)} " f"provenance={result.provenance}" + (f" snapshot_ts={result.snapshot_timestamp}" if result.snapshot_timestamp else ""), file=sys.stderr) return 0 if result.ok else 1 if __name__ == "__main__": sys.exit(main())
-
-
references
-
agent-reach
-
career.md 712 B
# 职场招聘 LinkedIn。 ## LinkedIn ```bash # 获取个人资料 mcporter call 'linkedin-scraper.get_person_profile(linkedin_url: "https://linkedin.com/in/username")' # 搜索人才 mcporter call 'linkedin-scraper.search_people(keyword: "AI engineer", limit: 10)' # 获取公司资料 mcporter call 'linkedin-scraper.get_company_profile(linkedin_url: "https://linkedin.com/company/xxx")' # 搜索职位 mcporter call 'linkedin-scraper.search_jobs(keyword: "software engineer", limit: 10)' ``` > **需要登录**: LinkedIn scraper 需要有效的登录态。 ### Fallback 方案 如果 MCP 不可用,可以用 Jina Reader: ```bash curl -s "https://r.jina.ai/https://linkedin.com/in/username" ``` -
dev.md 1.3 KB
# 开发工具 GitHub CLI ## GitHub (gh CLI) GitHub 官方命令行工具,用于仓库、Issue、PR、Actions、Release 以及 API 访问。 ```bash # 认证 gh auth login gh auth status # 搜索 gh search repos "query" --sort stars --limit 10 gh search code "query" --language python # 仓库 gh repo view owner/repo gh repo clone owner/repo gh repo create my-repo --private gh repo fork owner/repo gh repo fork owner/repo --clone gh repo sync owner/repo # Issues gh issue list -R owner/repo --state open gh issue view 123 -R owner/repo gh issue create -R owner/repo --title "Title" --body "Body" # Pull Requests gh pr list -R owner/repo --state open gh pr view 123 -R owner/repo gh pr create -R owner/repo --title "Title" --body "Body" gh pr checks 123 --repo owner/repo # Actions / CI gh run list --repo owner/repo --limit 10 gh run view <run-id> --repo owner/repo gh run view <run-id> --repo owner/repo --log-failed gh workflow list --repo owner/repo # Releases gh release list -R owner/repo gh release create v1.0.0 # API gh api /user gh api repos/owner/repo # JSON 输出 gh issue list --repo owner/repo --json number,title --jq '.[] | "\(.number): \(.title)"' ``` ## 选择指南 | 工具 | 来源 | 用途 | |-----|------|------| | gh CLI | agent-reach | Git 操作 | | zread | my-mcp-tools | 读仓库内容 | | context7 | my-mcp-tools | 查技术文档 | -
README.md 2.2 KB
# Agent Reach — 路由器 > Part of the **ultimate-browsing** skill (Tier 1.5). Routing and tier-escalation live in [../../SKILL.md](../../SKILL.md). > Per-category guides in this folder: [search.md](search.md) · [social.md](social.md) · [career.md](career.md) · [dev.md](dev.md) · [web.md](web.md) · [video.md](video.md). 17 平台工具集合。根据用户意图选择对应分类。 ## 路由表 | 用户意图 | 分类 | 详细文档 | |---------|------|---------| | 网页搜索/代码搜索 | search | [search.md](search.md) | | 小红书/抖音/微博/推特/B站/V2EX/Reddit | social | [social.md](social.md) | | 招聘/职位/LinkedIn | career | [career.md](career.md) | | GitHub/代码 | dev | [dev.md](dev.md) | | 网页/文章/公众号/RSS | web | [web.md](web.md) | | YouTube/B站/播客字幕 | video | [video.md](video.md) | ## 零配置快速命令 ```bash # Exa 网页搜索 mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' # 通用网页阅读 curl -s "https://r.jina.ai/URL" # GitHub 搜索 gh search repos "query" --sort stars --limit 10 # Twitter 搜索 twitter search "query" --limit 10 # YouTube/B站字幕 yt-dlp --write-sub --skip-download -o "/tmp/%(id)s" "URL" # Reddit 搜索 rdt search "query" --limit 10 # Reddit 读帖 + 评论 rdt read POST_ID # V2EX 热门 curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0" ``` ## 环境检查 ```bash # 检查可用 channel agent-reach doctor # 查看所有 MCP 服务 mcporter_list_servers() ``` ## 工作区规则 **不要在 agent workspace 创建文件。** 使用 `/tmp/` 存放临时输出。 ## 详细文档 根据用户需求,阅读对应的详细文档: - [搜索工具](search.md) — Exa AI 搜索 - [社交媒体](social.md) — 小红书, 抖音, Twitter, B站, V2EX, Reddit - [职场招聘](career.md) — LinkedIn - [开发工具](dev.md) — GitHub CLI - [网页阅读](web.md) — Jina Reader, 微信公众号, RSS - [视频播客](video.md) — YouTube, B站, 小宇宙 ## 配置渠道 如果某个 channel 需要配置,获取安装指南: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md 用户只需提供 cookies,其他配置由 agent 完成。 -
search.md 821 B
# 搜索工具 Exa AI 搜索引擎。 ## Exa AI 搜索 高质量 AI 搜索引擎,擅长技术和代码搜索。 ```bash mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' mcporter call 'exa.get_code_context_exa(query: "code question", tokensNum: 3000)' ``` ### 使用场景 | 场景 | 参数 | |-----|------| | 网页搜索 | `web_search_exa(query: "...", numResults: 5)` | | 代码搜索 | `get_code_context_exa(query: "...", tokensNum: 3000)` | ### 特点 - 擅长英文内容和技术文档 - 支持代码上下文搜索 - 结果质量高 ## 与其他搜索工具对比 | 工具 | 来源 | 适用场景 | |-----|------|---------| | Exa | agent-reach | 英文/技术/代码搜索 | | 智谱搜索 | my-mcp-tools | 中文搜索 | | GitHub 搜索 | agent-reach (dev.md) | 仓库/代码搜索 | -
social.md 5.6 KB
# 社交媒体 & 社区 小红书、抖音、Twitter/X、微博、B站、V2EX、Reddit。 ## 小红书 / XiaoHongShu (xhs-cli) ### 稳定可用的命令 ```bash # 搜索笔记(推荐入口) xhs search "query" # 阅读笔记详情(必须用搜索结果中的 URL 或 ID,不能裸 note_id) xhs read NOTE_ID_OR_URL # 查看评论 xhs comments NOTE_ID_OR_URL # 浏览热门 xhs hot # 推荐 feed xhs feed ``` ### 已知不稳定的命令(v0.6.4) ```bash # 以下命令当前可能返回 API error,谨慎使用: xhs user USER_ID # 可能返回 {code: -1} xhs user-posts USER_ID # 可能返回 {code: -1} xhs favorites # 可能返回 API error ``` ### 重要注意事项 > **安装**: `pipx install xiaohongshu-cli`,然后 `xhs login`(自动从浏览器提取 Cookie)。 > > **签名令牌限制**: 小红书强制每个 note 携带一个签名令牌,**不能直接用裸 note_id 去读**。正确流程是:先 `xhs search` 或 `xhs feed` 获取结果,再用结果中的 URL/ID 去 `xhs read`。直接构造 note_id 会被拦截。 > > **频率控制**: 高频请求(批量搜索、深翻评论)会触发验证码,这是平台限制无法绕过。建议每次操作间隔 2-3 秒。 > > **POST 操作**: 发帖(post)、评论(comment)、点赞(like) 等写操作在 v0.6.4 已修复签名问题 (PR [#19](https://github.com/jackwener/xiaohongshu-cli/pull/19)),可正常使用。 ## 抖音 / Douyin ```bash # 解析视频信息 mcporter call 'douyin.parse_douyin_video_info(share_link: "https://v.douyin.com/xxx/")' # 获取无水印下载链接 mcporter call 'douyin.get_douyin_download_link(share_link: "https://v.douyin.com/xxx/")' # 提取视频文案 mcporter call 'douyin.extract_douyin_text(share_link: "https://v.douyin.com/xxx/")' ``` > **无需登录** ## Twitter/X (twitter-cli) ### 稳定命令 ```bash # 首页时间线(最稳定) twitter feed -n 20 # 读取单条推文(含回复) twitter tweet URL_OR_ID # 读取长文 / X Article twitter article URL_OR_ID # 用户时间线 twitter user-posts @username -n 20 # 用户资料 twitter user @username ``` ### 可能不稳定的命令 ```bash # 搜索推文(Twitter 频繁改 GraphQL 端点,可能 404) twitter search "query" -n 10 # 如果 search 返回 404,升级 twitter-cli:pipx upgrade twitter-cli # likes(2024 年后只能看自己的,平台限制) twitter likes ``` ### 重要注意事项 > **安装**: `pipx install twitter-cli`(确保 v0.8.5+) > > **认证**: 如果你有访问权限,导出 Twitter 会话 Cookie 后,把 auth-token 与 ct0 两个值设置为 twitter-cli 文档所要求的认证环境变量。自动提取在 SSH/Docker/无头环境不可用。 > > **IP 风控**: 不要在 VPS/数据中心 IP 上频繁调用,尤其是 followers/following,有封号风险。使用住宅代理或本地环境。 > > **search 可能失效**: Twitter 频繁修改 GraphQL API,search 命令可能随时返回 404。如遇到,先 `pipx upgrade twitter-cli`。如果最新版仍不行,说明上游还没跟上 Twitter 的改动,用 `twitter feed` 替代。 > > **输出格式**: 建议用 `--yaml` 或 `--json` 获得结构化输出,对 AI agent 更友好。 ## 微博 / Weibo ```bash # 使用 Jina Reader 读取 curl -s "https://r.jina.ai/https://weibo.com/USER_ID/POST_ID" ``` > 微博主要通过网页抓取,推荐使用通用网页读取方式。 ## B站 / Bilibili ```bash # 获取视频元数据 yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" # 下载字幕 yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL" ``` > **注意**: 服务器 IP 可能遇到 412 错误。使用 `--cookies-from-browser chrome` 或配置代理。 ## V2EX (公开 API) 无需认证,直接调用公开 API。 ### 热门主题 ```bash curl -s "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: agent-reach/1.0" ``` ### 节点主题 ```bash # node_name 如: python, tech, jobs, qna, programmers curl -s "https://www.v2ex.com/api/topics/show.json?node_name=python&page=1" -H "User-Agent: agent-reach/1.0" ``` ### 主题详情 ```bash # topic_id 从 URL 获取,如 https://www.v2ex.com/t/1234567 curl -s "https://www.v2ex.com/api/topics/show.json?id=TOPIC_ID" -H "User-Agent: agent-reach/1.0" ``` ### 主题回复 ```bash curl -s "https://www.v2ex.com/api/replies/show.json?topic_id=TOPIC_ID&page=1" -H "User-Agent: agent-reach/1.0" ``` ### 用户信息 ```bash curl -s "https://www.v2ex.com/api/members/show.json?username=USERNAME" -H "User-Agent: agent-reach/1.0" ``` ### Python 调用示例 ```python from agent_reach.channels.v2ex import V2EXChannel ch = V2EXChannel() # 获取热门帖子 topics = ch.get_hot_topics(limit=10) for t in topics: print(f"[{t['node_title']}] {t['title']} ({t['replies']} 回复)") # 获取节点帖子 node_topics = ch.get_node_topics("python", limit=5) # 获取帖子详情 + 回复 topic = ch.get_topic(1234567) print(topic["title"], "—", topic["author"]) # 获取用户信息 user = ch.get_user("Livid") ``` > **节点列表**: https://www.v2ex.com/planes ## Reddit (rdt-cli) ```bash # 搜索帖子 rdt search "query" --limit 10 # 读帖子全文 + 评论 rdt read POST_ID # 浏览 subreddit rdt sub python --limit 20 # 浏览热门 rdt popular --limit 10 # 浏览 /r/all rdt all --limit 10 ``` > **安装**: `pipx install rdt-cli`(确保 v0.4.2+)。无需登录即可搜索和阅读。 > 需要登录的功能:`rdt feed --subs-only`(订阅列表)、`rdt saved`(收藏)。 > 建议使用 `--yaml` 输出,对 AI agent 更友好。 -
video.md 2.7 KB
# 视频/播客 YouTube、B站、小宇宙播客的字幕和转录。 ## YouTube (yt-dlp) ### 获取视频元数据 ```bash yt-dlp --dump-json "URL" ``` ### 下载字幕 ```bash # 下载字幕 (不下载视频) yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --skip-download -o "/tmp/%(id)s" "URL" # 然后读取 .vtt 文件 cat /tmp/VIDEO_ID.*.vtt ``` ### 获取评论 ```bash # 提取评论(best-effort,不保证完整) yt-dlp --write-comments --skip-download --write-info-json \ --extractor-args "youtube:max_comments=20" \ -o "/tmp/%(id)s" "URL" # 评论在 .info.json 的 comments 字段中 ``` ### 搜索视频 ```bash yt-dlp --dump-json "ytsearch5:query" ``` > **字幕注意**: 手动上传的字幕提取可靠;自动生成字幕可能存在行间重复,需后处理。 > **评论注意**: `--write-comments` 基于网页抓取(非 YouTube Data API),部分评论可能丢失。 ## B站 / Bilibili (yt-dlp + bili-cli) ### 视频元数据 (yt-dlp) ```bash yt-dlp --dump-json "https://www.bilibili.com/video/BVxxx" ``` ### 字幕 (yt-dlp) ```bash yt-dlp --write-sub --write-auto-sub --sub-lang "zh-Hans,zh,en" --convert-subs vtt --skip-download -o "/tmp/%(id)s" "URL" ``` ### 搜索/热门/排行 (bili-cli) ```bash # 搜索视频 bili search "query" --type video -n 5 # 热门视频 bili hot -n 10 # 排行榜 bili rank -n 10 ``` > **412 风控**: 海外 IP 必须提供 Cookie(`--cookies-from-browser chrome` 或 `--cookies /path/to/cookies.txt`),国内 IP 一般不受影响。 > **安装 bili-cli**: `pipx install bilibili-cli`,然后 `bili login` 扫码登录。 ## 小宇宙播客 / Xiaoyuzhou Podcast ### 转录单集播客 ```bash # 输出 Markdown 文件到 /tmp/ (transcribe via the agent-reach xiaoyuzhou tool) agent-reach run xiaoyuzhou transcribe "https://www.xiaoyuzhoufm.com/episode/EPISODE_ID" ``` ### 前置要求 1. **ffmpeg**: `brew install ffmpeg` 2. **Groq API Key** (免费): https://console.groq.com/keys 3. **配置 Key**: `agent-reach configure groq-key YOUR_KEY` 4. **首次运行**: `agent-reach install --env=auto` 安装工具 ### 检查状态 ```bash agent-reach doctor ``` > 输出 Markdown 文件默认保存到 `/tmp/`。 ## 抖音视频解析 ```bash # 解析视频信息 mcporter call 'douyin.parse_douyin_video_info(share_link: "https://v.douyin.com/xxx/")' # 获取无水印下载链接 mcporter call 'douyin.get_douyin_download_link(share_link: "https://v.douyin.com/xxx/")' ``` > 详见 [social.md](social.md#抖音--douyin) ## 选择指南 | 场景 | 推荐工具 | |-----|---------| | YouTube 字幕 | yt-dlp | | B站字幕 | yt-dlp | | 播客转录 | 小宇宙 transcribe.sh | | 抖音视频解析 | douyin MCP | -
web.md 1.9 KB
# 网页阅读 通用网页、微信公众号、RSS。 ## 通用网页 (Jina Reader) ```bash # 读取任意网页内容 curl -s "https://r.jina.ai/URL" # 示例 curl -s "https://r.jina.ai/https://example.com/article" ``` **适用场景**: 大多数网页可以直接用 Jina Reader 读取。 ## Web Reader (MCP) ```bash # 读取网页内容 (Markdown 格式) mcporter call 'web-reader.webReader(url: "https://example.com")' # 保留图片 mcporter call 'web-reader.webReader(url: "https://example.com", retain_images: true)' # 纯文本格式 mcporter call 'web-reader.webReader(url: "https://example.com", return_format: "text")' ``` **适用场景**: 需要更精确控制输出格式时使用。 ## 微信公众号 / WeChat Articles ### 搜索公众号文章(通过 Exa) ```bash # 搜索微信公众号文章 mcporter call 'exa.web_search_exa(query: "搜索关键词", numResults: 5, includeDomains: ["mp.weixin.qq.com"])' ``` ### 阅读公众号文章全文(通过 Exa) ```bash # 抓取文章全文 mcporter call 'exa.crawling_exa(urls: ["https://mp.weixin.qq.com/s/ARTICLE_ID"], maxCharacters: 10000)' ``` ### 可选:Camoufox 阅读(反爬更强) ```bash agent-reach run wechat-article "https://mp.weixin.qq.com/s/ARTICLE_ID" # Camoufox-backed WeChat reader ``` > **注意**: Jina Reader 无法读取微信文章(被 CAPTCHA 拦截),推荐用 Exa。 ## RSS (feedparser) ```python python3 -c " import feedparser for e in feedparser.parse('FEED_URL').entries[:5]: print(f'{e.title} — {e.link}') " ``` **适用场景**: 订阅博客、新闻源、播客等 RSS feed。 ## 选择指南 | 场景 | 推荐工具 | |-----|---------| | 通用网页 | Jina Reader (`curl r.jina.ai`) | | 需要图片/格式控制 | web-reader MCP | | 微信公众号 | Exa (搜索+阅读) / Camoufox (可选阅读) | | RSS 订阅 | feedparser | | 微博/知乎等 | Jina Reader |
-
-
insane-search
-
cache-archive.md 4.1 KB
# 캐시 & 아카이브 (surrogate 경로) > 원본 사이트가 차단되었을 때 캐시/아카이브된 **사본**으로 접근. > 2026-08-09 실측 probe 기준으로 정렬. 각 경로는 생명 주기가 짧다 — 이 파일도 > 90일마다 재검증 대상. (당일 probe: 기존 기대 경로 6개 중 4개 사망 또는 스텁 반환.) ## 의존성 없음 (curl만 사용). 수동 경로이며, 자동화는 엔진 Phase 2.5(`engine/surrogates.yaml`)가 담당한다. ## 엔진 자동 폴백 (Phase 2.5) `waf_profiles.yaml`의 `fallback_when_challenge`가 `surrogate_wayback`을 앞에 두므로, 그리드 실패 후 브라우저 실행 전에 아카이브 경로를 먼저 시도한다. 성공 시 `FetchResult.provenance = "snapshot"`, `snapshot_timestamp` = 아카이브의 자체 타임스탬프, `trust = "archive"`가 채워진다. **사본이므로 반드시 날짜와 함께 인용할 것.** `--allow-proxy` 없이는 `kind: proxy` 엔트리는 절대 실행되지 않으며, 프록시에는 Cookie/Authorization 헤더를 보내지 않는다 (중계자 = 구조적 MITM). ## 1. Wayback Machine (Internet Archive) — 1순위 **2026-08-09 probe: 정상 동작.** `available` API가 200 JSON으로 스냅샷 URL과 타임스탬프를 돌려준다 — 출처(provenance) 확보에 가장 좋은 primitive. ```bash # 스냅샷 존재 여부 + 최신 스냅샷 URL/타임스탬프 (진입점으로 이것을 쓸 것) curl -sL "https://archive.org/wayback/available?url={URL}" # 반환 JSON의 archived_snapshots.closest.url 로 접근 curl -sL "https://web.archive.org/web/{timestamp}/{URL}" ``` > **CDX API 주의**: 이전 버전이 권장하던 `web.archive.org/cdx/search/cdx`는 > 2026-08 probe에서 503 반환. 스냅샷 열거가 필요 없으면 `available` API만 사용. **성공 조건**: 크롤링 대상이었던 공개 URL **실패 조건**: robots.txt로 차단된 사이트, 스냅샷이 없는 URL, SPA 스냅샷 (렌더링 안 됨) ## 2. archive.today — 2순위 사용자 제출 아카이브. 페이월 기사, 삭제된 콘텐츠에 특히 유용. **2026-08 probe: 429 rate-limit이 잦고 도메인이 수시로 회전** (archive.ph → archive.md 관찰). 하나가 차단되면 다른 도메인을 순회한다 (엔진 `host_rotation`과 동일 패턴). ```bash # 최신 스냅샷 조회 — 도메인 회전은 필수 경로, 예외 처리 아님 for domain in archive.ph archive.md archive.li archive.is; do resp=$(curl -sL -o /dev/null -w "%{http_code}" "https://$domain/newest/{URL}") if [ "$resp" = "200" ] || [ "$resp" = "302" ]; then echo "성공: https://$domain/newest/{URL}" curl -sL "https://$domain/newest/{URL}" break fi done ``` **주의**: 429 응답에도 수 KB 본문이 딸려 오므로 상태코드 대신 본문 검증이 필요하다. ## 3. AMP 캐시 — 강등 (사실상 무용) 과거 1순위였으나 **2026-08 probe에서 사실상 무력화**: `{host}.cdn.ampproject.org/c/s/...`가 HTTP 200을 돌려주지만, 실제 본문은 **322바이트짜리 `<TITLE>Redirecting</TITLE>` meta-refresh** — 대상은 다시 **원본(차단된) 페이지**다. 이걸 성공으로 착각하면 에이전트가 차단 페이지로 되돌아가는 루프가 생긴다. 엔진은 `engine/validators.py:is_redirect_stub`으로 이 패턴을 CHALLENGE 판정한다 (3KB 미만 + meta-refresh/JS redirect + 대상 호스트 재등장 조합). 수동 사용도 권장하지 않는다. ## 4. Google Cache — 사망 확정 **2024년 7월 종료** 후로도 `webcache.googleusercontent.com`이 HTTP 200 + 수십 KB의 본문을 반환하지만, 실제로는 `<title>Google Search</title>` 인터스티셜 + JS 리다이렉트다 (2026-08 probe 재확인). **캐시가 아니라 검색 홈이다.** 엔진은 `INTERSTITIAL_TITLE_MARKERS`로 판정해 성공 집계에서 배제한다. ## 시도 순서 (probe 근거) ``` 1. Wayback available API → 스냅샷 URL + 타임스탬프 (provenance까지 확보) 2. archive.today 도메인 회전 (429 대비, 본문 검증 필수) 3. AMP 캐시: 시도하지 않음 (redirect stub → 원본으로 회귀) 4. Google Cache: 시도하지 않음 (사망, 검색 인터스티셜 반환) ``` -
fallback.md 6 KB
# 접근 실패 시 — 적응형 스케줄러 > 인덱스 방법이 실패하거나 인덱스에 없는 사이트일 때 실행. > Phase 0 → 1 → 2 → 3 순서로 에스컬레이션. 각 Phase에서 성공하면 즉시 종료. ## 원칙 1. **어떤 방법도 미리 제외하지 않는다** — 되는지는 시도해봐야 안다 2. **의존성이 없으면 설치하고 시도한다** — 미설치를 이유로 건너뛰지 않는다 3. **Phase 간 전환은 신호 기반** — 실패 유형에 따라 에스컬레이션 4. **결과 채택 기준**: 정확성/신뢰도 > 신선도 > 완전성 > 구조화 > 비용 --- ## Phase 0: 특수 엔드포인트 (인덱스 매칭) 인덱스에 사이트가 있으면 해당 전용 방법을 **먼저** 시도. 정확성과 비용이 가장 좋으므로 generic Phase 1보다 우선. 성공 → 종료 / 실패 → Phase 1 --- ## Phase 1: 경량 프로브 (병렬) **먼저 시도** (동시): - WebFetch (Claude 내장) - Jina Reader (기본 / JSON / SPA 모드) - curl Chrome Desktop UA **아직 성공 없으면 추가 시도**: - curl 모바일 UA + 모바일 URL (`m.{domain}`) - curl Googlebot UA - URL 변형 시도: `.json`, `/rss`, `/feed` **사이드카** (1차와 동시, low-trust): - Google AMP 캐시 - archive.today - Wayback Machine → **원본이 하나라도 성공하면 사이드카는 참고만.** 전부 실패 시에만 사이드카 채택 (provenance 태깅 필수) **모든 응답에서 메타데이터도 추출**: OGP, JSON-LD — [metadata.md](metadata.md) 참조 상세: [jina.md](jina.md), [cache-archive.md](cache-archive.md), [rss.md](rss.md) --- ## 에스컬레이션 신호 Phase 1 → Phase 2 전환 조건: | 신호 | 감지 방법 | 의미 | |------|-----------|------| | HTTP 403/430 | 상태 코드 | WAF/봇 차단 | | HTTP 429/503 | 상태 코드 | Rate limit (짧은 jitter retry 먼저, 실패 시 에스컬레이션) | | WAF 헤더 | `cf-ray`, `server: cloudflare`, `x-datadome` | Cloudflare/Akamai/DataDome | | WAF 쿠키 | `__cf_bm`, `_abck`, `datadome` | WAF 세션 | | 챌린지 본문 | `captcha`, `verify`, `enable javascript`, `check your browser` | JS 챌린지 | | 빈 SPA | `<div id="root"></div>` 외 콘텐츠 없음, 200자 미만 | JS 렌더링 필요 | | Redirect loop | 3회 이상 302/307 | 챌린지 리다이렉트 | **login/paywall 감지 시**: `login`, `sign in`, `로그인`, `subscribe`, `구독` 집중 → Phase 2/3으로 올려도 해결 안 됨. **"인증 필요"로 종료.** --- ## Phase 2: TLS 임퍼소네이션 (curl_cffi) **조건**: Phase 1에서 WAF/봇 차단 신호 감지 **의존성 확보**: ```bash python3 -c "import curl_cffi" 2>/dev/null || pip install curl_cffi -q ``` 설치 실패 시 → 즉시 Phase 3으로. **다중 타겟 순차 시도**: safari → chrome → firefox ```python from curl_cffi import requests TARGETS = ["safari", "chrome", "firefox"] HEADERS = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8", "Referer": "https://www.google.com/", } for target in TARGETS: try: session = requests.Session(impersonate=target) session.headers.update(HEADERS) resp = session.get("{URL}", timeout=20) if resp.status_code == 200 and len(resp.text) > 300: # 성공 — JSON-LD도 같이 추출 break except: continue ``` 성공 → 종료 / 실패 또는 JS 챌린지 → Phase 3 상세: [tls-impersonate.md](tls-impersonate.md) --- ## Phase 3: Playwright MCP (브라우저) **조건**: Phase 2도 실패 또는 JS 챌린지/CAPTCHA 감지 ``` browser_navigate → {URL} browser_wait_for → "body" (3초) browser_evaluate → () => document.body.innerText (Light Mode — 먼저) 필요 시 browser_snapshot → 접근성 트리 전체 ``` **API 발견**: `browser_network_requests`로 숨은 JSON API를 찾으면 이후 curl_cffi로 재사용 가능. 상세: [playwright.md](playwright.md) --- ## 응답 검증 | 판정 | 조건 | 결과 | |------|------|------| | **성공** | 콘텐츠 타입에 맞는 분량 + 주제 관련 키워드 | 채택 | | **부분 성공** | OG 메타/JSON-LD만 (본문 없음) | 보조 소스 | | **실패 — 인증** | login/paywall 감지 | "인증 필요"로 종료 | | **실패 — 챌린지** | CAPTCHA/JS challenge | 다음 Phase로 | | **실패 — 에러** | 4xx/5xx | 다음 Phase로 | | **실패 — 빈 SPA** | 콘텐츠 없음 | 다음 Phase로 | **콘텐츠 분량 기준** (유연하게): - 기사/블로그: 500자 이상 - 상품 페이지: JSON-LD 있으면 성공 - 트윗/짧은 글: 100자 이상 - 프로필: JSON-LD Person 있으면 성공 ## False-Positive 마커 (HTTP 200이지만 실패) | 패턴 | 감지 방법 | 처리 | |------|----------|------| | X SPA 셸 (247KB) | 200 OK + `Sign in to X` 또는 `hasResults: false` | 실패 — 웹 검색 도구+oEmbed 폴백 | | CAPTCHA 페이지 | 200 OK + `captcha\|recaptcha\|hcaptcha\|cf-turnstile` | 실패 — 다음 Phase | | 소프트 페이월 | 200 OK + `member-only\|subscribe to read\|구독하세요` | 부분 성공 — 메타만 채택 | | DDG 소프트 리밋 | 202 Accepted + body 15KB 미만 | 실패 — 다른 엔진 폴백 | | 빈 JSON | 200 OK + `hasResults.*false\|"entries":\s*\[\]` | 실패 — 다른 방법 시도 | | 지역 차단 | 200 OK + `not available in your region\|geo-restricted` | 실패 — "지역 차단" 알림 | | WAF 소프트 블록 | 200 OK + `checking your browser\|verify you are human` | 실패 — Phase 2/3 에스컬레이션 | | Akamai behavioral | 200 OK + `behavioral-content\|sec-if-cpt` + `_abck` 쿠키 | 실패 — JS 실행 필수 → Phase 3 직행 (TLS 타겟 변경 무의미) | | RSS Content-Type 오류 | RSS 기대 + `text/html` 응답 | 실패 — "RSS 미지원" | | 에러 JSON | 200 OK + JSON `"error"` 키 존재 | 실패 — 에러 내용 로깅 | ## 전부 실패 시 1. 시도한 Phase와 각 실패 신호를 기록 2. 사이드카 결과가 있으면 provenance 태깅하여 채택 3. 사이드카도 없으면 사용자에게 실패 보고 + 시도 결과 공유 -
jina.md 3.8 KB
# 범용 웹 추출 — Jina Reader > `r.jina.ai/URL` 한 줄로 거의 모든 공개 URL을 마크다운으로 변환. > Puppeteer 기반 실제 브라우저 렌더링 — JS SPA까지 처리. > > **2026-08-09 probe 기준 무료 무키 경로는 종료됨.** 익명 호출은 401이며, > 리다이렉트를 따라가면 Cloudflare Turnstile(`Just a moment...`)에 막힌다. > **이제 `JINA_API_KEY` 환경 변수가 필요**하다 — `Authorization: Bearer <key>` 헤더. > 엔진에서는 `engine/surrogates.yaml`의 `jina_reader` 엔트리가 키가 있을 때만 활성화된다 > (kind=reader, provenance=live — 서버 측 재렌더링). > 예전 "무료 500 RPM" 안내는 모두 폐기되었으므로 따르지 않는다. ## 기본 사용 ```bash curl -s -H "Authorization: Bearer ${JINA_API_KEY}" "https://r.jina.ai/{URL}" ``` ## 고급 기능 ### JSON 구조화 출력 ```bash curl -H "Accept: application/json" "https://r.jina.ai/{URL}" ``` 반환: `data.{title, description, url, content, metadata, external, usage}` **핵심**: `external.alternate`에서 사이트의 **RSS URL을 자동 발견** 가능. ### CSS 선택자 타겟팅 ```bash curl -H "X-Target-Selector: .article-body" "https://r.jina.ai/{URL}" ``` 네비게이션/풋터 제거, 본문만 추출. 커뮤니티 게시판에서 특히 효과적. ### SPA 스트리밍 모드 ```bash curl -H "Accept: text/event-stream" "https://r.jina.ai/{URL}" ``` JS 로딩 완료까지 대기. 동적 콘텐츠가 완전히 렌더링된 최종 버전 반환. ### 스크린샷 ```bash curl -H "X-Respond-With: screenshot" "https://r.jina.ai/{URL}" ``` GCS 서명 URL 반환 (4시간 유효). 비주얼 검증 용도. ### PDF 처리 ```bash curl -s "https://r.jina.ai/https://example.com/file.pdf" ``` PDF → 마크다운 자동 변환. 페이지 수 메타데이터 포함. ### 쿠키 전달 (인증 사이트) ```bash curl -H "X-Set-Cookie: session=abc123" "https://r.jina.ai/{URL}" ``` ### 링크 보존 ```bash curl -H "X-With-Links: true" "https://r.jina.ai/{URL}" ``` ### 캐시 제어 ```bash # 캐시 우회 (실시간 필요 시) curl -H "X-No-Cache: true" "https://r.jina.ai/{URL}" # 캐시 TTL 지정 (초) curl -H "X-Cache-Tolerance: 600" "https://r.jina.ai/{URL}" ``` ### 순수 텍스트 / 원본 HTML ```bash # body.innerText만 curl -H "X-Respond-With: text" "https://r.jina.ai/{URL}" # 원본 HTML curl -H "X-Respond-With: html" "https://r.jina.ai/{URL}" ``` ## 검증된 성공 사이트 | 사이트 | 결과 | 비고 | |--------|------|------| | Threads | 성공 | 프로필 + 포스트 | | 클리앙 | 성공 | 게시글 목록 + 본문 | | 루리웹 | 성공 | 게시글 목록 + 본문 | | 뽐뿌 | 성공 | 게시글 + RSS도 가능 | | 네이버 뉴스 | 성공 | 기사 목록 + 본문 완전 | | 네이버 증권 | 성공 | 실시간 주가 | | 긱뉴스 | 성공 | 토픽 목록 + 본문 | | 44bits | 성공 | 기사 목록 | | 커리어리 | 성공 | JS 렌더링으로 추출 | | 브런치 | 성공 | 기사 전문 | | 한경 | 성공 | 뉴스 기사 | | 다음 뉴스 | 성공 | 뉴스 기사 | | Medium | 성공 | 기사 전문 (paywall 제외) | | Substack | 성공 | 뉴스레터 전문 | | dev.to | 성공 | 기사 전문 | | PDF (모든 URL) | 성공 | 자동 변환 | ## 실패하는 사이트 | 사이트 | 이유 | |--------|------| | X/Twitter | 402 — Syndication/oEmbed 사용 (twitter.md 참조) | | Reddit | 차단 — JSON API 사용 (json-api.md 참조) | | 디시인사이드 | 빈 본문 반환 | | 에펨코리아 | HTTP 430 | | 요즘IT | CloudFront 403 | | 네이버 쇼핑 | CAPTCHA | | 쿠팡 | WAF 차단 | ## RSS 자동 발견 Jina JSON 모드의 `external.alternate`에서 사이트의 RSS URL이 자동 노출됨: ```bash curl -H "Accept: application/json" "https://r.jina.ai/{URL}" | \ python3 -c "import sys,json; print(json.load(sys.stdin)['data'].get('external',{}))" ``` -
json-api.md 3.5 KB
# JSON API 직접 호출 > URL 변형이나 공개 엔드포인트로 구조화된 JSON을 직접 가져오는 패턴. > 인증 불필요. Jina Reader보다 빠르고 정확한 구조화 데이터 획득. ## Reddit **Mobile User-Agent 필수** (없으면 403/429). ```bash UA="Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" # 서브레딧 핫 포스트 curl -sL -H "User-Agent: $UA" "https://www.reddit.com/r/{subreddit}/hot.json?limit=10" # 검색 curl -sL -H "User-Agent: $UA" "https://www.reddit.com/r/{subreddit}/search.json?q={query}&restrict_sr=1" # 포스트 + 댓글 curl -sL -H "User-Agent: $UA" "https://www.reddit.com/r/{subreddit}/comments/{post_id}/{slug}/.json" # 정렬: hot.json / new.json / top.json?t=week ``` 데이터: `title`, `author`, `score`, `selftext`(전문), `num_comments`, `created_utc` 댓글: 응답 `[1]` 배열에 재귀적 트리 ## Hacker News (Firebase API) Rate limit 사실상 없음. ```bash # 탑 스토리 ID 목록 curl -sL "https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy=%22%24key%22" # 개별 아이템 curl -sL "https://hacker-news.firebaseio.com/v0/item/{id}.json" # 변형: beststories / newstories / askstories / showstories ``` 데이터: `title`, `url`, `score`, `by`(작성자), `descendants`(댓글수), `kids`(댓글 ID) 배치 조회: ```bash python3 -c " import urllib.request, json ids = json.load(urllib.request.urlopen('https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=5&orderBy=\"\$key\"')) for id in ids: item = json.load(urllib.request.urlopen(f'https://hacker-news.firebaseio.com/v0/item/{id}.json')) print(f'[{item.get(\"score\",0)}] {item.get(\"title\")}') print(f' {item.get(\"url\",\"N/A\")[:60]}') " ``` ## Lobste.rs Rate limit 없음. HN보다 작지만 고품질 큐레이션. ```bash # 핫 스토리 curl -sL "https://lobste.rs/hottest.json" # 태그별 (ai, programming, web, security 등) curl -sL "https://lobste.rs/t/ai.json" # 최신 curl -sL "https://lobste.rs/newest.json" # 개별 스토리 + 댓글 curl -sL "https://lobste.rs/s/{short_id}.json" ``` 데이터: `title`, `url`, `score`, `comment_count`, `tags`, `submitter_user` ## dev.to ```bash # 태그별 최신 curl -sL "https://dev.to/api/articles?tag=ai&per_page=5" # 이번 주 탑 curl -sL "https://dev.to/api/articles?top=7&per_page=5" # 특정 유저 curl -sL "https://dev.to/api/articles?username={user}&per_page=5" ``` 데이터: `title`, `user.name`, `public_reactions_count`, `reading_time_minutes`, `tags` ## npm Registry ```bash # 패키지 최신 버전 curl -sL "https://registry.npmjs.org/{package}/latest" # 패키지 검색 curl -sL "https://registry.npmjs.org/-/v1/search?text={query}&size=5" # 다운로드 통계 curl -sL "https://api.npmjs.org/downloads/range/last-month/{package}" ``` ## PyPI ```bash # 패키지 정보 curl -sL "https://pypi.org/pypi/{package}/json" # 다운로드 통계 curl -sL "https://pypistats.org/api/packages/{package}/recent" ``` ## Wikipedia ```bash # 페이지 요약 curl -sL "https://en.wikipedia.org/api/rest_v1/page/summary/{title}" # 한국어: https://ko.wikipedia.org/api/rest_v1/page/summary/{title} # 검색 curl -sL "https://en.wikipedia.org/w/api.php?action=opensearch&search={query}&limit=5&format=json" ``` ## V2EX ```bash curl -sL "https://www.v2ex.com/api/topics/hot.json" -H "User-Agent: insane-search/1.0" ``` ## RSS 피드 → [rss.md](rss.md)로 이동. 한국 언론 RSS, Google News RSS, feedparser 사용법 등 상세 가이드 참조. -
media.md 3.5 KB
# 미디어 추출 — yt-dlp > yt-dlp는 YouTube 전용 도구가 아니라 **1,858개 사이트**를 지원하는 범용 미디어 추출 도구. > 영상, 오디오, 팟캐스트, 라이브 스트리밍 — 미디어 URL이면 yt-dlp를 먼저 시도한다. ## 설치 확인 ```bash which yt-dlp || python3 -m yt_dlp --version ``` - `yt-dlp` 명령어가 PATH에 있으면 그대로 사용 - 없으면 `python3 -m yt_dlp`로 대체 (아래 모든 명령어에서 치환) - 미설치 시: `pip install yt-dlp` ## 핵심 명령어 (모든 지원 사이트 공통) ### 메타데이터 추출 (가장 범용) ```bash yt-dlp --dump-json "URL" ``` title, uploader, duration, view_count, description, tags 등 구조화 JSON 반환. 전용 extractor가 있는 사이트에서 ~95% 성공. ### 자막 추출 ```bash yt-dlp --write-sub --write-auto-sub --sub-lang "en,ko" --skip-download -o "/tmp/%(id)s" "URL" cat /tmp/VIDEO_ID.*.vtt ``` YouTube는 100개 언어 자동자막 지원. 다른 사이트는 자체 자막 제공 시에만 동작. ### 검색 ```bash # YouTube yt-dlp --dump-json "ytsearch5:{검색어}" # SoundCloud yt-dlp --dump-json "scsearch5:{검색어}" # Dailymotion yt-dlp --dump-json "dailymotionsearch5:{검색어}" # Yahoo yt-dlp --dump-json "yahoosearch5:{검색어}" ``` ### 채널/플레이리스트 목록 (다운로드 없이) ```bash yt-dlp --flat-playlist --dump-json "채널_URL" ``` title, id, url, duration 반환. 채널 전체 영상 목록을 초고속 수집. ### 댓글 추출 (YouTube) ```bash yt-dlp --write-comments --skip-download --write-info-json \ --extractor-args "youtube:max_comments=20" \ -o "/tmp/%(id)s" "URL" ``` ## 지원 플랫폼 카테고리 ### 영상 | 사이트 | 메타데이터 | 자막 | 검색 | 비고 | |--------|----------|------|------|------| | YouTube | O | O (자동생성 포함) | `ytsearch` | 최고 지원 | | Vimeo | O | O (사이트 제공 시) | X | 학술/다큐 콘텐츠 풍부 | | Twitch | O (VOD/클립) | X | X | 기술 스트리밍 | | TikTok | O | X | X | 공개 계정만 | | Dailymotion | O | O | `dailymotionsearch` | | | Rumble | O | X | X | | | PeerTube | O | X | X | 탈중앙화 | ### 오디오/팟캐스트 | 사이트 | 메타데이터 | 검색 | 비고 | |--------|----------|------|------| | SoundCloud | O | `scsearch` | 검색까지 가능 — 최고 | | Apple Podcasts | O | X | RSS 기반 | | TuneIn | O | X | | | acast | O | X | 채널 단위 지원 | | Spreaker | O | X | | | Audius | O | X | 블록체인 기반 | ### 한국 플랫폼 | 사이트 | Extractor | 비고 | |--------|-----------|------| | Naver TV | `Naver`, `Naver:live` | | | Kakao | `Kakao` | | | SBS | `SBS`, `sbs.co.kr` | | | JTBC | `JTBC`, `JTBC:program` | | | Chzzk | `chzzk:video`, `chzzk:live` | 네이버 스트리밍 | | Soop (구 AfreecaTV) | `soop`, `soop:live` | | | Daum | `daum.net`, `daum.net:clip` | | | Weverse | `Weverse`, `WeverseLive` | K-팝 팬덤 | ### 뉴스 VOD | 사이트 | 비고 | |--------|------| | BBC | 공개 VOD | | ABC (호주) | iview | | CBS News | | | NBC News | 차단 많음 | > 뉴스 사이트는 직접 URL보다 **YouTube 공식 채널 경유**가 더 안정적. > 예: `ytsearch:BBC News {키워드}` ## 주의사항 - 자동 생성 자막은 행간 중복 → 후처리 필요 - generic extractor는 성공률 ~30% — 전용 extractor 있는 사이트 우선 - 페이월/로그인 사이트는 대부분 실패 - `--dump-json`이 가장 안전한 범용 명령 (다운로드 없음, 메타데이터만) -
metadata.md 2.9 KB
# 메타데이터 추출 — OGP / JSON-LD / Schema.org > HTML을 받았을 때 구조화된 데이터를 추출하는 보조 기법. > 본문 전체를 못 가져와도 제목, 요약, 가격, 프로필 등 핵심 정보를 확보할 수 있다. ## 의존성 없음 (curl + python3 기본 모듈). ## OGP (Open Graph Protocol) 메타태그 대부분의 사이트가 소셜 공유용으로 삽입. 제목 + 설명 + 이미지 확보 가능. ```bash curl -sL -H "User-Agent: Mozilla/5.0 ..." "{URL}" | \ python3 -c " import sys, re html = sys.stdin.read() for m in re.findall(r'<meta property=\"og:(\w+)\" content=\"([^\"]*?)\"', html): print(f'og:{m[0]} = {m[1]}') for m in re.findall(r'<meta name=\"description\" content=\"([^\"]*?)\"', html): print(f'description = {m}') " ``` ## JSON-LD (Schema.org 구조화 데이터) **가장 가치 높은 추출 대상.** 상품, 기사, 프로필 등 구조화된 정보가 JSON으로 들어있다. ```bash curl -sL "{URL}" | \ python3 -c " import sys, re, json html = sys.stdin.read() blocks = re.findall(r'<script type=\"application/ld\+json\">(.*?)</script>', html, re.DOTALL) for b in blocks: try: data = json.loads(b) print(json.dumps(data, ensure_ascii=False, indent=2)) except: pass " ``` ### 실제 사례 **쿠팡 검색 결과** — `CollectionPage` + `ItemList`: ```json { "@type": "CollectionPage", "mainEntity": { "@type": "ItemList", "itemListElement": [ { "@type": "ListItem", "item": { "@type": "Product", "name": "...", "offers": { "price": 29900 } } } ] } } ``` **LinkedIn 프로필** — `Person`: ```json { "@type": "Person", "name": "...", "jobTitle": "...", "alumniOf": [ { "@type": "Organization", "name": "..." } ] } ``` **뉴스 기사** — `NewsArticle`: ```json { "@type": "NewsArticle", "headline": "...", "datePublished": "2026-04-16", "author": { "name": "..." }, "articleBody": "..." } ``` ## Next.js RSC 페이로드 (요즘IT 등) Next.js App Router 사이트는 `self.__next_f.push()` 스크립트에 콘텐츠가 포함됨. ```bash curl -sL "{URL}" | \ python3 -c " import sys, re html = sys.stdin.read() chunks = re.findall(r'self\.__next_f\.push\(\[1,\"(.*?)\"\]\)', html) text = ''.join(chunks) # 한국어 텍스트 추출 (유니코드 이스케이프 디코딩) decoded = text.encode().decode('unicode_escape', errors='ignore') print(decoded[:3000]) " ``` ## 활용 시점 메타데이터 추출은 **독립 방법이 아니라 보조 기법**이다. 어떤 Phase에서든 HTML을 받으면 같이 실행: - Phase 1에서 curl로 HTML 받음 → JSON-LD도 추출 - Phase 2에서 curl_cffi로 HTML 받음 → JSON-LD도 추출 - Phase 3에서 Playwright로 DOM 받음 → `browser_evaluate`로 JSON-LD 추출 본문은 못 가져와도 JSON-LD에서 **상품 가격, 기사 요약, 프로필 정보**는 확보될 수 있다. -
naver.md 3.3 KB
# 네이버 계열 접근 전략 > 네이버 서비스별로 접근 방법이 다르다. 블로그는 모바일 URL, 뉴스/증권은 Jina Reader. ## 네이버 블로그 WebFetch 차단. 모바일 URL 변환 + iPhone UA로 접근. ```bash # blog.naver.com/{ID}/{NO} → m.blog.naver.com 변환 curl -sL \ -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" \ -H "Accept-Language: ko-KR,ko;q=0.9" \ -H "Referer: https://m.naver.com/" \ "https://m.blog.naver.com/PostView.naver?blogId={ID}&logNo={NO}" ``` RSS도 가능 (최신 50개, 본문 약 300자): ```bash curl -sL "https://rss.blog.naver.com/{BLOG_ID}.xml" ``` ## 네이버 뉴스 Jina Reader로 완전 접근 가능. ```bash # 기사 목록 curl -s "https://r.jina.ai/https://news.naver.com/" # 개별 기사 curl -s "https://r.jina.ai/https://n.news.naver.com/article/{press_id}/{article_id}" ``` ## 네이버 증권 Jina Reader로 실시간 주가, 주요 뉴스 접근. ```bash curl -s "https://r.jina.ai/https://finance.naver.com/item/main.naver?code={종목코드}" ``` ## 네이버 금융 시세 (비공식, 무인증) 인증 불필요. 주가 시계열 데이터 JSON 반환. ```bash # 일봉 시세 (삼성전자=005930) curl -sL "https://api.finance.naver.com/siseJson.naver?symbol=005930&requestType=1&startTime=20240101&endTime=20241231&timeframe=day" # 분봉 curl -sL "https://api.finance.naver.com/siseJson.naver?symbol=005930&requestType=0&timeframe=minute&count=200" ``` 응답: `[[날짜, 시가, 고가, 저가, 종가, 거래량, 외국인거래율], ...]` ## 네이버 검색 (신원위장으로 직접 접근) curl_cffi + 세션 쿠키 워밍으로 네이버 검색 결과를 직접 크롤링할 수 있다. API 키 불필요. ```python from curl_cffi import requests from urllib.parse import quote s = requests.Session(impersonate="chrome124") s.headers.update({ "Accept-Language": "ko-KR,ko;q=0.9", "Referer": "https://www.google.com/", }) s.get("https://www.naver.com/", timeout=10) # 쿠키 워밍 s.headers["Referer"] = "https://www.naver.com/" # 통합 검색 (블로그+뉴스+웹 혼합) r = s.get(f"https://search.naver.com/search.naver?query={quote('검색어')}") # 블로그 탭 r = s.get(f"https://search.naver.com/search.naver?where=post&query={quote('검색어')}") # 뉴스 탭 r = s.get(f"https://search.naver.com/search.naver?where=news&query={quote('검색어')}") ``` ### 추출 가능한 데이터 | 탭 | URL 패턴 | 추출 | |---|---|---| | 통합 | `search.naver?query=` | 블로그 URL, 외부 링크, 뉴스 | | 블로그 | `where=post&query=` | blog.naver.com URL, 제목, 스니펫 | | 뉴스 | `where=news&query=` | n.news.naver.com URL, 제목 | ### 한국어 키워드 검색의 핵심 경로 웹 검색 도구는 한국어 신규 콘텐츠 인덱싱이 지연되지만, 네이버 검색은 한국어에 최적화되어 있다. **한국 사이트 키워드 검색 → 네이버 검색 직접 접근이 가장 정확하고 빠르다.** ## 네이버 카페 로그인 + iframe 이중 장벽. 본문 직접 접근 불가. fallback 체인에서 Phase 1~3을 시도하되, login/paywall 감지 시 "인증 필요"로 종료. ## 네이버 TV yt-dlp로 접근 (media.md 참조). ```bash yt-dlp --dump-json "https://tv.naver.com/v/{video_id}" ``` -
playwright.md 6.7 KB
# Playwright — MCP vs Local Chrome > JS 렌더링 / JS 챌린지 사이트를 위한 두 가지 접근. **WAF 프로파일의 > `capabilities_needed` 태그가 선택을 결정**한다. 사용자가 직접 고를 필요 없다. ## 두 Approach 요약 | Approach | 실행기 | TLS 스택 | 적합 WAF | 한계 | |----------|--------|----------|----------|------| | **1. MCP** | `mcp__playwright__*` 도구 | Playwright 번들 Chromium (BoringSSL) | Cloudflare 기본, CAPTCHA 없는 SPA, JS 챌린지 약한 사이트 | Akamai Bot Manager 등 TLS-감지형 WAF에 **즉시 탐지됨** (`channel` 옵션 없음) | | **2. Local Node + `channel:'chrome'`** | `engine/templates/playwright_real_chrome.js` | 시스템 설치 실제 Chrome | Akamai Bot Manager, PerimeterX, DataDome 강화 설정 | Node + Chrome 시스템 설치 필요 | `engine/executor.py`가 프로파일 태그를 보고 자동 라우팅하므로, 이 선택을 스킬 외부에서 의식할 필요는 없다. ## Approach 1 — Playwright MCP ### 의존성 이 항목은 이미 연결된 MCP를 사용하는 엔진 어댑터의 설명이다. 에이전트가 직접 여는 브라우저 세션은 js eval의 omowright(`browser` 스킬에 스테이징)로 띄운다: 직접 소유 브라우저는 `connectPipe`, 스텔스는 `connectCloakProfile`, 사용자 로그인이 필요하면 `connectBrowserSkill`. MCP를 새로 설치하지 않는다. ### 기본 워크플로 ``` 1. browser_navigate → URL 2. browser_wait_for → 메인 콘텐츠 셀렉터 (SPA는 필수) 3. browser_snapshot (접근성 트리 — 토큰 효율) 또는 browser_evaluate (특정 셀렉터 데이터 추출) 또는 browser_run_code (스크롤/페이지네이션) ``` ### 도구별 용도 | 도구 | 용도 | |------|------| | `browser_snapshot` | 접근성 트리 반환 — 텍스트+인터랙티브 요소 구조화. 가장 빠르고 토큰 효율적 | | `browser_evaluate` | `() => document.querySelector(...).innerText` 등 JS 평가 | | `browser_run_code` | `async ({ page }) => {...}` 풀 자동화 — 무한 스크롤, 다단계 인터랙션 | | `browser_network_requests` | XHR/fetch 호출 목록 — **WAF 뒤 진짜 API 엔드포인트 발견용** (→ curl_cffi로 직접 호출) | | `browser_console_messages` | JS 에러/로그 | ### 주의 - MCP는 Chromium 번들 기반. TLS 지문이 BoringSSL이라 Akamai/DataDome은 **293 바이트 Access Denied** 또는 즉시 403 반환. - 이 경우 자동으로 Approach 2로 이관되도록 `engine/executor.py`가 처리. 수동 선택 불필요. ## Approach 2 — Local Node + Real Chrome ### 의존성 (최초 1회) 엔진 템플릿의 스크립트 의존성은 `engine/templates/package.json`에 고정되어 있다. 엔진 디렉터리에서 한 번만 설치한다. Chrome은 이미 시스템에 설치되어 있어야 하며, 브라우저 다운로드 명령은 없다. ```bash cd "$SKILL_DIR/engine" test -f package.json || cp templates/package.json package.json bun install ``` ### 호출 (engine 내부) ```python from insane_search.engine.executor import run_playwright_fallback attempt, html = run_playwright_fallback( "https://example.com/path", profile_id="akamai_bot_manager", success_selectors=["article"], device_class="desktop", # "desktop" | "mobile" | "auto" ) ``` 내부에서 `engine/templates/playwright_real_chrome.js` 또는 `playwright_mobile_chrome.js`를 Node로 실행하고 HTML을 받아온다. 템플릿은 **URL과 셀렉터 파라미터만** 받으며 사이트별 분기가 없다. 템플릿은 엔진이 실행하는 프로그램이다. 에이전트가 이 템플릿을 본떠 브라우저 스크립트를 새로 쓰지 않는다. ### 데스크톱 템플릿 (`playwright_real_chrome.js`) - 번들 Chromium이 아니라 **시스템에 설치된 실제 Chrome**을 띄운다. TLS 지문이 실제 Chrome이 되는 것이 핵심이다. - stealth 플러그인을 적용하고, 작업 전용 영속 프로필 디렉터리를 쓴다. - Akamai는 headless를 탐지하므로 **headful**로 실행하고, 뷰포트는 1366×900이다. ### 모바일 템플릿 (`playwright_mobile_chrome.js`) - TLS는 데스크톱과 같은 실제 Chrome이고, iPhone 13 Pro 디바이스 기술자(UA/viewport/isMobile/hasTouch)만 주입한다. headful로 실행한다. **주의**: 실제 Chrome + 모바일 디바이스 기술자 조합은 TLS 핑거프린트를 Chrome으로 유지하면서 HTTP 레이어(UA/viewport)만 모바일로 바꾼다. WAF가 실제 Chrome으로 인식해서 관대한 경우가 많다. ### 엔진 밖에서 같은 효과가 필요할 때 엔진 폴백이 아니라 에이전트가 직접 페이지를 조작해야 한다면 js eval에서 omowright(`browser` 스킬에 스테이징)를 쓴다. 지문이 고정된 스텔스 브라우저는 `connectCloakProfile({ profileDir })`, 모바일 뷰는 `emulate(page, "iphone-14")`, 사용자 로그인이 필요하면 `connectBrowserSkill()`이다. ## 선택 규칙 (자동) `engine/waf_profiles.yaml`의 `capabilities_needed` 태그가 결정한다: | 태그 조합 | 선택 실행기 | 대표 케이스 | |----------|-------------|-------------| | `needs_real_tls_stack` + `needs_js_exec` | Approach 2 (real_chrome) | Akamai Bot Manager | | `needs_js_exec` only | Approach 1 (MCP) | Cloudflare Turnstile | | `needs_real_tls_stack` only | Approach 2 (real_chrome) | 일부 DataDome 설정 | | 둘 다 없음 | curl 체인에서 해결. Playwright 안 씀 | F5 BIG-IP (TLS만 우회 필요) | `device_class="mobile"`이 지정되면 real_chrome → mobile 변종으로 swap. ## 공통 검증 두 Approach 모두 최종 HTML을 `engine/validators.py:validate()`로 재검증한다. 즉 Playwright가 HTML을 받아와도 **챌린지 페이지 또는 빈 SPA면 여전히 CHALLENGE 판정**. 자동으로 다음 조합이나 failure 보고로 이어진다. ## 디버깅 팁 - 템플릿의 `profileDir`는 작업 전용 경로만 사용한다. 사용자의 실제 프로필을 실행·복제·초기화·삭제하지 않는다. - 작업 종료 시 브라우저를 닫고 작업 전용 프로필만 정리한다. - 실패 시 `result.trace`의 `error` 필드에 Node stderr 200자가 포함됨 ## 사이트 예시 (독자 이해용, 코드 분기 근거 아님) > 이 섹션은 **설명 목적**이며 `engine/**` 코드에는 반영되지 않는다. - **Cloudflare 기본 챌린지**: Approach 1 (MCP) 충분 - **Akamai Bot Manager**: Approach 2 필수. MCP로는 TLS-UA 불일치 탐지됨 - **SSR 블로그 플랫폼**: curl_cffi safari만으로 HTML 수신. Playwright 불필요 - **검색 결과 JS 렌더링 SPA**: Approach 1로 `browser_wait_for` 후 `browser_snapshot` 실제 라우팅은 프로파일 태그가 결정한다. 위 예시는 참고일 뿐 코드 분기 근거로 쓰지 않는다. -
public-api.md 3.4 KB
# 공개 API 직접 호출 > 인증 없이 구조화된 데이터를 반환하는 공개 API. > 웹 크롤링이 아니라 공식 API — 안정적이고 정확. ## Bluesky (AT Protocol) 프로필과 피드는 완전 공개. 검색은 403 차단. ```bash # 프로필 curl -sL "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={handle}" | \ python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{d[\"displayName\"]} — Followers: {d[\"followersCount\"]}, Posts: {d[\"postsCount\"]}')" # 피드 (최근 게시물) curl -sL "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor={handle}&limit=10" ``` Rate limit: ~3,000 req/hour ## Mastodon 인스턴스별 상이. mastodon.social은 공개 타임라인 차단, hachyderm.io/fosstodon.org 등은 허용. ```bash # 계정 조회 curl -sL "https://{instance}/api/v1/accounts/lookup?acct={username}" # 계정 타임라인 (ID 획득 후) curl -sL "https://{instance}/api/v1/accounts/{id}/statuses?limit=10" # 해시태그 타임라인 (인스턴스에 따라 인증 필요) curl -sL "https://hachyderm.io/api/v1/timelines/tag/{tag}?limit=10" ``` ## Stack Exchange (v2.3) ```bash # 질문 검색 curl -sL "https://api.stackexchange.com/2.3/search?order=desc&sort=votes&intitle={query}&site=stackoverflow" # 태그 기반 curl -sL "https://api.stackexchange.com/2.3/questions?tagged={tag1};{tag2}&site=stackoverflow&pagesize=5" # 답변 포함 (본문 필요 시) curl -sL "https://api.stackexchange.com/2.3/questions/{id}/answers?order=desc&sort=votes&site=stackoverflow&filter=withbody" ``` Rate limit: 비인증 300 req/day (IP당) ## arXiv (학술 논문) ```bash # 논문 검색 (ti=제목, au=저자, abs=초록, cat=카테고리) curl -sL "http://export.arxiv.org/api/query?search_query=ti:{query}&max_results=5&sortBy=submittedDate&sortOrder=descending" ``` **주의**: 3 req/second 제한. 요청 간 1초 sleep 필수. 카테고리: cs.AI, cs.CL, cs.LG, cs.CV 등 ## CrossRef (DOI / 피어리뷰 논문) ```bash # 논문 검색 curl -sL "https://api.crossref.org/works?query={query}&filter=from-pub-date:2025-01&rows=5&sort=relevance" # DOI로 조회 curl -sL "https://api.crossref.org/works/{DOI}" ``` Rate limit: 50 req/second. User-Agent에 이메일 추가 시 Polite Pool 진입. ## OpenLibrary (도서) ```bash # ISBN 조회 curl -sL "https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&jscmd=data&format=json" # 도서 검색 curl -sL "https://openlibrary.org/search.json?q={query}&limit=5" ``` ## Wayback Machine (아카이브) ```bash # 스냅샷 확인 curl -sL "https://archive.org/wayback/available?url={URL}" # CDX API (스냅샷 목록) curl -sL "https://web.archive.org/cdx/search/cdx?url={URL}&output=json&fl=timestamp,statuscode&limit=5" ``` ## GitHub REST API (gh CLI 없을 때) 비인증 60 req/hour. gh CLI 우선 사용 권장. ```bash # 저장소 검색 curl -sL "https://api.github.com/search/repositories?q={query}&sort=stars&per_page=5" # 릴리즈 curl -sL "https://api.github.com/repos/{owner}/{repo}/releases?per_page=5" # 코드 검색 curl -sL "https://api.github.com/search/code?q={query}+language:python&per_page=5" ``` ## Rate Limit 요약 | API | 비인증 | 비고 | |-----|-------|------| | Bluesky | ~3K/hr | 검색 403 | | Mastodon | 인스턴스별 | | | Stack Exchange | 300/day | 인증 시 10K | | arXiv | 3/sec | sleep 필수 | | CrossRef | 50/sec | | | OpenLibrary | 무제한 | | | Wayback | 무제한 | | | GitHub REST | 60/hr | gh CLI 우선 | -
README.md 19.5 KB
# Insane Search > Part of the **ultimate-browsing** skill (Tier 1). Routing and tier-escalation live in [../../SKILL.md](../../SKILL.md). > The engine package it drives is at [../../engine/](../../engine/), invoked from the skill directory as `python3 -m engine "<URL>"`. > Deep-dives in this folder: TLS impersonation, Playwright routing, fallback, metadata, Jina, cache/archive, RSS, JSON/public APIs, Twitter, Naver, media. > URL 접근이 차단될 때, **사이트 무관한** 우회 전략을 자동 선택한다. ## 하네스 규칙 (Claude에게 강제되는 지침) 이 규칙은 Claude가 즉흥 판단으로 엇나가지 못하게 하기 위한 **고삐**다. 위반 시 이전 test.md 세션처럼 "chrome 200에서 break → safari 미시도 → Playwright 미설치라 포기" 식의 오판이 재현된다. **R1 — 일반 웹 URL 차단/403/402 감지 시**: 1. WebFetch, 즉흥 curl, 수동 헤더 조합 **시도 금지** 2. 즉시 다음을 실행: ```bash python3 -m engine "<URL>" [--selector "<CSS>"] [--device auto|desktop|mobile] [--trace] \ [--no-retry] [--no-extract] ``` 3. 종료코드 0(ok) 또는 1(fail) 받은 뒤 판단. trace를 먼저 읽고 재시도 결정. 4. 실패 시에만 `--trace --json`으로 재호출해서 원인 진단 후 `--device` 또는 `user_hint` 조정. **R2 — 첫 200에서 탈출 금지**: HTTP 200은 **검사 시작 조건**이지 성공이 아니다. `validate()`의 4-계층 검증을 통과해야 성공 선언. CLI는 이미 강제한다. **R3 — 편향 금지**: `engine/**`, `waf_profiles.yaml`에 특정 사이트 도메인·셀렉터·브랜드명 하드코딩 금지. `python3 engine/bias_check.py`가 CI 게이트. 자세한 규칙은 **No-Site-Name Rule** 섹션. **R4 — 힌트는 런타임에만**: 사이트 고유 정보(성공 셀렉터, 우선 Referer)는 CLI 인자 또는 `user_hint`로만 전달, 저장소에 고정 금지. **R5 — Phase 0 공식 API 우선**: X/Reddit/YouTube/HN/arXiv 등 **공식 공개 엔드포인트**가 있는 플랫폼은 Phase 0 테이블을 먼저 확인하고 해당 API를 쓴다. 이건 편향이 아니라 합의된 접근 경로. **R6 — 실패 선언은 전수 시도 후에만**: 격자(URL 변환 × TLS impersonate × Referer × Playwright fallback)를 **모두** 돌린 뒤에만 "뚫을 수 없음" 결론. CLI의 `max_attempts` 기본 12가 이를 보장. 단, R7 조건(WAF 조기 감지)이 성립하면 engine 격자는 계속 돌되, Claude가 **병렬로** MCP 정찰 루트를 시도할 수 있다. 빠른 쪽이 이긴다. **R7 — WAF 조기 감지 시 API-first 병행 분기** (분기 결정은 자동이지만 사용자가 결과에서 확인 가능 — 어떤 우회 경로로 성공/실패했는지 결과 metadata에 명시): 발동 조건 (AND): 1. engine 실행 초기에 첫 2~3회 attempt가 모두 `verdict=challenge` 2. `profile_used`가 `akamai_bot_manager`, `cloudflare_turnstile`, `datadome_probable`, `perimeterx_human`, `f5_big_ip`, `aws_waf` 중 하나로 확정 3. **사용자 요청이 리스트/수집/반복 의도** (여러 페이지, N개 이상, "전부", "크롤링", 페이지네이션 등). 단건 본문 조회는 해당 없음. 세 조건 모두 참일 때 Claude는 **병렬 경로**를 시작한다: **"병렬"의 실행 의미** (Claude 도구 호출이 순차이므로 명확화): - engine은 `run_in_background=true`로 Bash 툴에서 띄워둔다 — 격자는 그대로 돌되 블로킹하지 않음 - Claude는 그 사이 foreground에서 MCP Playwright 정찰 루트를 진행 - engine이 먼저 성공해도 좋고, MCP 정찰로 얻은 API가 먼저 성공해도 좋음. 빠른 쪽 결과 채택 **MCP 정찰 루트**: 1. `mcp__playwright__browser_navigate` → 대상 페이지 로드 (브라우저 렌더링) 2. `mcp__playwright__browser_network_requests` → XHR/fetch 호출 목록 수집, `/api/`·`/graphql`·`\.json` 필터로 내부 엔드포인트 식별 3. 식별된 JSON API URL을 `python3 -m engine <API_URL>`로 재호출 (백그라운드 engine과는 별개 호출). 대부분 API 레이어는 페이지 HTML보다 WAF 보호가 얕아 curl_cffi로 바로 수집됨 4. 응답 스키마 파악 후 pagination / query parameter 조합해 반복 수집 **왜**: SPA + WAF 사이트(쇼핑몰·커머스 다수)는 마케팅 페이지(HTML)만 WAF로 중투자하고 내부 API는 gateway 레벨 기본 방어만 쓰는 경우가 많다. HTML 격자 전수 낭비(50회 × 0.5s + Playwright fallback 40s ≈ 65초)보다 **MCP 정찰 1회(5~10초) + API 재호출(0.5초)**가 훨씬 경제적이고 성공률 높음. **R7을 쓰지 말아야 할 때**: 단일 페이지 본문 읽기만 필요한 단건 조회(문서 하나, 블로그 포스트 하나)는 engine만으로 충분하다 — 발동 조건 #3이 이를 배제한다. **R7 편향 방지**: 내부 API URL·파라미터는 `engine/**`에 하드코딩 금지. 탐지된 URL은 런타임 호출에만 쓰고 저장소에 고정하지 않는다. --- 이 스킬의 핵심 불변식: - **단일 진입점**: 일반 웹 페이지는 항상 `python3 -m engine <URL>` 또는 `from engine import fetch; fetch(...)`. - **편향 금지**: `engine/**`, `waf_profiles.yaml`에 특정 사이트 하드코딩 금지. - **힌트는 런타임에만**: 사이트 고유 정보는 CLI/`user_hint` 경유. ## 의도 분류 (Phase 0 진입 전) | 사용자 입력 | 경로 | |------------|------| | URL 제공 (`https://...`) | → Phase 0 검사 후 없으면 Phase 1 (generic fetch chain) | | 핸들 제공 (`@username`) | → Phase 0 syndication/API | | 키워드만 ("X에서 AI 검색") | → 웹 검색 도구(`site:{domain} {keyword}`) 먼저 → URL 확보 후 재진입 | > **한국어 신규 콘텐츠 한계**: 네이버/다음/한국 커뮤니티의 키워드 검색은 웹 검색 도구 경유가 유일하며, 신규 콘텐츠 인덱싱이 지연될 수 있다. ## Phase 0 — 플랫폼 공식 API 인덱스 > 플랫폼이 **공식 공개한** 전용 API/CLI만 여기에 둔다. 이건 편향이 아니라 합의된 엔드포인트 사용이다. ### 소셜/커뮤니티 전용 API | 플랫폼 | 방법 | 상세 | |--------|------|------| | X/Twitter | syndication (타임라인) + oEmbed (개별 트윗) + 키워드 검색: 웹 검색 도구 → oEmbed | [twitter.md](twitter.md) | | Reddit | URL + `.json` + Mobile UA | [json-api.md](json-api.md) | | Bluesky | AT Protocol (`public.api.bsky.app/xrpc/...`) | [public-api.md](public-api.md) | | Mastodon | 인스턴스별 공개 API | [public-api.md](public-api.md) | | Hacker News | Firebase API + Algolia Search | [json-api.md](json-api.md) | | Stack Overflow | SE API v2.3 | [public-api.md](public-api.md) | | Lobste.rs / V2EX / dev.to | 공개 JSON API | [json-api.md](json-api.md) | ### 미디어 (CLI 도구 필수) | 플랫폼 | 방법 | 상세 | |--------|------|------| | YouTube/Vimeo/Twitch/TikTok/SoundCloud 등 1,858개 | `yt-dlp --dump-json` | [media.md](media.md) | ### 학술/레지스트리 | 플랫폼 | 방법 | 상세 | |--------|------|------| | arXiv | Atom API | [public-api.md](public-api.md) | | CrossRef | REST API | [public-api.md](public-api.md) | | Wikipedia | REST API | [json-api.md](json-api.md) | | OpenLibrary | JSON API | [public-api.md](public-api.md) | | GitHub | gh CLI / REST API | [public-api.md](public-api.md) | | npm / PyPI | Registry API | [json-api.md](json-api.md) | | Wayback Machine | CDX API | [public-api.md](public-api.md) | ### 한국 전용 공식 API | 플랫폼 | 방법 | 상세 | |--------|------|------| | 네이버 검색 | `search.naver.com` (통합/블로그/뉴스탭) | [naver.md](naver.md) | | 네이버 금융 시세 | `api.finance.naver.com/siseJson.naver` (비공식 JSON) | [naver.md](naver.md) | **그 외 모든 사이트는 Phase 1(generic fetch chain)이 자동 처리한다.** ## Phase 1 — Generic Fetch Chain ### 단일 진입점 ```python from insane_search.engine import fetch result = fetch( "https://example.com/path", success_selectors=["article", "[class*='product-card']"], # 포지티브 프루프 (선택) device_class="auto", # "auto" | "desktop" | "mobile" user_hint=None, # {"referer_strategy": "self_root", "impersonate_first": "safari"} timeout=25, ) if result.ok: print(result.verdict) # strong_ok | weak_ok html = result.content # raw body — 단, content-rescue가 발동한 경우 구조 텍스트 # v0.10.0 content-rescue: PDF 응답은 pypdf 추출 텍스트, visible text가 얇은 # SPA 셸은 JSON-LD articleBody / 렌더된 innerText로 대체될 수 있다. # result.extraction_source로 판별: "raw"(원문 그대로) | pdf | json_ld | *+inner_text. # 일반 HTML 성공은 항상 raw. 끄기: enable_extraction=False / --no-extract. # 429/502/503/504는 probe에서 백오프 재시도(Retry-After 반영, 총 10초 캡); # 끄기: enable_retry=False / --no-retry. else: # Phase 3 수동 개입 (Playwright MCP) 필요 — result.trace로 원인 진단 pass ``` ### 내부 단계 (디버깅용 노출) `fetch()`는 단일 API이지만 내부는 phase로 나뉘어 있다. `result.trace`에서 각 시도를 확인할 수 있다. ``` probe — curl_cffi + safari + self-referer로 첫 시도 validate — 4-계층 검증 (marker / size / cookie / success_selectors) detect — WAF 제품 감지 ([(profile_id, confidence)] 랭킹) plan — 프로파일의 tls_candidates × url_transforms × referer 격자 구성 execute — 격자 전수 시도 (첫 200에서 탈출하지 않음) fallback — capability 태그 기반 Playwright 라우팅 (MCP or local+chrome) report — FetchResult(ok, verdict, profile_used, trace, summary) ``` ### 검증 원칙 - HTTP 200은 **검사 시작 조건**이지 성공이 아니다. - 성공 판정은 **4-계층 AND**: 1. 챌린지 마커 없음 (`sec-if-cpt-container`, `Access Denied`, `Just a moment...`, `DataDome`) 2. 비정상 크기 아님 (< 3KB 또는 WAF fingerprint 크기) 3. 쿠키 센서 상태 정상 (`_abck=~-1~` 아님) 4. `success_selectors` 중 하나 이상 매칭 (caller 제공 시 → `strong_ok`, 미제공 시 → `weak_ok`) ### 격자 축 (profile이 우선순위 추천, 격자는 전수 시도) | 축 | 값 | 비고 | |----|-----|------| | `url_transforms` | `original`, `mobile_subdomain` (`www.→m.`), `am_prefix`, `drop_www` | 사이트명 없음, 규칙만 | | `tls_impersonate` | `safari`, `safari_ios`, `chrome99`, `chrome119`, `chrome131`, `chrome_android`, `firefox`... | 프로파일별 avoid 리스트 존재 | | `referer_strategy` | `self_root`, `google_search`, `none` | | **device_class**: - `"auto"` (기본) — 프로파일 전략 따름 - `"desktop"` — TLS 데스크톱만 + `mobile_subdomain` 비활성 - `"mobile"` — TLS 모바일만 + `mobile_subdomain` 활성 ### Playwright 폴백 (capability-matched) `engine/executor.py`가 프로파일의 `capabilities_needed`를 읽고 실행기를 자동 선택: | 태그 | 실행기 | 언제 | |------|--------|------| | `needs_real_tls_stack` + `needs_js_exec` | `playwright_real_chrome.js` (로컬 Node) | Akamai Bot Manager 등 — Chromium 번들 TLS는 탐지됨 | | `needs_js_exec` only | Playwright MCP (`mcp__playwright__*`) | Cloudflare 기본 방어 등 | | `needs_mobile_context` (+ real_tls) | `playwright_mobile_chrome.js` | 모바일 디바이스 에뮬레이션 필요 | 자세한 선택 기준: [playwright.md](playwright.md). ### Playwright MCP 호출 규칙 `fetch_chain`의 `needs_js_exec only` 케이스는 **Claude 세션에서 MCP 도구를 직접 호출**해야 한다. subprocess 경로 없음. 즉: 1. `result.summary`에 "Playwright MCP must be invoked from the Claude session"이 포함되면 2. `mcp__playwright__browser_navigate` → `browser_wait_for` → `browser_snapshot` 흐름으로 Claude가 직접 처리 ## Phase 2 — 수동 개입 (옵션) Phase 1이 `ok=False`를 반환하면 사용자 힌트를 받아 재시도: ```python result = fetch( url, success_selectors=[...], user_hint={"impersonate_first": "safari_ios", "referer_strategy": "none"}, ) ``` 힌트는 **현재 호출 1회에만** 적용되며 저장되지 않는다. ## 의존성 자동 설치 최초 호출 시 필요 패키지를 자동 설치한다: ```bash python3 -c "import curl_cffi, bs4, yaml" 2>/dev/null || pip install curl_cffi beautifulsoup4 pyyaml -q ``` 브라우저를 직접 제어할 때는 js eval에서 omowright를 쓴다(`browser` 스킬에 스테이징됨): 직접 띄우는 브라우저는 `connectPipe` / 스텔스는 `connectCloakProfile`(CloakBrowser), 사용자가 로그인해 둔 브라우저는 `connectBrowserSkill`. 이 엔진의 Playwright 템플릿은 Tier 1 추출 폴백 전용이며 에이전트가 직접 브라우저를 다루는 경로가 아니다. 인증이 필요한 페이지는 사용자 프로필을 복제하지 말고 attached 엔진으로 간다. ## 빠른 참조 — Phase 0 명령어 ```bash # 범용 웹 (Jina Reader — 일반 HTML만, WAF 사이트엔 무효) curl -s "https://r.jina.ai/{URL}" # yt-dlp — 1,858 사이트 미디어 메타데이터 yt-dlp --dump-json "URL" # Reddit curl -sL -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" \ "https://www.reddit.com/r/{sub}/hot.json?limit=10" # X/Twitter 타임라인 curl -sL "https://syndication.twitter.com/srv/timeline-profile/screen-name/{handle}" # Hacker News curl -sL "https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy=%22%24key%22" # YouTube 자막 yt-dlp --write-sub --write-auto-sub --sub-lang "en,ko" --skip-download -o "/tmp/%(id)s" "URL" ``` ## No-Site-Name Rule `engine/**`, `waf_profiles.yaml`, `engine/templates/**` 파일에는 **특정 사이트의 도메인/URL/셀렉터/브랜드명을 하드코딩하지 않는다**. ### 금지 - `"coupang.com": {...}` 같은 사이트별 레지스트리 엔트리 - `if "coupang" in url: ...` 같은 도메인 분기 - WAF 프로파일 `notes`에 특정 사이트 이름이나 경험적 byte 크기 박제 ### 허용 - `SKILL.md` / `references/*.md`의 **설명 텍스트**에 사이트 이름 예시 (독자 이해용) - `Phase 0` 공식 API 인덱스 (플랫폼이 공식 공개한 엔드포인트) - `observations/*.jsonl` 로그 (append-only 관측 데이터 — 코드 경로에 영향 없음) - 호출자가 제공하는 `success_selectors`, `user_hint` (현재 호출에만 유효) ### 경계 사례 판단 기준 > "이 엔트리가 다른 사이트에서도 같은 WAF를 쓰면 일반적으로 유효한가?" → YES면 `waf_profiles.yaml`, NO면 runtime hint. ### 새 사이트가 안 뚫릴 때 1. 먼저 `result.trace`에서 어느 phase가 실패했는지 확인 2. 사용자의 `user_hint`로 1회 재시도 3. 반복 성공 패턴이 관측되면 `observations/`에 로그 (아직 자동 기록 없음 — 수동) 4. 3회+ 반복 확인되고 **동일 WAF를 쓰는 다른 사이트에도 유효**하면 `waf_profiles.yaml` 해당 프로파일의 `tls_impersonate_candidates` / `url_transform_order`를 튜닝 (사이트명 절대 넣지 않음) 5. 여전히 안 되면 새 WAF 프로파일 후보 검토 (예: DataDome 세부화, Kasada 등) ## 관련 문서 (references/) — 언제 무엇을 읽을지 이 섹션은 **참조 파일 선택 가이드**다. 문제가 생겼을 때 어떤 `references/*.md`를 열어야 할지 결정하는 기준으로 쓴다. Claude는 필요할 때만 해당 파일을 `Read`하고, 선제적으로 전부 읽지 않는다. ### A. Engine 확장·진단 (하네스 내부) | 파일 | 언제 읽는가 | 무엇을 다루는가 | |------|-------------|-----------------| | [`tls-impersonate.md`](tls-impersonate.md) | curl_cffi 격자가 전부 `challenge`/`blocked`로 끝날 때, 새 impersonate 타겟을 `waf_profiles.yaml`에 추가할 때 | curl_cffi로 Safari/Chrome/Firefox TLS(JA3/JA4) 지문 복제하는 방법, WAF(Akamai/Cloudflare/F5 등)별 최적 타겟 조합, 임퍼소네이션 타겟 버전 목록, `tls_impersonate_avoid`의 실증 근거 | | [`playwright.md`](playwright.md) | engine이 Playwright fallback으로 넘어가는데 MCP/Local Chrome 중 어디로 갈지 확인 필요할 때 | Approach 1 (`mcp__playwright__*` — Cloudflare급 챌린지), Approach 2 (Local Node + `channel:'chrome'` + stealth — Akamai Bot Manager급), 템플릿 파라미터 규격 | | [`fallback.md`](fallback.md) | `verdict`가 애매하거나 Phase 전환 타이밍 결정 필요할 때 | engine의 Phase 0→1→2→3 에스컬레이션 원칙, 응답 성공/실패 판정 기준 세부, 각 Phase 종료 조건 | | [`metadata.md`](metadata.md) | 본문 전체를 못 가져왔지만 제목·요약·가격·저자 같은 핵심만이라도 필요할 때 | OGP 메타 태그, JSON-LD (Schema.org), Twitter Card 파싱, 구조화 데이터 추출 패턴 | ### B. 경량 대안 (engine 말고 다른 도구가 나은 상황) | 파일 | 언제 읽는가 | 무엇을 다루는가 | |------|-------------|-----------------| | [`jina.md`](jina.md) | WAF 없는 일반 웹(블로그·뉴스·Wiki)의 깨끗한 마크다운 추출 필요할 때 | `r.jina.ai/URL` 한 줄로 Puppeteer 기반 JS SPA 렌더링, 마크다운 변환, 무료 500 RPM, API 키 불필요 | | [`cache-archive.md`](cache-archive.md) | 원본 사이트가 차단됐지만 과거 스냅샷으로라도 접근 필요할 때 | Wayback Machine CDX API, archive.today, AMP Cache (Google Cache는 2024-07 종료됨) | | [`rss.md`](rss.md) | 뉴스·블로그·커뮤니티의 시계열 업데이트를 구조화해 받고 싶을 때 | RSS/Atom 자동 발견, 피드 파싱, 인증 불필요 — 가장 깔끔한 시계열 데이터 소스 | ### C. 플랫폼별 공식/공개 API (Phase 0 인덱스와 연결) | 파일 | 언제 읽는가 | 무엇을 다루는가 | |------|-------------|-----------------| | [`json-api.md`](json-api.md) | Reddit/Wikipedia/HN/npm/PyPI 등 **URL 변형만으로** JSON을 주는 사이트 | Reddit `/json` suffix + Mobile UA, HN Firebase, Algolia Search, Wikipedia REST, npm/PyPI Registry API | | [`public-api.md`](public-api.md) | Bluesky/Mastodon/arXiv/Stack Overflow/CrossRef/GitHub/OpenLibrary/Wayback 공식 API 사용 시 | 인증 없이 쓰는 공식 공개 REST/AT/Atom API 엔드포인트, 요청 형식, 공통 파라미터 | | [`twitter.md`](twitter.md) | X/Twitter 접근 — 프로필 타임라인, 특정 트윗, 키워드 검색 | `syndication.twitter.com` 타임라인, oEmbed 개별 트윗, 검색은 웹 검색 도구로 URL 확보 후 oEmbed | | [`naver.md`](naver.md) | 네이버 블로그·뉴스·증권·검색 접근 | 서비스별 우회(블로그는 `m.blog.naver.com` 변환, 증권은 비공식 JSON, 검색은 `search.naver.com`), 한글 검색 쿼리 패턴 | | [`media.md`](media.md) | YouTube/Vimeo/Twitch/TikTok/SoundCloud 등 미디어 메타·자막·오디오 필요 시 | `yt-dlp --dump-json` 기반 1,858개 사이트 커버, 자막 다운로드(`--write-sub`), 포맷 선택, 라이브/팟캐스트 | ### D. Engine 코드 직접 읽을 때 | 파일 | 언제 읽는가 | |------|-------------| | `engine/fetch_chain.py` | 체인 단계 로직·`Attempt`/`FetchResult` schema 확인 | | `engine/validators.py` | 4-계층 검증 세부 (Verdict 분류, 챌린지 마커 목록) | | `engine/waf_detector.py` | WAF 랭킹 감지 알고리즘, `_LAST_LOAD_ERROR` 처리 | | `engine/waf_profiles.yaml` | 프로파일별 detectors·tls_candidates·capabilities_needed | | `engine/url_transforms.py` | URL 변환 규칙 추가할 때 | | `engine/executor.py` | Playwright MCP vs local capability 매칭 로직 | | `engine/templates/*.js` | Playwright 템플릿 튜닝 (warmup, reload, devices) | | `engine/bias_check.py` | 편향 린터 규칙 — brand denylist, URL_PATTERN, excluded dirs | -
rss.md 2.9 KB
# RSS/Atom 피드 > 인증 불필요. URL만 알면 바로 구독. 뉴스/블로그/커뮤니티에서 가장 깔끔한 데이터. ## 의존성 ```bash python3 -c "import feedparser" 2>/dev/null || pip install feedparser -q ``` ## RSS 자동 발견 Jina Reader JSON 모드로 사이트의 RSS URL을 자동 탐지: ```bash curl -sH "Accept: application/json" "https://r.jina.ai/{URL}" | \ python3 -c "import sys,json; print(json.load(sys.stdin)['data'].get('external',{}).get('alternate',[]))" ``` ## URL 변형으로 피드 탐색 사이트에 RSS가 명시되지 않아도 시도해볼 패턴: ```bash curl -sL "{origin}/rss" curl -sL "{origin}/feed" curl -sL "{origin}/atom.xml" curl -sL "{origin}/rss.xml" curl -sL "{origin}/index.xml" ``` ## Google News RSS (무인증) ```bash # 키워드 검색 curl -sL "https://news.google.com/rss/search?q={검색어}&hl=ko&gl=KR&ceid=KR:ko" # 토픽별 (TECHNOLOGY, BUSINESS, SCIENCE, SPORTS, HEALTH, WORLD) curl -sL "https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=ko&gl=KR&ceid=KR:ko" # 시간 필터: when:1h, when:7d, when:12m, after:YYYY-MM-DD curl -sL "https://news.google.com/rss/search?q={검색어}+when:7d&hl=ko&gl=KR&ceid=KR:ko" ``` ## 한국 언론사 RSS 전부 무인증. 바로 curl로 접근 가능. ```bash # SBS 뉴스 curl -sL "https://news.sbs.co.kr/news/rss.do" # 조선일보 curl -sL "http://www.chosun.com/site/data/rss/rss.xml" # 중앙일보 curl -sL "http://rss.joinsmsn.com/joins_news_list.xml" # 동아일보 curl -sL "http://rss.donga.com/total.xml" # 경향신문 curl -sL "http://www.khan.co.kr/rss/rssdata/total_news.xml" # 매일경제 curl -sL "http://file.mk.co.kr/news/rss/rss_30000001.xml" # MBC 뉴스 curl -sL "http://imnews.imbc.com/rss/news/news_00.xml" # 한국경제 curl -sL "https://www.hankyung.com/feed/all-news" # 연합뉴스 curl -sL "https://www.yonhapnewsagency.com/RSS/headline.xml" ``` ## 블로그/플랫폼 RSS ```bash # 네이버 블로그 curl -sL "https://rss.blog.naver.com/{BLOG_ID}.xml" # 티스토리 curl -sL "https://{blogname}.tistory.com/rss" # 벨로그 curl -sL "https://v2.velog.io/rss/@{username}" # Substack curl -sL "https://{publication}.substack.com/feed" # GitHub 릴리즈 (Atom) curl -sL "https://github.com/{owner}/{repo}/releases.atom" # YouTube 채널 curl -sL "https://www.youtube.com/feeds/videos.xml?channel_id={id}" # HN (hnrss.org — 비공식이지만 안정적) curl -sL "https://hnrss.org/frontpage" ``` ## feedparser 파싱 ```python import feedparser feed = feedparser.parse("FEED_URL") for e in feed.entries[:10]: print(f"{e.title} — {e.link}") if hasattr(e, 'summary'): print(f" {e.summary[:200]}") ``` ## SearXNG (무인증 메타검색) 공개 인스턴스에서 JSON 검색 가능. 인스턴스별로 JSON 지원 여부 다름. ```bash # 공개 인스턴스 목록: https://searx.space curl -sL "https://search.mdosch.de/search?q={검색어}&format=json" \ -H "User-Agent: insane-search/1.0" ``` -
tls-impersonate.md 6.3 KB
# TLS 임퍼소네이션 — curl_cffi > TLS 핑거프린트(JA3/JA4) 기반 WAF를 우회하는 핵심 방법. > 일반 curl/requests는 OpenSSL 핑거프린트라 즉시 차단되지만, > curl_cffi는 실제 브라우저(Chrome/Safari/Firefox)의 TLS 핑거프린트를 복제한다. ## 의존성 ```bash python3 -c "import curl_cffi" 2>/dev/null || pip install curl_cffi -q ``` 설치 후 사용 가능. **미설치를 이유로 이 스텝을 건너뛰지 않는다.** ## 다중 타겟 순차 시도 하나의 impersonate 타겟이 실패하면 다른 타겟으로 재시도한다. **시도 순서: safari → chrome → firefox** ```python from curl_cffi import requests TARGETS = ["safari", "chrome", "firefox"] HEADERS = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", "Referer": "https://www.google.com/", } def cffi_fetch(url, locale="ko-KR"): """다중 타겟 순차 시도 + 신원위장. 성공하면 (response, target) 반환.""" from urllib.parse import urlparse origin = f"{urlparse(url).scheme}://{urlparse(url).netloc}" for target in TARGETS: try: session = requests.Session(impersonate=target) session.headers.update(HEADERS) session.headers["Accept-Language"] = f"{locale},{locale.split('-')[0]};q=0.9" session.headers["Referer"] = "https://www.google.com/" # 신원위장: 홈페이지 쿠키 워밍 → Referer 체인 try: session.get(origin, timeout=10) except Exception: pass # 홈 실패해도 본 요청은 시도 session.headers["Referer"] = origin resp = session.get(url, timeout=20) # JS 필수 사이트 감지 → 나머지 타겟 시도 무의미 if "behavioral-content" in resp.text or "sec-if-cpt" in resp.text: return None, None # → Phase 3 Playwright if resp.status_code == 200 and len(resp.text) > 500: return resp, target except Exception: continue return None, None ``` ## 임퍼소네이션 타겟 목록 (v0.15.0) generic alias는 항상 최신 버전으로 해석된다. **2026년에 chrome99 같은 옛 버전은 WAF가 의심하므로 generic alias 사용 권장.** | Alias | 해석 (2026.04) | 용도 | |-------|---------------|------| | `safari` | safari260 | **한국 사이트 최적** (쿠팡, 에펨코리아) | | `chrome` | chrome146 | 범용 (Cloudflare, Akamai) | | `firefox` | firefox135 | chrome/safari 실패 시 대안 | | `chrome_android` | chrome131_android | 모바일 API 엔드포인트 | | `safari_ios` | safari260_ios | iOS 모바일 | <details> <summary>핀 버전 전체 (클릭)</summary> ``` chrome99, chrome100, chrome101, chrome104, chrome107, chrome110, chrome116, chrome119, chrome120, chrome123, chrome124, chrome131, chrome133a, chrome136, chrome142, chrome145, chrome146, chrome131_android, edge99, edge101, safari15_3, safari15_5, safari17_0, safari17_2_ios, safari18_0, safari18_0_ios, safari260, safari260_ios, firefox133, firefox135 ``` </details> ## WAF별 최적 전략 | WAF | 최적 타겟 | 추가 조건 | 성공률 | |-----|-----------|-----------|--------| | F5 BIG-IP (쿠팡) | `safari` | `Referer: https://www.coupang.com/` | ~70% | | Cloudflare (TLS만) | `chrome` | Sec-Fetch-* 헤더 추가 | ~80% | | Akamai | `chrome` | 레지덴셜 프록시 병행 | 80-90% | | AWS WAF | `chrome` | — | ~80% | | CloudFront (요즘IT) | 불필요 | 일반 curl + Chrome UA로 충분 | 100% | ## 세션과 쿠키 ```python from curl_cffi import requests # 세션 유지 (쿠키 자동 관리) session = requests.Session(impersonate="safari") # 첫 요청으로 세션 쿠키 획득 session.get("https://www.coupang.com/") # 이후 요청에 쿠키 자동 전달 resp = session.get("https://www.coupang.com/np/search?q=키보드") ``` ## 콤보: nodriver/FlareSolverr → curl_cffi JS 챌린지 사이트는 브라우저로 쿠키를 획득한 뒤 curl_cffi로 고속 처리: ```python # 1. nodriver로 cf_clearance 쿠키 획득 import nodriver as uc browser = await uc.start(headless=True) page = await browser.get("https://cf-protected-site.com") await page.cf_verify() cookies = await browser.cookies.get_all() # 2. curl_cffi Session에 쿠키 전달 from curl_cffi import requests session = requests.Session(impersonate="chrome") for c in cookies: session.cookies.set(c["name"], c["value"]) resp = session.get("https://cf-protected-site.com/api/data") ``` ## 비동기 (async) ```python import asyncio from curl_cffi.requests import AsyncSession async def fetch_many(urls): async with AsyncSession(impersonate="chrome") as session: tasks = [session.get(url) for url in urls] return await asyncio.gather(*tasks) ``` ## HTTP/3 (v0.15.0+) ```python from curl_cffi import requests from curl_cffi.const import CurlHttpVersion resp = requests.get( "https://www.cloudflare.com/", impersonate="chrome", http_version=CurlHttpVersion.V3, ) ``` WAF 벤더들이 아직 HTTP/3 핑거프린트를 적극 활용하지 않아 우회 효과가 높다. ## 대안 라이브러리 curl_cffi 실패 시 대안: | 라이브러리 | 설치 | 특징 | |-----------|------|------| | primp | `pip install primp` | Rust 기반, Firefox 148까지, 고성능 | | wreq/rnet | `pip install wreq` | Rust 기반, 100+ 디바이스 프로필 | | tls-client2 | `pip install tls-client2` | Go 기반 포크, 동기만 | ```python # primp 예시 import primp client = primp.Client(impersonate="chrome_146") resp = client.get("https://example.com") ``` ## curl_cffi가 못 뚫는 것 | 방어 수단 | curl_cffi | 대응 | |-----------|-----------|------| | TLS/JA3 핑거프린트 | 우회 가능 | 핵심 기능 | | HTTP/2 SETTINGS 핑거프린트 | 우회 가능 | impersonate에 포함 | | HTTP/3 QUIC 핑거프린트 | 우회 가능 (v0.15+) | 신규 | | JS 챌린지 (Turnstile 등) | **불가** | → nodriver 또는 Playwright | | CAPTCHA | **불가** | → 2captcha/CapSolver | | IP 평판 (데이터센터) | **불가** | → 프록시/VPN | | 행동 분석 (마우스/타이밍) | **불가** | → 실제 브라우저 | JS 챌린지가 걸린 사이트는 → [playwright.md](playwright.md) 로 넘긴다. -
twitter.md 3.5 KB
# X/Twitter 접근 전략 > WebFetch는 402로 차단됨. 아래 방법으로 우회한다. 모두 API 키/인증 불필요. ## 검색 (트윗 발견) ```python <사용 가능한 web search tool>(query="site:x.com {검색어}") # Claude Code: WebSearch / OpenCode 계열: websearch_web_search_exa 등 — 하네스마다 실제 tool 이름이 다르므로 현재 세션의 tool 목록에서 확인할 것 ``` 웹 검색 도구는 X 포스트를 검색 결과로 반환한다. 제목, snippet, URL을 획득할 수 있지만 트윗 전문이나 engagement 수치는 없다. ## 타임라인 조회 — Syndication API 특정 핸들의 최근 ~100개 트윗 + engagement 수치(likes, RTs) 제공. ### 엔드포인트 ``` https://syndication.twitter.com/srv/timeline-profile/screen-name/{handle} ``` ### 원샷 스크립트 ```bash curl -sL "https://syndication.twitter.com/srv/timeline-profile/screen-name/{handle}" | \ python3 -c " import sys, json, re, html content = sys.stdin.read() match = re.search(r'__NEXT_DATA__.*?>(.*?)</script>', content) if match: data = json.loads(match.group(1)) for e in data['props']['pageProps']['timeline']['entries']: if e['type'] == 'tweet': t = e['content']['tweet'] print(f\"@{t['user']['screen_name']} ({t.get('created_at','?')})\") print(f\" {html.unescape(t.get('full_text',''))[:300]}\") print(f\" Likes: {t.get('favorite_count',0)} | RTs: {t.get('retweet_count',0)}\") print('---') " ``` ### 가져올 수 있는 데이터 | 필드 | 경로 | 예시 | |------|------|------| | 트윗 전문 | `tweet.full_text` | "Give your agent the..." | | 작성자 핸들 | `tweet.user.screen_name` | "openclaw" | | 작성자 이름 | `tweet.user.name` | "OpenClaw" | | 좋아요 수 | `tweet.favorite_count` | 1929 | | RT 수 | `tweet.retweet_count` | 169 | | 작성 시각 | `tweet.created_at` | "Mon Apr 06 04:04:08 +0000 2026" | | 트윗 ID | `tweet.id_str` | "2041003999856406714" | | 미디어 URL | `tweet.entities.media[].media_url_https` | 이미지/동영상 URL | ### 제한 - 최근 ~100개 반환 (페이지네이션 불가) - 비공개 계정 접근 불가 - 검색 기능 없음 (타임라인만) - **저팔로워/신규 계정**: `hasResults: false` 반환 가능. 이 경우 oEmbed 개별 트윗 접근은 정상 동작하므로 "조합 패턴"으로 폴백. - 비공식 엔드포인트 — X가 변경/차단 가능 ## 개별 트윗 조회 — oEmbed API 특정 트윗 URL을 알 때 전문 가져오기. ### 엔드포인트 ``` https://publish.twitter.com/oembed?url=https://x.com/{user}/status/{tweet_id} ``` ### 사용법 ```bash curl -sL "https://publish.twitter.com/oembed?url=https://x.com/{user}/status/{tweet_id}" ``` ### 응답 (JSON) | 필드 | 설명 | |------|------| | `author_name` | 작성자 표시 이름 | | `author_url` | 작성자 프로필 URL | | `html` | 트윗 전문이 포함된 HTML blockquote | | `url` | 트윗 원본 URL | ## 조합 패턴 (검색 → 상세) ``` 1단계: 웹 검색 도구(query="site:x.com {키워드}") → 트윗 URL 획득 2단계: curl oEmbed API → 트윗 전문 획득 ``` ## 실패하는 방법 (사용하지 말 것) | 방법 | 결과 | 원인 | |------|------|------| | WebFetch | 402 Payment Required | Claude Code의 WebFetch 제한 | | Nitter | 빈 응답 | Nitter 인스턴스 대부분 종료됨 | | Wayback Machine | OG 메타태그만 | SPA 렌더링 안 됨 | | Mobile UA curl | OG 메타태그만 | SPA 렌더링 안 됨 | | RSS | 엔드포인트 없음 | X는 RSS 지원 중단 |
-
-
chrome-stealth.md 2.2 KB
# Tier 2 — stealth through omowright and CloakBrowser For real-Chrome semantics, stealth, traces, or authenticated sessions, drive omowright from the js-eval kernel. The library is staged inside the `browser` skill; load it with `loadOmowright()` from that skill's `scripts/omowright.mjs`. ## Stealth is the engine, not a plugin Bot-scored pages (Cloudflare Turnstile, FingerprintJS, DataDome) are handled by launching **CloakBrowser** — a Chromium build with source-level fingerprint patches — through `connectCloakProfile({ profileDir })`. The profile pins a fingerprint seed on first use and refuses to change identity silently, so the same site sees the same browser on every run. ```js const browser = await omowright.connectCloakProfile({ profileDir }) try { const page = await browser.newTab(url) console.log(await page.evaluate("navigator.webdriver")) // must be false await Bun.write(pngPath, await page.screenshot()) } finally { await browser.close() } ``` Do not pass an init-script for `navigator.webdriver` and do not add stealth plugins: CloakBrowser patches the engine itself. A launch that still meets a challenge is handled by the `browser` skill's ladder (`references/owned-engine/ladder.md`: layers, coordinates, `createCaptcha`), and a page that only a signed-in user can reach belongs to the attached engine, not to a cloned profile. ## Cookie login limits `scripts/extract_cookies.py` still exports cookies from a local browser; inject them with `injectCookies(page, cookies)` into an **owned** profile. It sanitizes the set (drops expired entries, keeps host-prefixed cookies secure and root-scoped). Limits that do not change: - Accounts whose risk engines bind a session to a device (Google, password managers) invalidate a session reused from a new fingerprint; use the attached engine for those. - Cookies apply on the next navigation — reload after injecting. - NEVER copy a session out of, or clear cookies/cache/site data in, the user's live profile. ## The extraction engine's own Playwright fallback The Tier-1 Python engine (`engine/`) keeps its own script-based Chrome fallback for headless extraction; that is an engine internal, not an agent-facing browser path. Interactive work, QA and screenshots go through omowright as above.
-
-
scripts
-
tests
-
test_cookie_domain_filter.py 3.5 KB
#!/usr/bin/env python3 from __future__ import annotations import shutil import sqlite3 import sys import tempfile import unittest from collections.abc import Callable from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from extract_cookies import extract_cookies # noqa: E402 def _make_chromium_db(path: Path, rows: list[tuple[str, bytes, str]]) -> None: conn = sqlite3.connect(str(path)) conn.execute( "CREATE TABLE cookies (name TEXT, encrypted_value BLOB, host_key TEXT, path TEXT, " "expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER)" ) conn.executemany( "INSERT INTO cookies VALUES (?,?,?,?,?,?,?,?)", [ (name, value, host, "/", 13_300_000_000_000_000, 1, 1, 1) for name, value, host in rows ], ) conn.commit() conn.close() def _make_firefox_db(path: Path, rows: list[tuple[str, str, str]]) -> None: conn = sqlite3.connect(str(path)) conn.execute( "CREATE TABLE moz_cookies (name TEXT, value TEXT, host TEXT, path TEXT, " "expiry INTEGER, isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER)" ) conn.executemany( "INSERT INTO moz_cookies VALUES (?,?,?,?,?,?,?,?)", [ (name, value, host, "/", 9999999999, 1, 1, 1) for name, value, host in rows ], ) conn.commit() conn.close() class DomainFilter(unittest.TestCase): def _base_with_db(self, rel: str, make: Callable[[Path], None]) -> Path: base = Path(tempfile.mkdtemp()) self.addCleanup(lambda: shutil.rmtree(str(base), ignore_errors=True)) db = base / rel db.parent.mkdir(parents=True) make(db) return base def test_chromium_does_not_overmatch_suffix_text(self) -> None: base = self._base_with_db( "Google/Chrome/User Data/Default/Cookies", lambda p: _make_chromium_db( p, [ ("exact", b"exact-value", "example.com"), ("sub", b"sub-value", ".login.example.com"), ("near", b"near-value", ".example.com"), ], ), ) near = extract_cookies( "chrome", ["ample.com"], platform="win32", keyring_reader=lambda _s: b"k" * 32, base_override=base, ) exact = extract_cookies( "chrome", ["example.com"], platform="win32", keyring_reader=lambda _s: b"k" * 32, base_override=base, ) self.assertEqual(near, []) self.assertEqual({cookie["name"] for cookie in exact}, {"exact", "near", "sub"}) def test_firefox_does_not_overmatch_suffix_text(self) -> None: base = self._base_with_db( "Firefox/Profiles/abc.default/cookies.sqlite", lambda p: _make_firefox_db( p, [ ("exact", "exact-value", "example.com"), ("sub", "sub-value", ".login.example.com"), ("near", "near-value", ".example.com"), ], ), ) near = extract_cookies( "firefox", ["ample.com"], platform="darwin", base_override=base, ) exact = extract_cookies( "firefox", ["example.com"], platform="darwin", base_override=base, ) self.assertEqual(near, []) self.assertEqual({cookie["name"] for cookie in exact}, {"exact", "near", "sub"}) if __name__ == "__main__": unittest.main(verbosity=2) -
test_extract_cookies.py 9.4 KB
#!/usr/bin/env python3 """Synthetic-fixture tests for cross-platform cookie extraction (no live browser).""" from __future__ import annotations import shutil import sqlite3 import sys import tempfile import unittest from pathlib import Path from typing import Callable from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from cookie_crypto import decrypt_chromium_value, derive_key # noqa: E402 from cookie_paths import UnsupportedPlatform, resolve_cookie_db # noqa: E402 from extract_cookies import extract_cookies, inject_cookies, write_cookie_file # noqa: E402 def _make_chromium_db(path: Path, name: str, encrypted_value: bytes, host: str) -> None: conn = sqlite3.connect(str(path)) conn.execute( "CREATE TABLE cookies (name TEXT, encrypted_value BLOB, host_key TEXT, path TEXT, " "expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER)" ) conn.execute( "INSERT INTO cookies VALUES (?,?,?,?,?,?,?,?)", (name, encrypted_value, host, "/", 13_300_000_000_000_000, 1, 1, 1), ) conn.commit() conn.close() def _make_firefox_db(path: Path, name: str, value: str, host: str) -> None: conn = sqlite3.connect(str(path)) conn.execute( "CREATE TABLE moz_cookies (name TEXT, value TEXT, host TEXT, path TEXT, " "expiry INTEGER, isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER)" ) conn.execute("INSERT INTO moz_cookies VALUES (?,?,?,?,?,?,?,?)", (name, value, host, "/", 9999999999, 1, 1, 1)) conn.commit() conn.close() def _encrypt_cbc_v10(key: bytes, plaintext: str) -> bytes: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes data = plaintext.encode() pad = 16 - (len(data) % 16) padded = data + bytes([pad]) * pad encryptor = Cipher(algorithms.AES128(key), modes.CBC(b" " * 16)).encryptor() return b"v10" + encryptor.update(padded) + encryptor.finalize() def _encrypt_gcm_v10(key: bytes, plaintext: str) -> bytes: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes nonce = b"n" * 12 encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce)).encryptor() ciphertext = encryptor.update(plaintext.encode()) + encryptor.finalize() return b"v10" + nonce + ciphertext + encryptor.tag class PathResolution(unittest.TestCase): def test_macos_chromium_path(self) -> None: base = Path(self.tmp()) target = base / "Google/Chrome/Default/Cookies" target.parent.mkdir(parents=True) target.write_bytes(b"") self.assertEqual(resolve_cookie_db("chrome", "darwin", base_override=base), target) def test_linux_chromium_network_path(self) -> None: base = Path(self.tmp()) target = base / "google-chrome/Default/Network/Cookies" target.parent.mkdir(parents=True) target.write_bytes(b"") self.assertEqual(resolve_cookie_db("chrome", "linux", base_override=base), target) def test_windows_chromium_path(self) -> None: base = Path(self.tmp()) target = base / "Google/Chrome/User Data/Default/Cookies" target.parent.mkdir(parents=True) target.write_bytes(b"") self.assertEqual(resolve_cookie_db("chrome", "win32", base_override=base), target) def test_unsupported_platform_raises(self) -> None: with self.assertRaises(UnsupportedPlatform): resolve_cookie_db("chrome", "sunos") def test_unsupported_browser_raises(self) -> None: with self.assertRaises(UnsupportedPlatform): resolve_cookie_db("nonexistent", "darwin") def tmp(self) -> str: import tempfile d = tempfile.mkdtemp() self.addCleanup(lambda: __import__("shutil").rmtree(d, ignore_errors=True)) return d class KeyDerivation(unittest.TestCase): def test_macos_iters_differ_from_linux(self) -> None: secret = b"some-keychain-secret" self.assertNotEqual(derive_key("darwin", secret), derive_key("linux", secret)) self.assertEqual(len(derive_key("darwin", secret)), 16) def test_windows_passthrough_32_bytes(self) -> None: key = b"k" * 32 self.assertEqual(derive_key("win32", key), key) def test_windows_rejects_wrong_length(self) -> None: with self.assertRaises(ValueError): derive_key("win32", b"short") def test_unsupported_platform_raises(self) -> None: with self.assertRaises(UnsupportedPlatform): derive_key("sunos", b"x") class Decryption(unittest.TestCase): def test_macos_cbc_roundtrip(self) -> None: key = derive_key("darwin", b"secret") blob = _encrypt_cbc_v10(key, "session-token-123") self.assertEqual(decrypt_chromium_value("darwin", key, blob), "session-token-123") def test_windows_gcm_roundtrip(self) -> None: key = b"k" * 32 blob = _encrypt_gcm_v10(key, "win-token-xyz") self.assertEqual(decrypt_chromium_value("win32", key, blob), "win-token-xyz") class EndToEnd(unittest.TestCase): def _base_with_db(self, rel: str, make: Callable[[Path], None]) -> Path: base = Path(tempfile.mkdtemp()) self.addCleanup(lambda: shutil.rmtree(str(base), ignore_errors=True)) db = base / rel db.parent.mkdir(parents=True) make(db) return base def test_chromium_extract_with_injected_keyring(self) -> None: key = derive_key("darwin", b"secret") blob = _encrypt_cbc_v10(key, "logged-in") base = self._base_with_db( "Google/Chrome/Default/Cookies", lambda p: _make_chromium_db(p, "SID", blob, ".youtube.com"), ) cookies = extract_cookies( "chrome", ["youtube.com"], platform="darwin", keyring_reader=lambda _s: b"secret", base_override=base, ) self.assertEqual(len(cookies), 1) self.assertEqual(cookies[0]["value"], "logged-in") self.assertEqual(cookies[0]["name"], "SID") def test_firefox_extract_unencrypted(self) -> None: base = self._base_with_db( "Firefox/Profiles/abc.default/cookies.sqlite", lambda p: _make_firefox_db(p, "auth", "plain-value", ".example.com"), ) cookies = extract_cookies("firefox", ["example.com"], platform="darwin", base_override=base) self.assertEqual(len(cookies), 1) self.assertEqual(cookies[0]["value"], "plain-value") class SecretHandling(unittest.TestCase): def test_cookie_output_file_is_owner_only(self) -> None: base = Path(tempfile.mkdtemp()) self.addCleanup(lambda: shutil.rmtree(str(base), ignore_errors=True)) output = base / "cookies.json" write_cookie_file(output, [{"name": "SID", "value": "secret"}]) self.assertEqual(output.stat().st_mode & 0o777, 0o600) self.assertIn("secret", output.read_text()) def test_cookie_output_replaces_existing_file_only_after_private_temp_write(self) -> None: base = Path(tempfile.mkdtemp()) self.addCleanup(lambda: shutil.rmtree(str(base), ignore_errors=True)) output = base / "cookies.json" output.write_text("old\n") output.chmod(0o644) def _dump(_cookies, f, indent: int) -> None: self.assertEqual(output.read_text(), "old\n") self.assertEqual(output.stat().st_mode & 0o777, 0o644) temp_files = list(base.glob(".cookies.json.*.tmp")) self.assertEqual(len(temp_files), 1) self.assertEqual(temp_files[0].stat().st_mode & 0o777, 0o600) f.write('[{"name": "SID", "value": "secret"}]') with patch("extract_cookies.json.dump", side_effect=_dump): write_cookie_file(output, [{"name": "SID", "value": "secret"}]) self.assertEqual(output.stat().st_mode & 0o777, 0o600) self.assertIn("secret", output.read_text()) def test_cookie_output_refuses_symlinks(self) -> None: base = Path(tempfile.mkdtemp()) self.addCleanup(lambda: shutil.rmtree(str(base), ignore_errors=True)) target = base / "target.json" target.write_text("{}") link = base / "cookies.json" link.symlink_to(target) with self.assertRaises(ValueError): write_cookie_file(link, [{"name": "SID", "value": "secret"}]) def test_cookie_output_refuses_dangling_symlinks(self) -> None: base = Path(tempfile.mkdtemp()) self.addCleanup(lambda: shutil.rmtree(str(base), ignore_errors=True)) link = base / "cookies.json" link.symlink_to(base / "missing.json") with self.assertRaises(ValueError): write_cookie_file(link, [{"name": "SID", "value": "secret"}]) def test_inject_cookies_sends_values_over_stdin_not_argv(self) -> None: cookie = { "name": "SID", "value": "secret-token", "domain": ".youtube.com", "path": "/", "expires": 9999999999, "secure": True, "httpOnly": True, "sameSite": "Lax", } with patch("extract_cookies.subprocess.run") as run: run.return_value.returncode = 0 run.return_value.stdout = "1" run.return_value.stderr = "" inject_cookies([cookie], 9242) command = run.call_args.args[0] self.assertNotIn("secret-token", command) self.assertIn("secret-token", run.call_args.kwargs["input"]) if __name__ == "__main__": unittest.main(verbosity=2)
-
-
cookie_crypto.py 2.9 KB
"""Pure key derivation + value decryption, with the OS-keyring read injected.""" from __future__ import annotations import base64 import json import subprocess from pathlib import Path from cookie_paths import UnsupportedPlatform def derive_key(platform: str, secret: bytes) -> bytes: if platform == "win32": if len(secret) != 32: raise ValueError(f"win32 os_crypt key must be 32 bytes, got {len(secret)}") return secret if platform in ("darwin", "linux"): from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC iterations = 1003 if platform == "darwin" else 1 return PBKDF2HMAC(algorithm=hashes.SHA1(), length=16, salt=b"saltysalt", iterations=iterations).derive(secret) raise UnsupportedPlatform(f"unsupported platform for key derivation: {platform!r}") def decrypt_chromium_value(platform: str, key: bytes, encrypted: bytes) -> str: if not encrypted: return "" prefix = encrypted[:3] if prefix in (b"v10", b"v11") and platform == "win32": from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes nonce, ciphertext, tag = encrypted[3:15], encrypted[15:-16], encrypted[-16:] decryptor = Cipher(algorithms.AES(key), modes.GCM(nonce, tag)).decryptor() return (decryptor.update(ciphertext) + decryptor.finalize()).decode("utf-8", errors="replace") if prefix in (b"v10", b"v11"): from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes decryptor = Cipher(algorithms.AES128(key), modes.CBC(b" " * 16)).decryptor() decrypted = decryptor.update(encrypted[3:]) + decryptor.finalize() pad = decrypted[-1] if isinstance(pad, int) and 1 <= pad <= 16: decrypted = decrypted[:-pad] return decrypted.decode("utf-8", errors="replace") return encrypted.decode("utf-8", errors="replace") def macos_keyring_secret(safe_storage: str) -> bytes: result = subprocess.run( ["security", "find-generic-password", "-s", safe_storage, "-w"], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"cannot read {safe_storage} from Keychain: {result.stderr.strip()}") return result.stdout.strip().encode() def linux_keyring_secret(safe_storage: str) -> bytes: try: import secretstorage except ImportError: return b"peanuts" conn = secretstorage.dbus_init() for item in secretstorage.get_default_collection(conn).get_all_items(): if item.get_label() == safe_storage: return item.get_secret() return b"peanuts" def windows_oscrypt_key(local_state_path: Path) -> bytes: state = json.loads(local_state_path.read_text()) blob = base64.b64decode(state["os_crypt"]["encrypted_key"])[5:] import win32crypt return win32crypt.CryptUnprotectData(blob, None, None, None, 0)[1] -
cookie_domains.py 1.1 KB
from __future__ import annotations SQL_LIKE_ESCAPE = "\\" class CookieDomainError(ValueError): pass def normalize_cookie_domain(domain: str) -> str: normalized = domain.strip().lower().lstrip(".") if not normalized: raise CookieDomainError("cookie domain must not be empty") return normalized def escape_sql_like(value: str) -> str: return ( value .replace(SQL_LIKE_ESCAPE, SQL_LIKE_ESCAPE * 2) .replace("%", f"{SQL_LIKE_ESCAPE}%") .replace("_", f"{SQL_LIKE_ESCAPE}_") ) def domain_where_clause(column: str, domains: list[str]) -> tuple[str, list[str]]: normalized_domains = sorted({normalize_cookie_domain(domain) for domain in domains}) if not normalized_domains: raise CookieDomainError("at least one cookie domain is required") clauses: list[str] = [] params: list[str] = [] for domain in normalized_domains: clauses.append( f"({column} = ? OR {column} = ? OR {column} LIKE ? ESCAPE '{SQL_LIKE_ESCAPE}')" ) params.extend([domain, f".{domain}", f"%.{escape_sql_like(domain)}"]) return " OR ".join(clauses), params -
cookie_paths.py 3.8 KB
"""Pure profile-path resolution for cross-platform cookie extraction.""" from __future__ import annotations import os from pathlib import Path from typing import Final, Literal, TypedDict, assert_never class UnsupportedPlatform(ValueError): """Raised when a browser/platform combination is not supported.""" BrowserKind = Literal["chromium", "firefox"] class BrowserDirs(TypedDict): darwin: str linux: str win32: str class BrowserSpec(TypedDict): kind: BrowserKind safe_storage: str | None dirs: BrowserDirs BROWSERS: Final[dict[str, BrowserSpec]] = { "chrome": { "kind": "chromium", "safe_storage": "Chrome Safe Storage", "dirs": {"darwin": "Google/Chrome", "linux": "google-chrome", "win32": "Google/Chrome/User Data"}, }, "brave": { "kind": "chromium", "safe_storage": "Brave Safe Storage", "dirs": { "darwin": "BraveSoftware/Brave-Browser", "linux": "BraveSoftware/Brave-Browser", "win32": "BraveSoftware/Brave-Browser/User Data", }, }, "chromium": { "kind": "chromium", "safe_storage": "Chromium Safe Storage", "dirs": {"darwin": "Chromium", "linux": "chromium", "win32": "Chromium/User Data"}, }, "firefox": { "kind": "firefox", "safe_storage": None, "dirs": {"darwin": "Firefox/Profiles", "linux": ".mozilla/firefox", "win32": "Mozilla/Firefox/Profiles"}, }, } CHROMIUM_PROFILE_DIRS: Final = ["Default", "Profile 1", "Profile 2"] def platform_base(platform: str, kind: BrowserKind) -> Path: home = Path.home() match platform: case "darwin": return home / "Library" / "Application Support" case "linux": match kind: case "firefox": return home case "chromium": return Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config"))) case unreachable: assert_never(unreachable) case "win32": return Path(os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local"))) case _: raise UnsupportedPlatform(f"unsupported platform: {platform!r}") def browser_dir(spec: BrowserSpec, platform: str) -> str: match platform: case "darwin": return spec["dirs"]["darwin"] case "linux": return spec["dirs"]["linux"] case "win32": return spec["dirs"]["win32"] case _: raise UnsupportedPlatform(f"browser not mapped for platform {platform!r}") def resolve_cookie_db(browser: str, platform: str, base_override: Path | None = None) -> Path: spec = BROWSERS.get(browser) if spec is None: raise UnsupportedPlatform(f"unsupported browser: {browser!r}") base = base_override if base_override is not None else platform_base(platform, spec["kind"]) profile_root = base / browser_dir(spec, platform) match spec["kind"]: case "firefox": if not profile_root.exists(): raise FileNotFoundError(f"no Firefox profile root at {profile_root}") for entry in sorted(profile_root.iterdir()): db = entry / "cookies.sqlite" if db.exists(): return db raise FileNotFoundError(f"no cookies.sqlite under {profile_root}") case "chromium": for profile in CHROMIUM_PROFILE_DIRS: for candidate in (profile_root / profile / "Cookies", profile_root / profile / "Network" / "Cookies"): if candidate.exists(): return candidate raise FileNotFoundError(f"no Cookies DB under {profile_root}") case unreachable: assert_never(unreachable) -
extract_cookies.py 9.3 KB
#!/usr/bin/env python3 """Cross-platform browser cookie extraction for Tier-2 Chrome stealth. The OS-keyring lookup is an injected boundary: cookie_paths resolves profile paths and cookie_crypto derives keys + decrypts values, both pure and testable with synthetic fixtures on any OS. This module wires them to a real browser DB and a local Chrome CDP session controlled by playwright-core scripts. Usage: python extract_cookies.py --browser chrome --domain youtube.com --output /tmp/cookies.json python extract_cookies.py --browser chrome --domain youtube.com --inject --cdp 9242 """ from __future__ import annotations import argparse import json import os import shutil import sqlite3 import subprocess import sys import tempfile from pathlib import Path from typing import Callable, NotRequired, TypedDict from cookie_crypto import ( decrypt_chromium_value, derive_key, linux_keyring_secret, macos_keyring_secret, windows_oscrypt_key, ) from cookie_domains import domain_where_clause from cookie_paths import BROWSERS, BrowserSpec, UnsupportedPlatform, platform_base, resolve_cookie_db _SAMESITE = {-1: "None", 0: "None", 1: "Lax", 2: "Strict"} IMPORTANT_COOKIES = { "SID", "SSID", "HSID", "APISID", "SAPISID", "__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDTS", "__Secure-3PSIDTS", "LOGIN_INFO", "PREF", "VISITOR_INFO1_LIVE", "YSC", "NID", "CONSENT", } class CookieRecord(TypedDict): name: str value: str domain: str path: str expires: int secure: bool httpOnly: bool sameSite: str class CdpCookie(TypedDict): name: str value: str domain: str path: str secure: bool httpOnly: bool sameSite: str expires: NotRequired[int] _CDP_SET_COOKIES_SCRIPT = r""" const port = Number(process.argv[1] || 0); if (!Number.isInteger(port) || port <= 0) { process.stderr.write("invalid CDP port\n"); process.exit(2); } let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => { (async () => { const cookies = JSON.parse(input || "[]"); const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json()); const ws = new WebSocket(version.webSocketDebuggerUrl); let nextId = 1; const pending = new Map(); ws.onmessage = (event) => { const msg = JSON.parse(event.data); const waiter = pending.get(msg.id); if (!waiter) return; pending.delete(msg.id); if (msg.error) waiter.reject(new Error(msg.error.message || "CDP error")); else waiter.resolve(msg.result); }; await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); const send = (method, params) => new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); }); let ok = 0; for (const cookie of cookies) { const result = await send("Network.setCookie", cookie); if (result && result.success) ok += 1; } ws.close(); process.stdout.write(String(ok)); })().catch((error) => { process.stderr.write(`${error.name || "Error"}: ${error.message || error}\n`); process.exit(1); }); }); """ def _secure_cookie_db_copy(db_path: Path) -> Path: handle = tempfile.NamedTemporaryFile(prefix="omo-cookies-", suffix=".sqlite", delete=False) tmp = Path(handle.name) handle.close() try: shutil.copyfile(db_path, tmp) tmp.chmod(0o600) return tmp except (OSError, shutil.Error): tmp.unlink(missing_ok=True) raise def write_cookie_file(path: Path, cookies: list[CookieRecord]) -> None: if path.is_symlink(): raise ValueError(f"refusing to write cookies through symlink: {path}") path.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) tmp_path = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8") as f: os.fchmod(f.fileno(), 0o600) json.dump(cookies, f, indent=2) f.write("\n") os.replace(tmp_path, path) finally: tmp_path.unlink(missing_ok=True) def extract_firefox(db_path: Path, domains: list[str]) -> list[CookieRecord]: tmp = _secure_cookie_db_copy(db_path) try: where, params = domain_where_clause("host", domains) conn = sqlite3.connect(str(tmp)) rows = conn.execute( f"SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite " f"FROM moz_cookies WHERE ({where}) ORDER BY host, name", params, ).fetchall() conn.close() finally: tmp.unlink(missing_ok=True) return [ { "name": n, "value": v, "domain": h, "path": p, "expires": e, "secure": bool(sec), "httpOnly": bool(ho), "sameSite": _SAMESITE.get(ss, "Lax"), } for n, v, h, p, e, sec, ho, ss in rows ] def extract_chromium(db_path: Path, domains: list[str], platform: str, key: bytes) -> list[CookieRecord]: tmp = _secure_cookie_db_copy(db_path) try: where, params = domain_where_clause("host_key", domains) conn = sqlite3.connect(str(tmp)) rows = conn.execute( f"SELECT name, encrypted_value, host_key, path, expires_utc, is_secure, is_httponly, samesite " f"FROM cookies WHERE ({where}) ORDER BY host_key, name", params, ).fetchall() conn.close() finally: tmp.unlink(missing_ok=True) out = [] for n, enc, h, p, exp, sec, ho, ss in rows: unix_expires = int((exp / 1_000_000) - 11644473600) if exp and exp > 0 else 0 out.append({ "name": n, "value": decrypt_chromium_value(platform, key, enc), "domain": h, "path": p, "expires": unix_expires, "secure": bool(sec), "httpOnly": bool(ho), "sameSite": _SAMESITE.get(ss, "Lax"), }) return out def _browser_spec(browser: str) -> BrowserSpec: spec = BROWSERS.get(browser) if spec is None: raise UnsupportedPlatform(f"unsupported browser: {browser!r}") return spec def default_keyring_reader(platform: str, spec: BrowserSpec) -> Callable[[str], bytes]: if platform == "darwin": return macos_keyring_secret if platform == "linux": return linux_keyring_secret if platform == "win32": def _win(_safe_storage: str) -> bytes: base = platform_base("win32", "chromium") return windows_oscrypt_key(base / spec["dirs"]["win32"] / "Local State") return _win raise UnsupportedPlatform(f"no keyring reader for platform {platform!r}") def extract_cookies( browser: str, domains: list[str], platform: str = sys.platform, keyring_reader: Callable[[str], bytes] | None = None, base_override: Path | None = None, ) -> list[CookieRecord]: spec = _browser_spec(browser) db = resolve_cookie_db(browser, platform, base_override=base_override) if spec["kind"] == "firefox": return extract_firefox(db, domains) reader = keyring_reader or default_keyring_reader(platform, spec) safe_storage = spec["safe_storage"] if safe_storage is None: raise UnsupportedPlatform(f"browser {browser!r} has no keyring storage name") key = derive_key(platform, reader(safe_storage)) return extract_chromium(db, domains, platform, key) def inject_cookies(cookies: list[CookieRecord], cdp_port: int) -> None: filtered = [c for c in cookies if c["name"] in IMPORTANT_COOKIES] or cookies payload: list[CdpCookie] = [ { "name": c["name"], "value": c["value"], "domain": c["domain"], "path": c.get("path") or "/", "secure": bool(c.get("secure")), "httpOnly": bool(c.get("httpOnly")), "sameSite": c.get("sameSite", "Lax"), **({"expires": int(c["expires"])} if c.get("expires") and int(c["expires"]) > 0 else {}), } for c in filtered ] proc = subprocess.run( ["node", "-e", _CDP_SET_COOKIES_SCRIPT, str(cdp_port)], input=json.dumps(payload), capture_output=True, text=True, timeout=15, ) if proc.returncode != 0: raise RuntimeError((proc.stderr or "CDP cookie injection failed").strip()) ok = int((proc.stdout or "0").strip() or "0") print(f"Injected {ok}/{len(filtered)} cookies into local Chrome (playwright-core CDP {cdp_port})") def main() -> None: parser = argparse.ArgumentParser(description="Extract browser cookies (cross-platform)") parser.add_argument("--browser", required=True, choices=sorted(BROWSERS.keys())) parser.add_argument("--domain", required=True, action="append", dest="domains") parser.add_argument("--output", help="Write cookies JSON to file") parser.add_argument("--inject", action="store_true", help="Inject into the local Chrome CDP endpoint used by a playwright-core script") parser.add_argument("--cdp", type=int, default=9242, help="CDP port (default 9242)") args = parser.parse_args() cookies = extract_cookies(args.browser, args.domains) print(f"Extracted {len(cookies)} cookies from {args.browser}") if args.output: write_cookie_file(Path(args.output), cookies) print(f"Saved to {args.output}") if args.inject: inject_cookies(cookies, args.cdp) if __name__ == "__main__": main()
-
-
.gitignore 29 B · in bundle
-
ATTRIBUTION.md 2.5 KB
# ATTRIBUTION / NOTICE This skill (`ultimate-browsing`, part of `@oh-my-opencode/shared-skills`) ships project-original content and one vendored-and-modified upstream engine. Each component's provenance, license, and required notices are reproduced below. --- ## 1. insane-search engine — vendored upstream snapshot, modified `engine/**` originates from the **insane-search** project and is NOT project-original code, despite being heavily modified since import. - Upstream source: https://github.com/fivetaku/insane-search - Vendored into this repository on 2026-06-21 by commit **`a4e4ed797`** (`feat(ultimate-browsing): vendor insane-search engine (junk-excluded)`), via an explicit file whitelist that excluded caches and smoke-test junk. - Baseline: the upstream state as of that date, a **pre-0.7.0 snapshot**. Upstream's CHANGELOG dates 0.7.0 to 2026-06-22; imported files carry no version marker. We have never re-vendored since; the tree has diverged in both directions. - Modifications by this project (non-exhaustive): de-personalization (`4743199a5`), the Phase 2.5 surrogate retrieval stage and surrogate registry, the provenance/trust result contract, the `bias_check.py` no-site-name CI gate, module split of the fetch chain, and the Python test suite under `engine/tests/`. - No upstream `LICENSE` file was included in the vendored snapshot, so this repository has no upstream license text to reproduce here. Do not infer project-original licensing from that absence; treat `engine/**` as upstream-derived when reasoning about provenance. The binding version policy — which upstream baseline we sit on, why we stay pinned, what a future re-vendor must preserve, and what it must not import — is [`engine/AGENTS.md` §UPSTREAM BASELINE AND VERSION POLICY](engine/AGENTS.md). --- ## 2. Project-original content (no third-party source vendored) The following are authored by the oh-my-openagent project and carry no third-party license obligation: - `references/insane-search/**` and `references/agent-reach/**` — the Tier-1 and Tier-1.5 reference docs. - `scripts/extract_cookies.py`, `scripts/cookie_paths.py`, `scripts/cookie_crypto.py` and their tests — the cross-platform cookie module. - `SKILL.md`, `references/chrome-stealth.md`. These reference platform-native CLIs and public APIs by name (e.g. `xhs`, `yt-dlp`, `agent-reach`, `mcporter`, Jina Reader, V2EX public API). Those are external tools the user installs separately; this skill includes none of their source. -
SKILL.md 10.7 KB
--- name: ultimate-browsing description: "Renders, drives, and screenshots web pages: JS-rendered sources, clicks and forms, persistent logins, WAF-blocked hosts (platform-native readers, stealth Chrome), and the browsing lane of a research run, with screenshots as provenance. Not for plain search or unblocked static fetch." --- # Ultimate Browsing Web access for everything a plain fetch cannot finish: a page that renders in JS, a click or a form, a screenshot, a login that must persist across pages, or a host that blocks generic fetchers (WAF / 403 / Cloudflare). Start at the cheapest tier that can do the job and climb only when it cannot: **Tier 1 — insane-search** (headless extraction + WAF bypass) -> **Tier 1.5 — agent-reach** (platform-native APIs, esp. Chinese platforms) -> **Tier 2 — a real browser** through omowright from js eval: 2a the owned engine (a browser your code launches, CloakBrowser for stealth), 2b the attached engine (the user's own signed-in browser). ## PHASE 0 — ROUTE FIRST (MANDATORY) ``` User request | +- extract text/data from a URL --------------------- TIER 1 insane-search +- URL blocked / 403 / Cloudflare / WAF ------------- TIER 1 insane-search +- YouTube/Vimeo/TikTok subtitles or metadata ------- TIER 1 insane-search (yt-dlp) +- read an article / blog / Reddit / HN / arXiv ----- TIER 1 insane-search | +- Chinese platform (xhs/douyin/weibo/bilibili/v2ex/wechat) TIER 1.5 agent-reach +- podcast transcript / stock forum ----------------- TIER 1.5 agent-reach +- Twitter feed / LinkedIn profile / GitHub via CLI - TIER 1.5 agent-reach | +- Tier 1/1.5 returned empty or partial ------------- TIER 2 2a owned engine -> 2b attached engine +- click / fill form / scroll / interact ------------ TIER 2 2a owned engine -> 2b attached engine +- screenshot / render / play video ----------------- TIER 2 2a owned engine -> 2b attached engine +- login session across pages / the user's account --- TIER 2 2b attached engine (their browser) +- test web app / QA / dogfood ---------------------- TIER 2 2a owned engine -> 2b attached engine | +- simple search query ------------------------------ NOT this skill (use web-search) ``` Read the matching reference before acting: [`references/insane-search/README.md`](references/insane-search/README.md), [`references/agent-reach/README.md`](references/agent-reach/README.md), or [`references/chrome-stealth.md`](references/chrome-stealth.md). ## Tier 1 — insane-search (headless extraction) **When**: content extraction, blocked-URL bypass, media metadata — no browser UI needed. **Why first**: ~10x faster than a browser, no process spin-up; handles most "fetch this blocked page" requests via curl_cffi TLS impersonation, yt-dlp (1858 sites), official public APIs, mobile URL transforms, **Phase-2.5 surrogate archives** (Wayback / archive.today snapshots, provenance-tagged — see [`references/insane-search/cache-archive.md`](references/insane-search/cache-archive.md)), a key-gated Jina Reader (`JINA_API_KEY`), and a Playwright real-Chrome fallback. The engine lives **inside this skill** at `engine/` and is invoked as a module. Surrogate results are dated COPIES: a result whose `provenance` is `snapshot` must be reported with its `snapshot_timestamp`, never presented as the live page. ```bash # Core command — auto-detects WAF, runs the full fetch grid (run from the skill dir): python3 -m engine "https://example.com/blocked-page" # add --selector "<CSS>" for positive-proof validation, --device auto|desktop|mobile, # --trace to inspect every attempt, --json for machine-readable output. # YouTube subtitles / metadata (no browser): yt-dlp --write-sub --write-auto-sub --sub-lang "en,ko" --skip-download -o "/tmp/%(id)s" "<URL>" # Reddit / HN / Bluesky / arXiv etc. use official public endpoints — see the Phase 0 index in # references/insane-search/README.md (Twitter syndication, Reddit .json, HN Firebase, ...). ``` The full engine harness (rules R1-R7, the Phase 0 official-API index, the no-site-name rule, and the `references/insane-search/*.md` deep-dives for TLS, Playwright routing, Naver, media, etc.) is in [`references/insane-search/README.md`](references/insane-search/README.md). Read it before tuning the engine or adding a WAF profile. ### Escalate to Tier 1.5 or Tier 2 when - The target is a Chinese / social platform with a native reader -> Tier 1.5. - insane-search returns empty/partial, or the page needs JS interaction, a screenshot, a persistent login, or media playback -> Tier 2. ## Tier 1.5 — agent-reach (platform-native readers) **When**: the target is a platform with a first-class API/CLI that beats generic fetching — especially Chinese platforms that stealth browsers still cannot reach cleanly. Several channels are zero-config (Douyin, V2EX, Reddit, RSS, YouTube); others need a one-time auth you supply via environment variables if you have access (`JINA_API_KEY` for Jina Reader — anonymous access is dead, see `references/insane-search/jina.md`; `TWITTER_*` for X; a transcription key for podcasts). | Category | Platforms | Entry | |---|---|---| | social | xhs (Xiaohongshu), douyin, weibo, bilibili, V2EX, Reddit, Twitter/X | [references/agent-reach/social.md](references/agent-reach/social.md) | | web | Jina Reader, WeChat articles, RSS | [references/agent-reach/web.md](references/agent-reach/web.md) | | video | YouTube, Bilibili, podcast transcripts, Douyin video | [references/agent-reach/video.md](references/agent-reach/video.md) | | career | LinkedIn | [references/agent-reach/career.md](references/agent-reach/career.md) | | dev | GitHub (gh CLI) | [references/agent-reach/dev.md](references/agent-reach/dev.md) | | search | Exa AI | [references/agent-reach/search.md](references/agent-reach/search.md) | ```bash mcporter call 'douyin.parse_douyin_video_info(url: "<URL>")' # douyin, zero-config curl -s "https://r.jina.ai/https://weibo.com/<uid>/<pid>" # weibo via Jina yt-dlp --dump-json "<bilibili-url>" # Bilibili (overseas: add --cookies-from-browser) curl -s "https://www.v2ex.com/api/topics/hot.json" # V2EX public API ``` Routing table, per-platform auth (set `TWITTER_*` env vars, `gh auth login`, a transcription key — only if you have access), rate-limit notes, and known version quirks are in [references/agent-reach/README.md](references/agent-reach/README.md). ## Tier 2 — a real browser (real interaction) **When**: real interaction is needed (clicks, forms, screenshots, video, persistent login), or Tier 1/1.5 failed. Both tiers are omowright, staged inside the `browser` skill and loaded from js eval: ```js const { loadOmowright } = await import("<browser-skill-root>/scripts/omowright.mjs") const { omowright } = await loadOmowright() ``` ### Tier 2a — owned engine (default) A browser your code launches with a task-owned profile. `connectPipe` opens no listening port; `connectCloakProfile` launches CloakBrowser with a pinned fingerprint seed and is the path for WAF, Cloudflare and bot-scored pages. ```js const browser = await omowright.connectPipe({ browserPath, browserArgs: ["--headless", `--user-data-dir=${profile}`], storageRoot: profile }) try { const page = await browser.newTab(url) const tree = omowright.compactSnapshot(await page.snapshot()) // the read; refs come from it const snoop = omowright.createNetworkSnoop(page) // read the API JSON instead of the DOM when there is one await page.locator("e3").click() await Bun.write(pngPath, await page.screenshot()) } finally { await browser.close() // then rm -rf the profile } ``` The rest of the surface (CUA coordinates, captcha solving, routes, traces, frames, human handoff) is in the `browser` skill's `references/owned-engine/`. A stealth binary is not proof of access: inspect the rendered result and report challenges that remain. ### Tier 2b — attached engine (logged-in pages) When the page needs the user's account, drive the browser they are already signed into instead of cloning their profile: `connectBrowserSkill()` → `session.navigate` → `bskSnapshot(session)` / `session.observe()` → `session.click` → `session.stop()`. NEVER launch against or clear cookies/cache/site data from the user's live profile, and never fall back to the owned engine for an authenticated criterion: if no extension is connected, run the `browser` skill's onboarding script and relay its one human step. The full loop is the `browser` skill. ### Cookie login (cross-platform) `scripts/extract_cookies.py` reads cookies from a local Chromium-family or Firefox-family browser and optionally injects them into the running CDP session. It resolves browser profile paths and decrypts cookie values per-OS (macOS Keychain, Linux libsecret, Windows DPAPI): ```bash # Extract cookies to a file: mkdir -p ~/.local/state/omo-cookies python3 scripts/extract_cookies.py --browser chrome --domain youtube.com --output ~/.local/state/omo-cookies/youtube.cookies.json # Extract and inject into the running CDP session: python3 scripts/extract_cookies.py --browser chrome --domain youtube.com --inject --cdp 9242 ``` Cookie export files are written with owner-only `0600` permissions. Do not place live auth cookies in shared temp directories or commit them to a repo. Cookie injection sends values to CDP over stdin rather than argv. Cookies apply on next navigation — reload after injecting. Google services use fingerprint-bound tokens that may not transfer across browser profiles. Limits in [references/chrome-stealth.md](references/chrome-stealth.md). ## Reference docs | File | When to read | |------|-------------| | [references/insane-search/README.md](references/insane-search/README.md) | Tier-1 engine harness (R1-R7, Phase 0 API index, no-site-name rule) + its `*.md` deep-dives | | [references/agent-reach/README.md](references/agent-reach/README.md) | Tier-1.5 routing table, platform auth, per-category `*.md` | | [references/chrome-stealth.md](references/chrome-stealth.md) | Tier-2 stealth through omowright + CloakBrowser, cookie login limits | ## Environment variables ```bash # agent-reach auth: set the channel-specific env vars from each tool's docs only if you have access # insane-search needs no env vars — it auto-installs deps on first run ``` ## Anti-patterns - Do NOT launch Chrome stealth for plain text extraction — use Tier 1. - Use stealth plugins only in an explicitly installed script environment, not injected into WebView. - Close every WebView/browser context when done and remove only task-owned profile clones. - Do NOT inject cookies without reloading the page. - Do NOT hardcode site domains/selectors into `engine/**` or `waf_profiles.yaml` — runtime hints only (see the no-site-name rule in the insane-search reference).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.