veilrender 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {veilrender-0.1.0 → veilrender-0.2.0}/PKG-INFO +13 -5
- {veilrender-0.1.0 → veilrender-0.2.0}/README.md +12 -4
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/__init__.py +1 -1
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/app.py +2 -1
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/browser.py +10 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/config.py +1 -1
- veilrender-0.2.0/src/veilrender/routes/dashboard.py +237 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/routes/health.py +0 -4
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/routes/render.py +8 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/routes/screenshot.py +8 -0
- veilrender-0.2.0/src/veilrender/stats.py +83 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender.egg-info/PKG-INFO +13 -5
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender.egg-info/SOURCES.txt +2 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/pyproject.toml +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/setup.cfg +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/__main__.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/__init__.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/benchmark_compare.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/cache.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/config.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/dotenv.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/httpserver.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/jsonc.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/markdown.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/readability.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/retry.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/soup.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/structlog.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/useragent.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/_vendor/yaml.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/auth.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/cdp_proxy.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/models.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender/routes/__init__.py +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender.egg-info/dependency_links.txt +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender.egg-info/entry_points.txt +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender.egg-info/requires.txt +0 -0
- {veilrender-0.1.0 → veilrender-0.2.0}/src/veilrender.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: veilrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal
|
|
5
5
|
Author: Peng Ding
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,15 +23,23 @@ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
|
23
23
|
[](https://pypi.org/project/veilrender/)
|
|
24
24
|
[](https://github.com/Oaklight/veilrender/releases/latest)
|
|
25
25
|
[](https://github.com/Oaklight/veilrender/actions/workflows/ci.yml)
|
|
26
|
-
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
27
|
-
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
26
|
+
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
27
|
+
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
28
28
|
[](https://opensource.org/licenses/MIT)
|
|
29
|
-
[](https://oaklight-veilrender-public.hf.space)
|
|
30
30
|
|
|
31
31
|
[中文](README_zh.md) | **English**
|
|
32
32
|
|
|
33
33
|
Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal.
|
|
34
34
|
|
|
35
|
+
### Try it now (no auth needed)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
curl -X POST https://oaklight-veilrender-public.hf.space/render \
|
|
39
|
+
-H "Content-Type: application/json" \
|
|
40
|
+
-d '{"url": "https://example.com"}'
|
|
41
|
+
```
|
|
42
|
+
|
|
35
43
|
VeilRender accepts a URL and returns the fully rendered page content (HTML, Markdown, readability-extracted article) using a headless Chromium browser. Designed as a fallback for fetch tools that fail on JavaScript-rendered pages.
|
|
36
44
|
|
|
37
45
|
## Quick Start
|
|
@@ -39,7 +47,7 @@ VeilRender accepts a URL and returns the fully rendered page content (HTML, Mark
|
|
|
39
47
|
### Docker
|
|
40
48
|
|
|
41
49
|
```bash
|
|
42
|
-
docker run -p 7860:7860 -e VEILRENDER_API_TOKEN=your-secret
|
|
50
|
+
docker run -p 7860:7860 -e VEILRENDER_API_TOKEN=your-secret oaklight/veilrender
|
|
43
51
|
```
|
|
44
52
|
|
|
45
53
|
### Local Development
|
|
@@ -3,15 +3,23 @@
|
|
|
3
3
|
[](https://pypi.org/project/veilrender/)
|
|
4
4
|
[](https://github.com/Oaklight/veilrender/releases/latest)
|
|
5
5
|
[](https://github.com/Oaklight/veilrender/actions/workflows/ci.yml)
|
|
6
|
-
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
7
|
-
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
6
|
+
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
7
|
+
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
9
|
-
[](https://oaklight-veilrender-public.hf.space)
|
|
10
10
|
|
|
11
11
|
[中文](README_zh.md) | **English**
|
|
12
12
|
|
|
13
13
|
Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal.
|
|
14
14
|
|
|
15
|
+
### Try it now (no auth needed)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
curl -X POST https://oaklight-veilrender-public.hf.space/render \
|
|
19
|
+
-H "Content-Type: application/json" \
|
|
20
|
+
-d '{"url": "https://example.com"}'
|
|
21
|
+
```
|
|
22
|
+
|
|
15
23
|
VeilRender accepts a URL and returns the fully rendered page content (HTML, Markdown, readability-extracted article) using a headless Chromium browser. Designed as a fallback for fetch tools that fail on JavaScript-rendered pages.
|
|
16
24
|
|
|
17
25
|
## Quick Start
|
|
@@ -19,7 +27,7 @@ VeilRender accepts a URL and returns the fully rendered page content (HTML, Mark
|
|
|
19
27
|
### Docker
|
|
20
28
|
|
|
21
29
|
```bash
|
|
22
|
-
docker run -p 7860:7860 -e VEILRENDER_API_TOKEN=your-secret
|
|
30
|
+
docker run -p 7860:7860 -e VEILRENDER_API_TOKEN=your-secret oaklight/veilrender
|
|
23
31
|
```
|
|
24
32
|
|
|
25
33
|
### Local Development
|
|
@@ -11,7 +11,7 @@ from veilrender._vendor.httpserver import App
|
|
|
11
11
|
from veilrender.browser import browser_manager
|
|
12
12
|
from veilrender.cdp_proxy import handle_cdp_upgrade, is_websocket_upgrade
|
|
13
13
|
from veilrender.config import settings
|
|
14
|
-
from veilrender.routes import health, render, screenshot
|
|
14
|
+
from veilrender.routes import dashboard, health, render, screenshot
|
|
15
15
|
|
|
16
16
|
logging.basicConfig(
|
|
17
17
|
level=logging.INFO,
|
|
@@ -40,6 +40,7 @@ def create_app() -> App:
|
|
|
40
40
|
|
|
41
41
|
# Register routes
|
|
42
42
|
health.register(app)
|
|
43
|
+
dashboard.register(app)
|
|
43
44
|
render.register(app)
|
|
44
45
|
screenshot.register(app)
|
|
45
46
|
|
|
@@ -139,6 +139,16 @@ class BrowserManager:
|
|
|
139
139
|
assert self._browser is not None
|
|
140
140
|
return self._browser
|
|
141
141
|
|
|
142
|
+
@property
|
|
143
|
+
def active_pages(self) -> int:
|
|
144
|
+
"""Number of browser pages currently in use."""
|
|
145
|
+
return settings.max_concurrent - self._semaphore._value
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_browser_alive(self) -> bool:
|
|
149
|
+
"""Whether the browser process is connected."""
|
|
150
|
+
return self._browser is not None and self._browser.is_connected()
|
|
151
|
+
|
|
142
152
|
@asynccontextmanager
|
|
143
153
|
async def get_page(
|
|
144
154
|
self,
|
|
@@ -19,7 +19,7 @@ class Settings:
|
|
|
19
19
|
self.viewport_height: int = int(
|
|
20
20
|
os.environ.get("VEILRENDER_VIEWPORT_HEIGHT", "720")
|
|
21
21
|
)
|
|
22
|
-
self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "
|
|
22
|
+
self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "5"))
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
settings = Settings()
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Dashboard endpoint — GET / stats page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from veilrender import stats
|
|
6
|
+
from veilrender._vendor.httpserver import App, Request, Response
|
|
7
|
+
from veilrender.browser import browser_manager
|
|
8
|
+
from veilrender.config import settings
|
|
9
|
+
|
|
10
|
+
_BADGES_HTML = """
|
|
11
|
+
<a href="https://github.com/Oaklight/veilrender" target="_blank" rel="noopener">
|
|
12
|
+
<img src="https://img.shields.io/github/stars/Oaklight/veilrender?style=flat&logo=github&label=GitHub" alt="GitHub">
|
|
13
|
+
</a>
|
|
14
|
+
<a href="https://pypi.org/project/veilrender/" target="_blank" rel="noopener">
|
|
15
|
+
<img src="https://img.shields.io/pypi/v/veilrender?style=flat&logo=pypi&logoColor=white&label=PyPI" alt="PyPI">
|
|
16
|
+
</a>
|
|
17
|
+
<a href="https://hub.docker.com/r/oaklight/veilrender" target="_blank" rel="noopener">
|
|
18
|
+
<img src="https://img.shields.io/docker/pulls/oaklight/veilrender?style=flat&logo=docker&logoColor=white&label=Docker" alt="Docker Hub">
|
|
19
|
+
</a>
|
|
20
|
+
<a href="https://huggingface.co/spaces/oaklight/veilrender-public" target="_blank" rel="noopener">
|
|
21
|
+
<img src="https://img.shields.io/badge/%F0%9F%A4%97_HF_Spaces-demo-yellow?style=flat" alt="HF Spaces">
|
|
22
|
+
</a>
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _stats_row(name: str, ep: stats.EndpointStats) -> str:
|
|
27
|
+
"""Build an HTML table row for one endpoint."""
|
|
28
|
+
rate_class = (
|
|
29
|
+
"green"
|
|
30
|
+
if ep.success_rate >= 95
|
|
31
|
+
else "yellow"
|
|
32
|
+
if ep.success_rate >= 80
|
|
33
|
+
else "red"
|
|
34
|
+
)
|
|
35
|
+
return f"""
|
|
36
|
+
<tr>
|
|
37
|
+
<td><code>{name}</code></td>
|
|
38
|
+
<td class="mono">{ep.requests}</td>
|
|
39
|
+
<td class="mono green">{ep.successes}</td>
|
|
40
|
+
<td class="mono red">{ep.failures}</td>
|
|
41
|
+
<td class="mono {rate_class}">{ep.success_rate:.1f}%</td>
|
|
42
|
+
<td class="mono">{ep.avg_ms:.0f} ms</td>
|
|
43
|
+
<td class="mono">{ep.p95_ms:.0f} ms</td>
|
|
44
|
+
</tr>"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_html() -> str:
|
|
48
|
+
"""Build the dashboard HTML."""
|
|
49
|
+
browser_status = "🟢 alive" if browser_manager.is_browser_alive else "🔴 dead"
|
|
50
|
+
active = browser_manager.active_pages
|
|
51
|
+
max_conc = settings.max_concurrent
|
|
52
|
+
|
|
53
|
+
total_req = stats.render.requests + stats.screenshot.requests
|
|
54
|
+
total_ok = stats.render.successes + stats.screenshot.successes
|
|
55
|
+
total_fail = stats.render.failures + stats.screenshot.failures
|
|
56
|
+
|
|
57
|
+
utilization = (active / max_conc * 100) if max_conc > 0 else 0
|
|
58
|
+
util_class = (
|
|
59
|
+
"green" if utilization < 70 else "yellow" if utilization < 90 else "red"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return f"""<!DOCTYPE html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8">
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
67
|
+
<meta http-equiv="refresh" content="5">
|
|
68
|
+
<title>VeilRender Dashboard</title>
|
|
69
|
+
<style>
|
|
70
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
71
|
+
body {{
|
|
72
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
73
|
+
background: #0f172a; color: #e2e8f0; padding: 2rem;
|
|
74
|
+
min-height: 100vh;
|
|
75
|
+
}}
|
|
76
|
+
|
|
77
|
+
/* Header */
|
|
78
|
+
.header {{
|
|
79
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
80
|
+
flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem;
|
|
81
|
+
padding-bottom: 1.5rem; border-bottom: 1px solid #1e293b;
|
|
82
|
+
}}
|
|
83
|
+
.header h1 {{
|
|
84
|
+
font-size: 1.5rem; font-weight: 700; color: #f8fafc;
|
|
85
|
+
letter-spacing: -0.02em;
|
|
86
|
+
}}
|
|
87
|
+
.header h1 span {{ color: #818cf8; }}
|
|
88
|
+
.badges {{ display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }}
|
|
89
|
+
.badges a {{ text-decoration: none; transition: opacity 0.15s; }}
|
|
90
|
+
.badges a:hover {{ opacity: 0.8; }}
|
|
91
|
+
.badges img {{ height: 20px; vertical-align: middle; }}
|
|
92
|
+
|
|
93
|
+
/* Section labels */
|
|
94
|
+
.section-label {{
|
|
95
|
+
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
|
96
|
+
letter-spacing: 0.08em; color: #64748b; margin-bottom: 0.75rem;
|
|
97
|
+
}}
|
|
98
|
+
|
|
99
|
+
/* Cards */
|
|
100
|
+
.grid {{
|
|
101
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
102
|
+
gap: 0.75rem; margin-bottom: 2rem;
|
|
103
|
+
}}
|
|
104
|
+
.card {{
|
|
105
|
+
background: #1e293b; border-radius: 10px; padding: 1.2rem;
|
|
106
|
+
border: 1px solid #334155; transition: border-color 0.2s;
|
|
107
|
+
}}
|
|
108
|
+
.card:hover {{ border-color: #475569; }}
|
|
109
|
+
.card .label {{
|
|
110
|
+
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em;
|
|
111
|
+
color: #94a3b8; margin-bottom: 0.4rem;
|
|
112
|
+
}}
|
|
113
|
+
.card .value {{ font-size: 1.6rem; font-weight: 700; font-variant-numeric: tabular-nums; }}
|
|
114
|
+
|
|
115
|
+
/* Table */
|
|
116
|
+
.table-wrap {{
|
|
117
|
+
background: #1e293b; border-radius: 10px; border: 1px solid #334155;
|
|
118
|
+
overflow: hidden; margin-bottom: 2rem;
|
|
119
|
+
}}
|
|
120
|
+
table {{ width: 100%; border-collapse: collapse; }}
|
|
121
|
+
th {{
|
|
122
|
+
padding: 0.7rem 1rem; text-align: left; font-size: 0.7rem;
|
|
123
|
+
text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8;
|
|
124
|
+
background: #1e293b; border-bottom: 1px solid #334155;
|
|
125
|
+
}}
|
|
126
|
+
td {{
|
|
127
|
+
padding: 0.7rem 1rem; border-top: 1px solid rgba(51, 65, 85, 0.5);
|
|
128
|
+
}}
|
|
129
|
+
tr:hover td {{ background: rgba(51, 65, 85, 0.3); }}
|
|
130
|
+
code {{ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; font-size: 0.9em; }}
|
|
131
|
+
.mono {{ font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; font-variant-numeric: tabular-nums; }}
|
|
132
|
+
|
|
133
|
+
/* Colors */
|
|
134
|
+
.green {{ color: #4ade80; }}
|
|
135
|
+
.yellow {{ color: #facc15; }}
|
|
136
|
+
.red {{ color: #f87171; }}
|
|
137
|
+
.blue {{ color: #60a5fa; }}
|
|
138
|
+
.indigo {{ color: #818cf8; }}
|
|
139
|
+
|
|
140
|
+
/* Capacity bar */
|
|
141
|
+
.bar-track {{
|
|
142
|
+
height: 6px; background: #334155; border-radius: 3px;
|
|
143
|
+
margin-top: 0.5rem; overflow: hidden;
|
|
144
|
+
}}
|
|
145
|
+
.bar-fill {{ height: 100%; border-radius: 3px; transition: width 0.3s; }}
|
|
146
|
+
|
|
147
|
+
/* Footer */
|
|
148
|
+
.footer {{
|
|
149
|
+
font-size: 0.7rem; color: #475569; display: flex;
|
|
150
|
+
justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;
|
|
151
|
+
}}
|
|
152
|
+
|
|
153
|
+
@media (max-width: 640px) {{
|
|
154
|
+
body {{ padding: 1rem; }}
|
|
155
|
+
.header {{ flex-direction: column; align-items: flex-start; }}
|
|
156
|
+
.grid {{ grid-template-columns: repeat(2, 1fr); }}
|
|
157
|
+
}}
|
|
158
|
+
</style>
|
|
159
|
+
</head>
|
|
160
|
+
<body>
|
|
161
|
+
<div class="header">
|
|
162
|
+
<h1>👻 <span>Veil</span>Render</h1>
|
|
163
|
+
<div class="badges">
|
|
164
|
+
{_BADGES_HTML}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div class="section-label">Overview</div>
|
|
169
|
+
<div class="grid">
|
|
170
|
+
<div class="card">
|
|
171
|
+
<div class="label">Uptime</div>
|
|
172
|
+
<div class="value">{stats.format_uptime()}</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="card">
|
|
175
|
+
<div class="label">Browser</div>
|
|
176
|
+
<div class="value">{browser_status}</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="card">
|
|
179
|
+
<div class="label">Capacity</div>
|
|
180
|
+
<div class="value {util_class}">{active} <span style="font-size:0.8rem;color:#94a3b8">/ {max_conc}</span></div>
|
|
181
|
+
<div class="bar-track">
|
|
182
|
+
<div class="bar-fill" style="width:{utilization:.0f}%;background:{"#4ade80" if utilization < 70 else "#facc15" if utilization < 90 else "#f87171"}"></div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="card">
|
|
186
|
+
<div class="label">Total Requests</div>
|
|
187
|
+
<div class="value indigo">{total_req}</div>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="card">
|
|
190
|
+
<div class="label">Successes</div>
|
|
191
|
+
<div class="value green">{total_ok}</div>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="card">
|
|
194
|
+
<div class="label">Failures</div>
|
|
195
|
+
<div class="value {"red" if total_fail > 0 else "green"}">{total_fail}</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div class="section-label">Endpoints</div>
|
|
200
|
+
<div class="table-wrap">
|
|
201
|
+
<table>
|
|
202
|
+
<thead>
|
|
203
|
+
<tr>
|
|
204
|
+
<th>Endpoint</th>
|
|
205
|
+
<th>Requests</th>
|
|
206
|
+
<th>Success</th>
|
|
207
|
+
<th>Failure</th>
|
|
208
|
+
<th>Rate</th>
|
|
209
|
+
<th>Avg Latency</th>
|
|
210
|
+
<th>P95 Latency</th>
|
|
211
|
+
</tr>
|
|
212
|
+
</thead>
|
|
213
|
+
<tbody>
|
|
214
|
+
{_stats_row("/render", stats.render)}
|
|
215
|
+
{_stats_row("/screenshot", stats.screenshot)}
|
|
216
|
+
</tbody>
|
|
217
|
+
</table>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div class="footer">
|
|
221
|
+
<span>Auto-refreshes every 5s · Stats are in-memory, reset on restart</span>
|
|
222
|
+
<span>VeilRender · Headless browser rendering API</span>
|
|
223
|
+
</div>
|
|
224
|
+
</body>
|
|
225
|
+
</html>"""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register(app: App) -> None:
|
|
229
|
+
"""Register dashboard route on the app."""
|
|
230
|
+
|
|
231
|
+
@app.get("/")
|
|
232
|
+
async def dashboard(request: Request) -> Response:
|
|
233
|
+
return Response(
|
|
234
|
+
body=_build_html(),
|
|
235
|
+
status_code=200,
|
|
236
|
+
content_type="text/html; charset=utf-8",
|
|
237
|
+
)
|
|
@@ -11,7 +11,3 @@ def register(app: App) -> None:
|
|
|
11
11
|
@app.get("/health")
|
|
12
12
|
async def health(request: Request) -> JSONResponse:
|
|
13
13
|
return JSONResponse({"status": "ok"})
|
|
14
|
-
|
|
15
|
-
@app.get("/")
|
|
16
|
-
async def root(request: Request) -> JSONResponse:
|
|
17
|
-
return JSONResponse({"service": "veilrender", "status": "ok"})
|
|
@@ -4,10 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import re
|
|
7
|
+
import time
|
|
7
8
|
|
|
8
9
|
from veilrender._vendor.httpserver import App, JSONResponse, Request
|
|
9
10
|
from veilrender._vendor.readability import extract as readability_extract
|
|
10
11
|
from veilrender._vendor.soup import Soup
|
|
12
|
+
from veilrender import stats
|
|
11
13
|
from veilrender.auth import verify_token
|
|
12
14
|
from veilrender.browser import browser_manager
|
|
13
15
|
from veilrender.config import settings
|
|
@@ -70,6 +72,8 @@ def register(app: App) -> None:
|
|
|
70
72
|
|
|
71
73
|
req = RenderRequest.from_dict(data)
|
|
72
74
|
timeout = req.timeout or settings.timeout
|
|
75
|
+
stats.render.requests += 1
|
|
76
|
+
t0 = time.monotonic()
|
|
73
77
|
|
|
74
78
|
try:
|
|
75
79
|
async with browser_manager.get_page() as (ctx, page):
|
|
@@ -83,6 +87,8 @@ def register(app: App) -> None:
|
|
|
83
87
|
final_url = page.url
|
|
84
88
|
html = await page.content()
|
|
85
89
|
except Exception as exc:
|
|
90
|
+
elapsed = (time.monotonic() - t0) * 1000
|
|
91
|
+
stats.render.record_failure(elapsed)
|
|
86
92
|
logger.error("Render failed for %s: %s", req.url, exc)
|
|
87
93
|
return JSONResponse(
|
|
88
94
|
{"error": f"Render failed: {exc!s}"},
|
|
@@ -119,4 +125,6 @@ def register(app: App) -> None:
|
|
|
119
125
|
links=links,
|
|
120
126
|
)
|
|
121
127
|
|
|
128
|
+
elapsed = (time.monotonic() - t0) * 1000
|
|
129
|
+
stats.render.record_success(elapsed)
|
|
122
130
|
return JSONResponse(result.to_dict())
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import time
|
|
6
7
|
|
|
7
8
|
from veilrender._vendor.httpserver import App, Request, Response
|
|
9
|
+
from veilrender import stats
|
|
8
10
|
from veilrender.auth import verify_token
|
|
9
11
|
from veilrender.browser import browser_manager
|
|
10
12
|
from veilrender.config import settings
|
|
@@ -38,6 +40,8 @@ def register(app: App) -> None:
|
|
|
38
40
|
|
|
39
41
|
req = ScreenshotRequest.from_dict(data)
|
|
40
42
|
timeout = req.timeout or settings.timeout
|
|
43
|
+
stats.screenshot.requests += 1
|
|
44
|
+
t0 = time.monotonic()
|
|
41
45
|
|
|
42
46
|
try:
|
|
43
47
|
async with browser_manager.get_page(
|
|
@@ -51,6 +55,8 @@ def register(app: App) -> None:
|
|
|
51
55
|
)
|
|
52
56
|
png_bytes = await page.screenshot(full_page=req.full_page)
|
|
53
57
|
except Exception as exc:
|
|
58
|
+
elapsed = (time.monotonic() - t0) * 1000
|
|
59
|
+
stats.screenshot.record_failure(elapsed)
|
|
54
60
|
logger.error("Screenshot failed for %s: %s", req.url, exc)
|
|
55
61
|
return Response(
|
|
56
62
|
body=f'{{"error": "Screenshot failed: {exc!s}"}}'.encode(),
|
|
@@ -58,6 +64,8 @@ def register(app: App) -> None:
|
|
|
58
64
|
content_type="application/json",
|
|
59
65
|
)
|
|
60
66
|
|
|
67
|
+
elapsed = (time.monotonic() - t0) * 1000
|
|
68
|
+
stats.screenshot.record_success(elapsed)
|
|
61
69
|
return Response(
|
|
62
70
|
body=png_bytes,
|
|
63
71
|
status_code=200,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""In-memory request statistics.
|
|
2
|
+
|
|
3
|
+
All counters are safe to mutate without locks because asyncio
|
|
4
|
+
is single-threaded cooperative multitasking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
_start_time: float = time.monotonic()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EndpointStats:
|
|
18
|
+
"""Per-endpoint request counters and latency tracking."""
|
|
19
|
+
|
|
20
|
+
requests: int = 0
|
|
21
|
+
successes: int = 0
|
|
22
|
+
failures: int = 0
|
|
23
|
+
total_ms: float = 0.0
|
|
24
|
+
_latencies: deque[float] = field(default_factory=lambda: deque(maxlen=1000))
|
|
25
|
+
|
|
26
|
+
def record_success(self, elapsed_ms: float) -> None:
|
|
27
|
+
"""Record a successful request."""
|
|
28
|
+
self.successes += 1
|
|
29
|
+
self.total_ms += elapsed_ms
|
|
30
|
+
self._latencies.append(elapsed_ms)
|
|
31
|
+
|
|
32
|
+
def record_failure(self, elapsed_ms: float) -> None:
|
|
33
|
+
"""Record a failed request."""
|
|
34
|
+
self.failures += 1
|
|
35
|
+
self.total_ms += elapsed_ms
|
|
36
|
+
self._latencies.append(elapsed_ms)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def avg_ms(self) -> float:
|
|
40
|
+
"""Average latency in milliseconds."""
|
|
41
|
+
total = self.successes + self.failures
|
|
42
|
+
return self.total_ms / total if total > 0 else 0.0
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def p95_ms(self) -> float:
|
|
46
|
+
"""95th percentile latency in milliseconds."""
|
|
47
|
+
if not self._latencies:
|
|
48
|
+
return 0.0
|
|
49
|
+
sorted_lat = sorted(self._latencies)
|
|
50
|
+
idx = int(len(sorted_lat) * 0.95)
|
|
51
|
+
return sorted_lat[min(idx, len(sorted_lat) - 1)]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def success_rate(self) -> float:
|
|
55
|
+
"""Success rate as a percentage."""
|
|
56
|
+
total = self.successes + self.failures
|
|
57
|
+
return (self.successes / total * 100) if total > 0 else 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
render = EndpointStats()
|
|
61
|
+
screenshot = EndpointStats()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def uptime_seconds() -> float:
|
|
65
|
+
"""Seconds since the process started."""
|
|
66
|
+
return time.monotonic() - _start_time
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_uptime() -> str:
|
|
70
|
+
"""Human-readable uptime string."""
|
|
71
|
+
secs = int(uptime_seconds())
|
|
72
|
+
days, secs = divmod(secs, 86400)
|
|
73
|
+
hours, secs = divmod(secs, 3600)
|
|
74
|
+
mins, secs = divmod(secs, 60)
|
|
75
|
+
parts = []
|
|
76
|
+
if days:
|
|
77
|
+
parts.append(f"{days}d")
|
|
78
|
+
if hours:
|
|
79
|
+
parts.append(f"{hours}h")
|
|
80
|
+
if mins:
|
|
81
|
+
parts.append(f"{mins}m")
|
|
82
|
+
parts.append(f"{secs}s")
|
|
83
|
+
return " ".join(parts)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: veilrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal
|
|
5
5
|
Author: Peng Ding
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,15 +23,23 @@ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
|
23
23
|
[](https://pypi.org/project/veilrender/)
|
|
24
24
|
[](https://github.com/Oaklight/veilrender/releases/latest)
|
|
25
25
|
[](https://github.com/Oaklight/veilrender/actions/workflows/ci.yml)
|
|
26
|
-
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
27
|
-
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
26
|
+
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
27
|
+
[](https://hub.docker.com/r/oaklight/veilrender)
|
|
28
28
|
[](https://opensource.org/licenses/MIT)
|
|
29
|
-
[](https://oaklight-veilrender-public.hf.space)
|
|
30
30
|
|
|
31
31
|
[中文](README_zh.md) | **English**
|
|
32
32
|
|
|
33
33
|
Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal.
|
|
34
34
|
|
|
35
|
+
### Try it now (no auth needed)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
curl -X POST https://oaklight-veilrender-public.hf.space/render \
|
|
39
|
+
-H "Content-Type: application/json" \
|
|
40
|
+
-d '{"url": "https://example.com"}'
|
|
41
|
+
```
|
|
42
|
+
|
|
35
43
|
VeilRender accepts a URL and returns the fully rendered page content (HTML, Markdown, readability-extracted article) using a headless Chromium browser. Designed as a fallback for fetch tools that fail on JavaScript-rendered pages.
|
|
36
44
|
|
|
37
45
|
## Quick Start
|
|
@@ -39,7 +47,7 @@ VeilRender accepts a URL and returns the fully rendered page content (HTML, Mark
|
|
|
39
47
|
### Docker
|
|
40
48
|
|
|
41
49
|
```bash
|
|
42
|
-
docker run -p 7860:7860 -e VEILRENDER_API_TOKEN=your-secret
|
|
50
|
+
docker run -p 7860:7860 -e VEILRENDER_API_TOKEN=your-secret oaklight/veilrender
|
|
43
51
|
```
|
|
44
52
|
|
|
45
53
|
### Local Development
|
|
@@ -8,6 +8,7 @@ src/veilrender/browser.py
|
|
|
8
8
|
src/veilrender/cdp_proxy.py
|
|
9
9
|
src/veilrender/config.py
|
|
10
10
|
src/veilrender/models.py
|
|
11
|
+
src/veilrender/stats.py
|
|
11
12
|
src/veilrender.egg-info/PKG-INFO
|
|
12
13
|
src/veilrender.egg-info/SOURCES.txt
|
|
13
14
|
src/veilrender.egg-info/dependency_links.txt
|
|
@@ -29,6 +30,7 @@ src/veilrender/_vendor/structlog.py
|
|
|
29
30
|
src/veilrender/_vendor/useragent.py
|
|
30
31
|
src/veilrender/_vendor/yaml.py
|
|
31
32
|
src/veilrender/routes/__init__.py
|
|
33
|
+
src/veilrender/routes/dashboard.py
|
|
32
34
|
src/veilrender/routes/health.py
|
|
33
35
|
src/veilrender/routes/render.py
|
|
34
36
|
src/veilrender/routes/screenshot.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|