agent-wire-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_wire/__init__.py +1 -0
- agent_wire/__main__.py +3 -0
- agent_wire/assets/SKILL.md +26 -0
- agent_wire/assets/cursor_rule.mdc +13 -0
- agent_wire/assets/hermes_skill.md +28 -0
- agent_wire/assets/windsurf_rules.md +14 -0
- agent_wire/assets/wire-worker.md +20 -0
- agent_wire/auth/__init__.py +0 -0
- agent_wire/auth/browser_paths.py +143 -0
- agent_wire/auth/cookie_vault.py +181 -0
- agent_wire/auth/rate_limiter.py +26 -0
- agent_wire/cache/__init__.py +0 -0
- agent_wire/cache/ring_buffer.py +94 -0
- agent_wire/cache/sqlite_ledger.py +132 -0
- agent_wire/channels/__init__.py +41 -0
- agent_wire/channels/base.py +249 -0
- agent_wire/channels/tier1_global/__init__.py +0 -0
- agent_wire/channels/tier1_global/github.py +371 -0
- agent_wire/channels/tier1_global/hackernews.py +135 -0
- agent_wire/channels/tier1_global/reddit.py +371 -0
- agent_wire/channels/tier1_global/rss.py +129 -0
- agent_wire/channels/tier1_global/search.py +160 -0
- agent_wire/channels/tier1_global/stackoverflow.py +94 -0
- agent_wire/channels/tier1_global/web.py +98 -0
- agent_wire/channels/tier1_global/wikipedia.py +91 -0
- agent_wire/channels/tier1_global/x_twitter.py +380 -0
- agent_wire/channels/tier1_global/youtube.py +223 -0
- agent_wire/channels/tier2_pro/__init__.py +0 -0
- agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
- agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
- agent_wire/channels/tier2_pro/devto.py +114 -0
- agent_wire/channels/tier2_pro/fediverse.py +181 -0
- agent_wire/channels/tier2_pro/hubs.py +161 -0
- agent_wire/channels/tier2_pro/linkedin.py +146 -0
- agent_wire/channels/tier2_pro/markets.py +207 -0
- agent_wire/channels/tier2_pro/meta_social.py +104 -0
- agent_wire/channels/tier2_pro/news.py +173 -0
- agent_wire/channels/tier2_pro/polymarket.py +105 -0
- agent_wire/channels/tier2_pro/publishing.py +84 -0
- agent_wire/channels/tier2_pro/stocktwits.py +79 -0
- agent_wire/channels/tier3_regional/bilibili.py +171 -0
- agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
- agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
- agent_wire/channels/tier3_regional/v2ex.py +70 -0
- agent_wire/channels/tier3_regional/wechat.py +67 -0
- agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
- agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
- agent_wire/channels/tier3_regional/xueqiu.py +77 -0
- agent_wire/channels/tier3_regional/zhihu.py +57 -0
- agent_wire/cli.py +403 -0
- agent_wire/compression/__init__.py +0 -0
- agent_wire/compression/anchor_slicer.py +45 -0
- agent_wire/compression/bm25_slicer.py +151 -0
- agent_wire/compression/dam_formatter.py +74 -0
- agent_wire/compression/url_compactor.py +45 -0
- agent_wire/config.py +52 -0
- agent_wire/engine/__init__.py +0 -0
- agent_wire/engine/challenge.py +58 -0
- agent_wire/engine/http_pool.py +136 -0
- agent_wire/engine/stealth_browser.py +90 -0
- agent_wire/engine/waterfall.py +159 -0
- agent_wire/firewall/__init__.py +0 -0
- agent_wire/firewall/inbound_shield.py +116 -0
- agent_wire/firewall/outbound_privacy.py +120 -0
- agent_wire/installers/__init__.py +0 -0
- agent_wire/installers/client_installer.py +315 -0
- agent_wire/media/__init__.py +0 -0
- agent_wire/media/audio_transcriber.py +77 -0
- agent_wire/media/subtitle_cleaner.py +102 -0
- agent_wire/models.py +60 -0
- agent_wire/research/__init__.py +1 -0
- agent_wire/research/brief.py +227 -0
- agent_wire/research/briefing.py +41 -0
- agent_wire/research/cluster.py +128 -0
- agent_wire/research/engine.py +632 -0
- agent_wire/research/library.py +74 -0
- agent_wire/research/planner.py +232 -0
- agent_wire/research/scoring.py +89 -0
- agent_wire/research/store.py +149 -0
- agent_wire/research/watch.py +70 -0
- agent_wire/router.py +107 -0
- agent_wire/server.py +468 -0
- agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
- agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
- agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
- agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
- agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""L2 on-disk cache: per-domain winning waterfall stage + 500-row circular HTTP cache."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import zstandard
|
|
9
|
+
|
|
10
|
+
ROUTE_TTL_S = 24 * 3600
|
|
11
|
+
|
|
12
|
+
# rowid breaks updated_at ties (many inserts within one second) so the oldest row goes first.
|
|
13
|
+
SCHEMA = """
|
|
14
|
+
CREATE TABLE IF NOT EXISTS domain_routes (
|
|
15
|
+
domain TEXT PRIMARY KEY,
|
|
16
|
+
winning_stage INTEGER NOT NULL,
|
|
17
|
+
cf_clearance TEXT,
|
|
18
|
+
user_agent TEXT,
|
|
19
|
+
updated_at INTEGER NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS http_etag_cache (
|
|
22
|
+
url_hash TEXT PRIMARY KEY,
|
|
23
|
+
url TEXT NOT NULL,
|
|
24
|
+
etag TEXT,
|
|
25
|
+
last_modified TEXT,
|
|
26
|
+
content_hash TEXT NOT NULL,
|
|
27
|
+
compressed_body BLOB,
|
|
28
|
+
updated_at INTEGER NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE TRIGGER IF NOT EXISTS limit_etag_cache_size
|
|
31
|
+
AFTER INSERT ON http_etag_cache
|
|
32
|
+
WHEN (SELECT COUNT(*) FROM http_etag_cache) > 500
|
|
33
|
+
BEGIN
|
|
34
|
+
DELETE FROM http_etag_cache
|
|
35
|
+
WHERE url_hash IN (
|
|
36
|
+
SELECT url_hash FROM http_etag_cache
|
|
37
|
+
ORDER BY updated_at ASC, rowid ASC
|
|
38
|
+
LIMIT (SELECT COUNT(*) - 500 FROM http_etag_cache)
|
|
39
|
+
);
|
|
40
|
+
END;
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Ledger:
|
|
45
|
+
def __init__(self, path: Path):
|
|
46
|
+
self._db = sqlite3.connect(path)
|
|
47
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
48
|
+
self._db.execute("PRAGMA synchronous=NORMAL")
|
|
49
|
+
self._db.executescript(SCHEMA)
|
|
50
|
+
self._zc = zstandard.ZstdCompressor(level=3)
|
|
51
|
+
self._zd = zstandard.ZstdDecompressor()
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self._db.close()
|
|
55
|
+
|
|
56
|
+
def get_winning_stage(self, domain: str, ttl_s: int = ROUTE_TTL_S) -> int | None:
|
|
57
|
+
row = self._db.execute(
|
|
58
|
+
"SELECT winning_stage FROM domain_routes WHERE domain = ? AND updated_at > ?",
|
|
59
|
+
(domain, int(time.time()) - ttl_s),
|
|
60
|
+
).fetchone()
|
|
61
|
+
return row[0] if row else None
|
|
62
|
+
|
|
63
|
+
def get_route(self, domain: str, ttl_s: int = ROUTE_TTL_S) -> dict | None:
|
|
64
|
+
row = self._db.execute(
|
|
65
|
+
"SELECT winning_stage, cf_clearance, user_agent FROM domain_routes"
|
|
66
|
+
" WHERE domain = ? AND updated_at > ?",
|
|
67
|
+
(domain, int(time.time()) - ttl_s),
|
|
68
|
+
).fetchone()
|
|
69
|
+
if row is None:
|
|
70
|
+
return None
|
|
71
|
+
return {"stage": row[0], "cf_clearance": row[1], "user_agent": row[2]}
|
|
72
|
+
|
|
73
|
+
def routes(self) -> list[tuple[str, int]]:
|
|
74
|
+
return self._db.execute(
|
|
75
|
+
"SELECT domain, winning_stage FROM domain_routes ORDER BY updated_at DESC"
|
|
76
|
+
).fetchall()
|
|
77
|
+
|
|
78
|
+
def clear_route(self, domain: str) -> None:
|
|
79
|
+
with self._db:
|
|
80
|
+
self._db.execute("DELETE FROM domain_routes WHERE domain = ?", (domain,))
|
|
81
|
+
|
|
82
|
+
def set_winning_stage(
|
|
83
|
+
self,
|
|
84
|
+
domain: str,
|
|
85
|
+
stage: int,
|
|
86
|
+
cf_clearance: str | None = None,
|
|
87
|
+
user_agent: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
with self._db:
|
|
90
|
+
self._db.execute(
|
|
91
|
+
"INSERT OR REPLACE INTO domain_routes VALUES (?, ?, ?, ?, ?)",
|
|
92
|
+
(domain, stage, cf_clearance, user_agent, int(time.time())),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def get_cached(self, url: str) -> dict | None:
|
|
96
|
+
row = self._db.execute(
|
|
97
|
+
"SELECT etag, last_modified, content_hash, compressed_body"
|
|
98
|
+
" FROM http_etag_cache WHERE url_hash = ?",
|
|
99
|
+
(_url_hash(url),),
|
|
100
|
+
).fetchone()
|
|
101
|
+
if row is None:
|
|
102
|
+
return None
|
|
103
|
+
etag, last_modified, content_hash, blob = row
|
|
104
|
+
body = self._zd.decompress(blob) if blob is not None else None
|
|
105
|
+
return {
|
|
106
|
+
"etag": etag,
|
|
107
|
+
"last_modified": last_modified,
|
|
108
|
+
"content_hash": content_hash,
|
|
109
|
+
"body": body,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def put_cached(
|
|
113
|
+
self,
|
|
114
|
+
url: str,
|
|
115
|
+
content_hash: str,
|
|
116
|
+
body: bytes | None = None,
|
|
117
|
+
etag: str | None = None,
|
|
118
|
+
last_modified: str | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
blob = self._zc.compress(body) if body is not None else None
|
|
121
|
+
with self._db:
|
|
122
|
+
self._db.execute(
|
|
123
|
+
"INSERT OR REPLACE INTO http_etag_cache VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
124
|
+
(_url_hash(url), url, etag, last_modified, content_hash, blob, int(time.time())),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def cache_rows(self) -> int:
|
|
128
|
+
return self._db.execute("SELECT COUNT(*) FROM http_etag_cache").fetchone()[0]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _url_hash(url: str) -> str:
|
|
132
|
+
return hashlib.sha256(url.encode()).hexdigest()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Lazy channel registry: modules import on first use so `wire serve` starts fast."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
MODULES = {
|
|
6
|
+
"web": "tier1_global.web",
|
|
7
|
+
"search": "tier1_global.search",
|
|
8
|
+
"github": "tier1_global.github",
|
|
9
|
+
"reddit": "tier1_global.reddit",
|
|
10
|
+
"x": "tier1_global.x_twitter",
|
|
11
|
+
"youtube": "tier1_global.youtube",
|
|
12
|
+
"hackernews": "tier1_global.hackernews",
|
|
13
|
+
"stackoverflow": "tier1_global.stackoverflow",
|
|
14
|
+
"wikipedia": "tier1_global.wikipedia",
|
|
15
|
+
"rss": "tier1_global.rss",
|
|
16
|
+
"arxiv": "tier2_pro.arxiv_papers",
|
|
17
|
+
"linkedin": "tier2_pro.linkedin",
|
|
18
|
+
"hubs": "tier2_pro.hubs",
|
|
19
|
+
"markets": "tier2_pro.markets",
|
|
20
|
+
"meta_social": "tier2_pro.meta_social",
|
|
21
|
+
"publishing": "tier2_pro.publishing",
|
|
22
|
+
"fediverse": "tier2_pro.fediverse",
|
|
23
|
+
"archive": "tier2_pro.archive_bypass",
|
|
24
|
+
"polymarket": "tier2_pro.polymarket",
|
|
25
|
+
"news": "tier2_pro.news",
|
|
26
|
+
"stocktwits": "tier2_pro.stocktwits",
|
|
27
|
+
"devto": "tier2_pro.devto",
|
|
28
|
+
"bilibili": "tier3_regional.bilibili",
|
|
29
|
+
"xiaohongshu": "tier3_regional.xiaohongshu",
|
|
30
|
+
"wechat": "tier3_regional.wechat",
|
|
31
|
+
"zhihu": "tier3_regional.zhihu",
|
|
32
|
+
"v2ex": "tier3_regional.v2ex",
|
|
33
|
+
"douyin_tiktok": "tier3_regional.douyin_tiktok",
|
|
34
|
+
"xiaoyuzhou": "tier3_regional.xiaoyuzhou",
|
|
35
|
+
"xueqiu": "tier3_regional.xueqiu",
|
|
36
|
+
"boss_zhipin": "tier3_regional.boss_zhipin",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def channel_class(name: str):
|
|
41
|
+
return importlib.import_module(f"agent_wire.channels.{MODULES[name]}").CHANNEL
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Channel interface, shared context and helpers used by every platform module."""
|
|
2
|
+
|
|
3
|
+
import html as htmllib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
11
|
+
|
|
12
|
+
from agent_wire.engine.http_pool import Fetched, HttpPool
|
|
13
|
+
from agent_wire.engine.waterfall import Waterfall
|
|
14
|
+
from agent_wire.firewall.inbound_shield import clean_html
|
|
15
|
+
from agent_wire.firewall.outbound_privacy import parse_json
|
|
16
|
+
from agent_wire.models import WireDocument, WireSearchHit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WireUnsupported(Exception):
|
|
20
|
+
"""The channel can't do this (yet, or without a login); the message says what to do."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Context:
|
|
25
|
+
pool: HttpPool
|
|
26
|
+
waterfall: Waterfall
|
|
27
|
+
vault: object | None = None
|
|
28
|
+
ledger: object | None = None
|
|
29
|
+
_channels: dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def channel(self, name: str) -> "Channel":
|
|
32
|
+
if name not in self._channels:
|
|
33
|
+
from agent_wire.channels import channel_class
|
|
34
|
+
|
|
35
|
+
self._channels[name] = channel_class(name)(self)
|
|
36
|
+
return self._channels[name]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Channel:
|
|
40
|
+
name: ClassVar[str] = ""
|
|
41
|
+
|
|
42
|
+
def __init__(self, ctx: Context):
|
|
43
|
+
self.ctx = ctx
|
|
44
|
+
|
|
45
|
+
async def read(
|
|
46
|
+
self, url: str, *, query: str | None = None, section: str | None = None,
|
|
47
|
+
include_comments: bool = True,
|
|
48
|
+
) -> WireDocument: # fmt: skip
|
|
49
|
+
raise WireUnsupported(f"{self.name} can't read URLs")
|
|
50
|
+
|
|
51
|
+
async def search(
|
|
52
|
+
self, query: str, *, limit: int = 5, sort: str = "relevance"
|
|
53
|
+
) -> list[WireSearchHit]:
|
|
54
|
+
raise WireUnsupported(f"{self.name} has no search; try platforms=['web']")
|
|
55
|
+
|
|
56
|
+
async def feed(self, target: str, *, sort: str = "hot", limit: int = 10) -> WireDocument:
|
|
57
|
+
raise WireUnsupported(f"{self.name} has no feeds")
|
|
58
|
+
|
|
59
|
+
async def me(self, target: str = "latest_posts", *, limit: int = 5) -> WireDocument:
|
|
60
|
+
raise WireUnsupported(f"{self.name} has no account inspection")
|
|
61
|
+
|
|
62
|
+
# ---- helpers -----------------------------------------------------------------------------
|
|
63
|
+
async def fetch(self, url: str, **kw) -> Fetched:
|
|
64
|
+
kw.setdefault("channel", self.name)
|
|
65
|
+
return await self.ctx.waterfall.fetch(url, **kw)
|
|
66
|
+
|
|
67
|
+
async def fetch_json(self, url: str, **kw):
|
|
68
|
+
"""API call: no archive/browser stages; identity keys stripped (outbound layer 3)."""
|
|
69
|
+
kw.setdefault("archive", False)
|
|
70
|
+
kw.setdefault("browser", False)
|
|
71
|
+
f = await self.fetch(url, **kw)
|
|
72
|
+
return parse_json(f.content), f
|
|
73
|
+
|
|
74
|
+
def doc(self, f: Fetched | None, url: str, **fields) -> WireDocument:
|
|
75
|
+
if f is not None:
|
|
76
|
+
fields.setdefault("stage", f.stage)
|
|
77
|
+
fields.setdefault("auth", f.auth)
|
|
78
|
+
fields.setdefault("raw_bytes", len(f.content))
|
|
79
|
+
return WireDocument(url=url, channel=self.name, **fields)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ts(value) -> datetime | None:
|
|
83
|
+
"""Epoch seconds/ms or ISO-8601 -> aware datetime."""
|
|
84
|
+
if value in (None, ""):
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
if isinstance(value, int | float) or str(value).isdigit():
|
|
88
|
+
v = float(value)
|
|
89
|
+
return datetime.fromtimestamp(v / 1000 if v > 1e11 else v, UTC)
|
|
90
|
+
return datetime.fromisoformat(str(value))
|
|
91
|
+
except (ValueError, OSError, OverflowError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_REL = re.compile(r"(\d+)\s*(second|minute|hour|day|week|month|year)s?\s+ago", re.IGNORECASE)
|
|
96
|
+
_REL_S = {"second": 1, "minute": 60, "hour": 3600, "day": 86400, "week": 7 * 86400,
|
|
97
|
+
"month": 30 * 86400, "year": 365 * 86400} # fmt: skip
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def relative_ts(text: str | None, now: datetime | None = None) -> datetime | None:
|
|
101
|
+
"""'3 weeks ago' / 'Streamed 2 days ago' -> approximate aware datetime."""
|
|
102
|
+
m = _REL.search(text or "")
|
|
103
|
+
if not m:
|
|
104
|
+
return None
|
|
105
|
+
return (now or datetime.now(UTC)) - timedelta(seconds=int(m[1]) * _REL_S[m[2].lower()])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def listing(items: list[dict]) -> str:
|
|
109
|
+
"""Compact DAM list for feeds/search: one line per item plus an optional indented snippet."""
|
|
110
|
+
lines = []
|
|
111
|
+
for i, it in enumerate(items, 1):
|
|
112
|
+
bits = [b for b in (it.get("meta") or ()) if b not in (None, "")]
|
|
113
|
+
title = " ".join(str(it.get("title") or it.get("url")).split()) # newlines break the link
|
|
114
|
+
head = f"{i}. [{title}]({it.get('url')})"
|
|
115
|
+
lines.append(head + (f" · {' · '.join(map(str, bits))}" if bits else ""))
|
|
116
|
+
if snippet := (it.get("snippet") or "").strip():
|
|
117
|
+
lines.append(" " + " ".join(snippet.split())[:280])
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def compact_count(text: str | int | None) -> int:
|
|
122
|
+
"""'1.2K' / '3,400' / '2M likes' -> int."""
|
|
123
|
+
if text is None:
|
|
124
|
+
return 0
|
|
125
|
+
if isinstance(text, int):
|
|
126
|
+
return text
|
|
127
|
+
m = re.search(r"([\d.,]+)\s*([KMB万亿])?", str(text), re.IGNORECASE)
|
|
128
|
+
if not m:
|
|
129
|
+
return 0
|
|
130
|
+
num = float(m[1].replace(",", "")) if m[1].replace(",", "").replace(".", "").isdigit() else 0
|
|
131
|
+
mult = {"K": 1e3, "M": 1e6, "B": 1e9, "万": 1e4, "亿": 1e8}.get((m[2] or "").upper(), 1)
|
|
132
|
+
return int(num * mult)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---- HTML -> compact Markdown -------------------------------------------------------------------
|
|
136
|
+
_BLOCK = frozenset(
|
|
137
|
+
{"p", "div", "section", "article", "main", "header", "footer", "figure", "details", "summary",
|
|
138
|
+
"dl", "dd", "dt", "tr", "table", "tbody", "thead"}
|
|
139
|
+
) # fmt: skip
|
|
140
|
+
_SKIP = frozenset({"img", "svg", "picture", "video", "audio", "iframe", "button", "form", "nav"})
|
|
141
|
+
_WS = re.compile(r"[ \t\r\f\v]+")
|
|
142
|
+
_BLANKS = re.compile(r"\n{3,}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def html_to_markdown(html: str, *, links: bool = True) -> str:
|
|
146
|
+
"""Untrusted HTML fragment -> Markdown, after the inbound shield removed hidden nodes."""
|
|
147
|
+
if not html:
|
|
148
|
+
return ""
|
|
149
|
+
tree = LexborHTMLParser(clean_html(html))
|
|
150
|
+
root = tree.body or tree.root
|
|
151
|
+
out = _md(root, links) if root else ""
|
|
152
|
+
return _BLANKS.sub("\n\n", "\n".join(ln.rstrip() for ln in out.splitlines())).strip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _md(node, links: bool) -> str:
|
|
156
|
+
parts = []
|
|
157
|
+
for child in node.iter(include_text=True):
|
|
158
|
+
tag = child.tag
|
|
159
|
+
if tag == "-text":
|
|
160
|
+
parts.append(
|
|
161
|
+
_WS.sub(" ", htmllib.unescape(child.text_content or "")).replace("\n", " ")
|
|
162
|
+
)
|
|
163
|
+
elif tag in _SKIP or tag.startswith("-"):
|
|
164
|
+
continue
|
|
165
|
+
elif tag == "pre":
|
|
166
|
+
parts.append(f"\n\n```\n{child.text(deep=True).strip(chr(10))}\n```\n\n")
|
|
167
|
+
elif tag == "code":
|
|
168
|
+
parts.append(f"`{child.text(deep=True)}`")
|
|
169
|
+
elif tag == "br":
|
|
170
|
+
parts.append("\n")
|
|
171
|
+
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
|
172
|
+
parts.append(f"\n\n{'#' * int(tag[1])} {_md(child, links).strip()}\n\n")
|
|
173
|
+
elif tag == "li":
|
|
174
|
+
parts.append(f"\n- {_md(child, links).strip()}")
|
|
175
|
+
elif tag in ("ul", "ol"):
|
|
176
|
+
parts.append(f"\n{_md(child, links)}\n")
|
|
177
|
+
elif tag == "blockquote":
|
|
178
|
+
inner = _md(child, links).strip()
|
|
179
|
+
parts.append("\n\n" + "\n".join(f"> {ln}" for ln in inner.splitlines()) + "\n\n")
|
|
180
|
+
elif tag == "a":
|
|
181
|
+
text = _md(child, links).strip()
|
|
182
|
+
href = child.attributes.get("href") or ""
|
|
183
|
+
use = links and href.startswith("http") and text and text != href
|
|
184
|
+
parts.append(f"[{text}]({href})" if use else text or href)
|
|
185
|
+
elif tag in ("td", "th"):
|
|
186
|
+
parts.append(f" {_md(child, links).strip()} |")
|
|
187
|
+
elif tag == "tr":
|
|
188
|
+
parts.append(f"\n|{_md(child, links)}")
|
|
189
|
+
elif tag in _BLOCK:
|
|
190
|
+
parts.append(f"\n\n{_md(child, links).strip()}\n\n")
|
|
191
|
+
else:
|
|
192
|
+
parts.append(_md(child, links))
|
|
193
|
+
return "".join(parts)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def html_text(html: str) -> str:
|
|
197
|
+
return " ".join(LexborHTMLParser(clean_html(html)).text(separator=" ").split()) if html else ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---- structured data embedded in pages ----------------------------------------------------------
|
|
201
|
+
_JSON = json.JSONDecoder()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def meta_tags(html: str) -> dict[str, str]:
|
|
205
|
+
"""og:/twitter:/name= meta tags, lower-cased keys."""
|
|
206
|
+
out = {}
|
|
207
|
+
for m in LexborHTMLParser(html).css("meta[property], meta[name]"):
|
|
208
|
+
key = (m.attributes.get("property") or m.attributes.get("name") or "").lower()
|
|
209
|
+
if key and (content := m.attributes.get("content")):
|
|
210
|
+
out.setdefault(key, htmllib.unescape(content))
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def json_ld(html: str) -> list[dict]:
|
|
215
|
+
items = []
|
|
216
|
+
for node in LexborHTMLParser(html).css('script[type="application/ld+json"]'):
|
|
217
|
+
try:
|
|
218
|
+
data = json.loads(node.text())
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
for d in data if isinstance(data, list) else [data]:
|
|
222
|
+
if isinstance(d, dict):
|
|
223
|
+
items += d["@graph"] if isinstance(d.get("@graph"), list) else [d]
|
|
224
|
+
return items
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def script_json(html: str, selector: str):
|
|
228
|
+
"""JSON inside a <script> matched by CSS selector (e.g. script#__NEXT_DATA__)."""
|
|
229
|
+
node = LexborHTMLParser(html).css_first(selector)
|
|
230
|
+
if not node:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
return json.loads(node.text())
|
|
234
|
+
except ValueError:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def assigned_json(html: str, var: str, *, js_undefined: bool = False):
|
|
239
|
+
"""The object literal assigned to `var` in inline JS (`window.__INITIAL_STATE__ = {...}`)."""
|
|
240
|
+
m = re.search(rf"{re.escape(var)}\s*=\s*", html)
|
|
241
|
+
if not m:
|
|
242
|
+
return None
|
|
243
|
+
text = html[m.end() :]
|
|
244
|
+
if js_undefined: # JS-only literal some sites leave in their state dumps
|
|
245
|
+
text = re.sub(r"([:\[,]\s*)undefined(?=\s*[,}\]])", r"\1null", text)
|
|
246
|
+
try:
|
|
247
|
+
return _JSON.raw_decode(text)[0]
|
|
248
|
+
except ValueError:
|
|
249
|
+
return None
|
|
File without changes
|