docpull 5.0.1__tar.gz → 5.0.2__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.
- {docpull-5.0.1/src/docpull.egg-info → docpull-5.0.2}/PKG-INFO +2 -2
- {docpull-5.0.1 → docpull-5.0.2}/README.md +1 -1
- {docpull-5.0.1 → docpull-5.0.2}/pyproject.toml +1 -1
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/__init__.py +6 -1
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cli.py +5 -0
- docpull-5.0.2/src/docpull/share.py +547 -0
- {docpull-5.0.1 → docpull-5.0.2/src/docpull.egg-info}/PKG-INFO +2 -2
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/SOURCES.txt +2 -0
- docpull-5.0.2/tests/test_share.py +101 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_surface_contract.py +5 -0
- {docpull-5.0.1 → docpull-5.0.2}/LICENSE +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/setup.cfg +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/__main__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/accounting.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/auth_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/benchmark.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/frontier.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/manager.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/cache/streaming_dedup.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/chunking.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/extractor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/filings.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/markdown.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/protocols.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/special_cases.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/conversion/trafilatura_extractor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/core/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/core/fetcher.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/_fetch.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/composite.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/contracts.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/crawler.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/filters.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/enhanced.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/protocols.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/link_extractors/static.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/protocols.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery/sitemap.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/discovery_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/doctor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/evidence_pack.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/exports.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/fixtures/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/fixtures/parallel-search-extract.json +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/graph.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/client.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/protocols.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/http/rate_limiter.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/judge.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/local_workflows.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/server.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/sources.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/mcp/tools.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/metadata_extractor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/config.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/document.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/events.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/profiles.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/models/run.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/monitor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pack_reader.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pack_tools.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/parallel_workflows.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/parity.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/parity_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/passk.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/base.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/manifest.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/chunk.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/convert.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/dedup.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/fetch.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/metadata.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/render.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_json.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_ndjson.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_okf.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/save_sqlite.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/pipeline/steps/validate.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/policy.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/policy_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_adapters.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_capabilities.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_keys.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/provider_probes.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/py.typed +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/rendering.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/scraper.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/__init__.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/download_policy.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/robots.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/security/url_validator.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/server.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/skill_export.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/source_scoring.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull/time_utils.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/dependency_links.txt +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/entry_points.txt +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/requires.txt +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/src/docpull.egg-info/top_level.txt +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_accounting.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_auth_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_benchmark.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_cache_conditional_get.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_chunking.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_ci_policy.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_conversion.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_convert_step_new.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_discovery.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_discovery_contracts.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_doctor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_document_record.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_evidence_pack.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_exports.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_frontier_resume.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_graph.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_integration.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_judge.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_link_extractors.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_local_workflows.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_mcp_server.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_mcp_tools.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_metadata_extractor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_monitor.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_naming.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_outputs_e2e.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_pack_server.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_pack_tools.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_parallel_workflows.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_parity_workflows.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_passk.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_pipeline.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_policy.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_policy_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_adapters.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_cli.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_keys.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_provider_probes.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_rate_limiter.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_real_site_regressions.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_rendering.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_save_ndjson.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_save_sqlite.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_security_hardening.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_special_cases.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_time_utils.py +0 -0
- {docpull-5.0.1 → docpull-5.0.2}/tests/test_trafilatura_extractor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docpull
|
|
3
|
-
Version: 5.0.
|
|
3
|
+
Version: 5.0.2
|
|
4
4
|
Summary: Convert public web pages into clean Markdown for AI agents and RAG
|
|
5
5
|
Author-email: Zachary Roth <support@raintree.technology>
|
|
6
6
|
Maintainer-email: Raintree Technology <support@raintree.technology>
|
|
@@ -501,7 +501,7 @@ part of the package release contract.
|
|
|
501
501
|
rendering, authenticated-source checks, and cron-friendly monitors:
|
|
502
502
|
`docpull policy`, `docpull discover`, `docpull refresh`,
|
|
503
503
|
`docpull pack audit`, `docpull answer-pack`, `docpull export`,
|
|
504
|
-
`docpull serve`, `docpull render`, `docpull auth check`, and
|
|
504
|
+
`docpull serve`, `docpull share`, `docpull render`, `docpull auth check`, and
|
|
505
505
|
`docpull monitor`.
|
|
506
506
|
- `docpull export` writes local files for OpenAI vector JSONL, LangChain,
|
|
507
507
|
LlamaIndex, DSPy, Sheets CSV/TSV, n8n workflow JSON, Vercel AI SDK JSON,
|
|
@@ -389,7 +389,7 @@ part of the package release contract.
|
|
|
389
389
|
rendering, authenticated-source checks, and cron-friendly monitors:
|
|
390
390
|
`docpull policy`, `docpull discover`, `docpull refresh`,
|
|
391
391
|
`docpull pack audit`, `docpull answer-pack`, `docpull export`,
|
|
392
|
-
`docpull serve`, `docpull render`, `docpull auth check`, and
|
|
392
|
+
`docpull serve`, `docpull share`, `docpull render`, `docpull auth check`, and
|
|
393
393
|
`docpull monitor`.
|
|
394
394
|
- `docpull export` writes local files for OpenAI vector JSONL, LangChain,
|
|
395
395
|
LlamaIndex, DSPy, Sheets CSV/TSV, n8n workflow JSON, Vercel AI SDK JSON,
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "docpull"
|
|
7
|
-
version = "5.0.
|
|
7
|
+
version = "5.0.2"
|
|
8
8
|
dynamic = []
|
|
9
9
|
description = "Convert public web pages into clean Markdown for AI agents and RAG"
|
|
10
10
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
@@ -14,7 +14,7 @@ Usage:
|
|
|
14
14
|
print(event)
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
-
__version__ = "5.0.
|
|
17
|
+
__version__ = "5.0.2"
|
|
18
18
|
|
|
19
19
|
from .cache import CacheManager, StreamingDeduplicator
|
|
20
20
|
from .conversion.chunking import Chunk, TokenCounter, chunk_markdown
|
|
@@ -94,6 +94,7 @@ from .scraper import (
|
|
|
94
94
|
scrape_site,
|
|
95
95
|
)
|
|
96
96
|
from .server import PackASGIApp, PackServerError, create_pack_app
|
|
97
|
+
from .share import ReportHTTPServer, ShareError, create_report_server, render_report_document
|
|
97
98
|
|
|
98
99
|
__all__ = [
|
|
99
100
|
"__version__",
|
|
@@ -134,6 +135,10 @@ __all__ = [
|
|
|
134
135
|
"create_pack_app",
|
|
135
136
|
"PackASGIApp",
|
|
136
137
|
"PackServerError",
|
|
138
|
+
"create_report_server",
|
|
139
|
+
"render_report_document",
|
|
140
|
+
"ReportHTTPServer",
|
|
141
|
+
"ShareError",
|
|
137
142
|
"ScrapeResult",
|
|
138
143
|
"ScrapeRunResult",
|
|
139
144
|
"Scraper",
|
|
@@ -206,6 +206,7 @@ Subcommands:
|
|
|
206
206
|
answer-pack Answer from local pack evidence with citations
|
|
207
207
|
export Export a pack for agent or RAG tools
|
|
208
208
|
serve Serve a local pack over localhost JSON routes
|
|
209
|
+
share Serve a Markdown or HTML report at a local URL
|
|
209
210
|
monitor Run cron-friendly local pack monitors
|
|
210
211
|
parallel Build optional Parallel-backed context packs
|
|
211
212
|
provider Check provider keys and run provider-backed context packs
|
|
@@ -1369,6 +1370,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1369
1370
|
from .server import run_serve_cli
|
|
1370
1371
|
|
|
1371
1372
|
return run_serve_cli(raw_argv[1:])
|
|
1373
|
+
if raw_argv and raw_argv[0] == "share":
|
|
1374
|
+
from .share import run_share_cli
|
|
1375
|
+
|
|
1376
|
+
return run_share_cli(raw_argv[1:])
|
|
1372
1377
|
if raw_argv and raw_argv[0] == "export":
|
|
1373
1378
|
from .exports import run_export_cli
|
|
1374
1379
|
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Local-first report sharing for Markdown and HTML files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import webbrowser
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote, urlparse
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
|
|
19
|
+
from .server import PackServerError, validate_bind_host
|
|
20
|
+
|
|
21
|
+
DEFAULT_SHARE_HOST = "127.0.0.1"
|
|
22
|
+
DEFAULT_SHARE_PORT = 0
|
|
23
|
+
SHARE_SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
|
26
|
+
_UNORDERED_RE = re.compile(r"^\s{0,3}[-*+]\s+(.+?)\s*$")
|
|
27
|
+
_ORDERED_RE = re.compile(r"^\s{0,3}\d+[.)]\s+(.+?)\s*$")
|
|
28
|
+
_FENCE_RE = re.compile(r"^\s*(```|~~~)\s*([A-Za-z0-9_+.-]*)\s*$")
|
|
29
|
+
_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
|
30
|
+
_LINK_RE = re.compile(r"\[([^\]]+)]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
|
31
|
+
_CODE_SPAN_RE = re.compile(r"`([^`]+)`")
|
|
32
|
+
_BOLD_RE = re.compile(r"\*\*([^*]+)\*\*|__([^_]+)__")
|
|
33
|
+
_ITALIC_RE = re.compile(r"(?<!\*)\*([^*\n]+)\*(?!\*)")
|
|
34
|
+
_HTML_DOCUMENT_RE = re.compile(r"<html(?:\s|>)", re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ShareError(RuntimeError):
|
|
38
|
+
"""User-facing report sharing error."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ReportHTTPServer(ThreadingHTTPServer):
|
|
42
|
+
"""Threading HTTP server with reusable sockets for report previews."""
|
|
43
|
+
|
|
44
|
+
allow_reuse_address = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_share_parser() -> argparse.ArgumentParser:
|
|
48
|
+
parser = argparse.ArgumentParser(
|
|
49
|
+
prog="docpull share",
|
|
50
|
+
description="Serve a local Markdown or HTML report at a simple URL",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("report", type=Path, help="Markdown or HTML report file")
|
|
53
|
+
parser.add_argument("--host", default=DEFAULT_SHARE_HOST, help="Bind host (default: 127.0.0.1)")
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--port",
|
|
56
|
+
type=int,
|
|
57
|
+
default=DEFAULT_SHARE_PORT,
|
|
58
|
+
help="Bind port (default: 0, choose an available port)",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--title", help="Override the rendered page title")
|
|
61
|
+
parser.add_argument("--open", action="store_true", help="Open the share URL in the default browser")
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--allow-network-bind",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Allow non-loopback hosts such as 0.0.0.0",
|
|
66
|
+
)
|
|
67
|
+
return parser
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_share_cli(argv: list[str] | None = None) -> int:
|
|
71
|
+
parser = create_share_parser()
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
console = Console()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
server = create_report_server(
|
|
77
|
+
args.report,
|
|
78
|
+
host=args.host,
|
|
79
|
+
port=args.port,
|
|
80
|
+
title=args.title,
|
|
81
|
+
allow_network_bind=args.allow_network_bind,
|
|
82
|
+
)
|
|
83
|
+
except (ShareError, PackServerError) as err:
|
|
84
|
+
console.print("[red]Share error:[/red] " + escape(str(err)))
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
url = report_url(args.host, server.server_port)
|
|
88
|
+
console.print(f"[green]Sharing:[/green] {Path(args.report).expanduser().resolve()}")
|
|
89
|
+
console.print(f"[green]URL:[/green] {url}")
|
|
90
|
+
console.print("Press Ctrl-C to stop.")
|
|
91
|
+
if args.open:
|
|
92
|
+
webbrowser.open(url)
|
|
93
|
+
try:
|
|
94
|
+
server.serve_forever()
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
console.print("\nStopped sharing.")
|
|
97
|
+
finally:
|
|
98
|
+
server.server_close()
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_report_server(
|
|
103
|
+
report: Path | str,
|
|
104
|
+
*,
|
|
105
|
+
host: str = DEFAULT_SHARE_HOST,
|
|
106
|
+
port: int = DEFAULT_SHARE_PORT,
|
|
107
|
+
title: str | None = None,
|
|
108
|
+
allow_network_bind: bool = False,
|
|
109
|
+
) -> ReportHTTPServer:
|
|
110
|
+
"""Create a local HTTP server for one Markdown or HTML report."""
|
|
111
|
+
validate_bind_host(host, allow_network_bind=allow_network_bind)
|
|
112
|
+
report_path = _validate_report_path(Path(report))
|
|
113
|
+
handler = _handler_for_report(report_path, title=title)
|
|
114
|
+
return ReportHTTPServer((host, port), handler)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def report_url(host: str, port: int) -> str:
|
|
118
|
+
"""Build the browser URL printed for a report server."""
|
|
119
|
+
display_host = host.strip() or DEFAULT_SHARE_HOST
|
|
120
|
+
# Display fallback only; socket binding is validated before server creation.
|
|
121
|
+
if display_host == "0.0.0.0": # nosec B104
|
|
122
|
+
display_host = DEFAULT_SHARE_HOST
|
|
123
|
+
if ":" in display_host and not display_host.startswith("["):
|
|
124
|
+
display_host = f"[{display_host}]"
|
|
125
|
+
return f"http://{display_host}:{port}/"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_report_document(report: Path | str, *, title: str | None = None) -> bytes:
|
|
129
|
+
"""Render a Markdown or HTML report into bytes suitable for text/html."""
|
|
130
|
+
report_path = _validate_report_path(Path(report))
|
|
131
|
+
text = report_path.read_text(encoding="utf-8")
|
|
132
|
+
suffix = report_path.suffix.lower()
|
|
133
|
+
page_title = title or _derive_title(text, fallback=report_path.stem)
|
|
134
|
+
if suffix in {".html", ".htm"}:
|
|
135
|
+
if _HTML_DOCUMENT_RE.search(text):
|
|
136
|
+
return text.encode("utf-8")
|
|
137
|
+
return _html_shell(_inline_fragment(text), title=page_title).encode("utf-8")
|
|
138
|
+
body = _markdown_to_html(text)
|
|
139
|
+
return _html_shell(body, title=page_title).encode("utf-8")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _handler_for_report(report_path: Path, *, title: str | None) -> type[BaseHTTPRequestHandler]:
|
|
143
|
+
class ReportRequestHandler(BaseHTTPRequestHandler):
|
|
144
|
+
server_version = "DocPullShare/1"
|
|
145
|
+
|
|
146
|
+
def do_GET(self) -> None: # noqa: N802
|
|
147
|
+
self._handle(send_body=True)
|
|
148
|
+
|
|
149
|
+
def do_HEAD(self) -> None: # noqa: N802
|
|
150
|
+
self._handle(send_body=False)
|
|
151
|
+
|
|
152
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
def _handle(self, *, send_body: bool) -> None:
|
|
156
|
+
path = urlparse(self.path).path.rstrip("/") or "/"
|
|
157
|
+
if path == "/health":
|
|
158
|
+
self._write_json(_health_payload(report_path), send_body=send_body)
|
|
159
|
+
return
|
|
160
|
+
if path == "/source":
|
|
161
|
+
body = report_path.read_bytes()
|
|
162
|
+
if _is_markdown(report_path):
|
|
163
|
+
content_type = "text/markdown; charset=utf-8"
|
|
164
|
+
else:
|
|
165
|
+
content_type = "text/html; charset=utf-8"
|
|
166
|
+
self._write_response(HTTPStatus.OK, content_type, body, send_body=send_body)
|
|
167
|
+
return
|
|
168
|
+
if path in {"/", "/report", "/report.html"}:
|
|
169
|
+
try:
|
|
170
|
+
body = render_report_document(report_path, title=title)
|
|
171
|
+
except OSError as err:
|
|
172
|
+
self._write_json(
|
|
173
|
+
{"error": f"Could not read report: {err}"},
|
|
174
|
+
status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
self._write_response(HTTPStatus.OK, "text/html; charset=utf-8", body, send_body=send_body)
|
|
178
|
+
return
|
|
179
|
+
self._write_json(
|
|
180
|
+
{"error": "Not found", "path": path},
|
|
181
|
+
status=HTTPStatus.NOT_FOUND,
|
|
182
|
+
send_body=send_body,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def _write_json(
|
|
186
|
+
self,
|
|
187
|
+
payload: dict[str, Any],
|
|
188
|
+
*,
|
|
189
|
+
status: HTTPStatus = HTTPStatus.OK,
|
|
190
|
+
send_body: bool = True,
|
|
191
|
+
) -> None:
|
|
192
|
+
body = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
193
|
+
self._write_response(status, "application/json; charset=utf-8", body, send_body=send_body)
|
|
194
|
+
|
|
195
|
+
def _write_response(
|
|
196
|
+
self,
|
|
197
|
+
status: HTTPStatus,
|
|
198
|
+
content_type: str,
|
|
199
|
+
body: bytes,
|
|
200
|
+
*,
|
|
201
|
+
send_body: bool,
|
|
202
|
+
) -> None:
|
|
203
|
+
self.send_response(int(status))
|
|
204
|
+
self.send_header("Content-Type", content_type)
|
|
205
|
+
self.send_header("Content-Length", str(len(body)))
|
|
206
|
+
self.send_header("Cache-Control", "no-store")
|
|
207
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
208
|
+
self.send_header(
|
|
209
|
+
"Content-Security-Policy",
|
|
210
|
+
"default-src 'none'; img-src data: http: https:; style-src 'unsafe-inline'; "
|
|
211
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
212
|
+
)
|
|
213
|
+
self.end_headers()
|
|
214
|
+
if send_body:
|
|
215
|
+
self.wfile.write(body)
|
|
216
|
+
|
|
217
|
+
return ReportRequestHandler
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _validate_report_path(path: Path) -> Path:
|
|
221
|
+
report_path = path.expanduser().resolve()
|
|
222
|
+
if not report_path.exists():
|
|
223
|
+
raise ShareError(f"Report file does not exist: {report_path}")
|
|
224
|
+
if not report_path.is_file():
|
|
225
|
+
raise ShareError(f"Report path is not a file: {report_path}")
|
|
226
|
+
if report_path.suffix.lower() not in {"", ".md", ".markdown", ".html", ".htm", ".txt"}:
|
|
227
|
+
raise ShareError("Report must be Markdown, HTML, or plain text.")
|
|
228
|
+
return report_path
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _health_payload(report_path: Path) -> dict[str, Any]:
|
|
232
|
+
stat = report_path.stat()
|
|
233
|
+
return {
|
|
234
|
+
"schema_version": SHARE_SCHEMA_VERSION,
|
|
235
|
+
"status": "ok",
|
|
236
|
+
"report_path": str(report_path),
|
|
237
|
+
"report_bytes": stat.st_size,
|
|
238
|
+
"format": "markdown" if _is_markdown(report_path) else "html",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_markdown(path: Path) -> bool:
|
|
243
|
+
return path.suffix.lower() in {"", ".md", ".markdown", ".txt"}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _derive_title(text: str, *, fallback: str) -> str:
|
|
247
|
+
for line in text.splitlines():
|
|
248
|
+
stripped = line.strip()
|
|
249
|
+
if stripped.startswith("# "):
|
|
250
|
+
return stripped.removeprefix("# ").strip() or fallback
|
|
251
|
+
if stripped:
|
|
252
|
+
plain = re.sub(r"<[^>]+>", "", stripped)
|
|
253
|
+
return plain[:80] or fallback
|
|
254
|
+
return fallback
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _html_shell(body: str, *, title: str) -> str:
|
|
258
|
+
escaped_title = html.escape(title, quote=True)
|
|
259
|
+
return f"""<!doctype html>
|
|
260
|
+
<html lang="en">
|
|
261
|
+
<head>
|
|
262
|
+
<meta charset="utf-8">
|
|
263
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
264
|
+
<title>{escaped_title}</title>
|
|
265
|
+
<style>
|
|
266
|
+
:root {{
|
|
267
|
+
color-scheme: light dark;
|
|
268
|
+
--bg: #f8f7f4;
|
|
269
|
+
--text: #202124;
|
|
270
|
+
--muted: #696b70;
|
|
271
|
+
--line: #d8d6cf;
|
|
272
|
+
--panel: #ffffff;
|
|
273
|
+
--accent: #0f766e;
|
|
274
|
+
--code: #f1eee8;
|
|
275
|
+
}}
|
|
276
|
+
@media (prefers-color-scheme: dark) {{
|
|
277
|
+
:root {{
|
|
278
|
+
--bg: #151515;
|
|
279
|
+
--text: #f3f2ee;
|
|
280
|
+
--muted: #b7b4ad;
|
|
281
|
+
--line: #3d3a34;
|
|
282
|
+
--panel: #1f1f1f;
|
|
283
|
+
--accent: #5eead4;
|
|
284
|
+
--code: #2b2925;
|
|
285
|
+
}}
|
|
286
|
+
}}
|
|
287
|
+
* {{ box-sizing: border-box; }}
|
|
288
|
+
body {{
|
|
289
|
+
margin: 0;
|
|
290
|
+
background: var(--bg);
|
|
291
|
+
color: var(--text);
|
|
292
|
+
font: 16px/1.58 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
293
|
+
}}
|
|
294
|
+
main {{
|
|
295
|
+
width: min(920px, calc(100% - 32px));
|
|
296
|
+
margin: 0 auto;
|
|
297
|
+
padding: 48px 0 64px;
|
|
298
|
+
}}
|
|
299
|
+
article {{
|
|
300
|
+
background: var(--panel);
|
|
301
|
+
border: 1px solid var(--line);
|
|
302
|
+
border-radius: 8px;
|
|
303
|
+
padding: clamp(20px, 4vw, 44px);
|
|
304
|
+
overflow-wrap: anywhere;
|
|
305
|
+
}}
|
|
306
|
+
h1, h2, h3, h4, h5, h6 {{
|
|
307
|
+
line-height: 1.22;
|
|
308
|
+
margin: 1.8em 0 0.55em;
|
|
309
|
+
letter-spacing: 0;
|
|
310
|
+
}}
|
|
311
|
+
h1 {{ margin-top: 0; font-size: 2rem; }}
|
|
312
|
+
h2 {{ font-size: 1.45rem; border-bottom: 1px solid var(--line); padding-bottom: 0.25rem; }}
|
|
313
|
+
h3 {{ font-size: 1.15rem; }}
|
|
314
|
+
p, ul, ol, blockquote, table, pre {{ margin: 0 0 1rem; }}
|
|
315
|
+
a {{ color: var(--accent); }}
|
|
316
|
+
blockquote {{
|
|
317
|
+
border-left: 3px solid var(--accent);
|
|
318
|
+
color: var(--muted);
|
|
319
|
+
margin-left: 0;
|
|
320
|
+
padding-left: 1rem;
|
|
321
|
+
}}
|
|
322
|
+
code {{
|
|
323
|
+
background: var(--code);
|
|
324
|
+
border-radius: 4px;
|
|
325
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
326
|
+
font-size: 0.92em;
|
|
327
|
+
padding: 0.12rem 0.28rem;
|
|
328
|
+
}}
|
|
329
|
+
pre {{
|
|
330
|
+
background: var(--code);
|
|
331
|
+
border: 1px solid var(--line);
|
|
332
|
+
border-radius: 8px;
|
|
333
|
+
overflow-x: auto;
|
|
334
|
+
padding: 1rem;
|
|
335
|
+
}}
|
|
336
|
+
pre code {{ background: transparent; padding: 0; }}
|
|
337
|
+
table {{
|
|
338
|
+
border-collapse: collapse;
|
|
339
|
+
display: block;
|
|
340
|
+
overflow-x: auto;
|
|
341
|
+
width: 100%;
|
|
342
|
+
}}
|
|
343
|
+
th, td {{
|
|
344
|
+
border: 1px solid var(--line);
|
|
345
|
+
padding: 0.55rem 0.7rem;
|
|
346
|
+
text-align: left;
|
|
347
|
+
vertical-align: top;
|
|
348
|
+
}}
|
|
349
|
+
th {{ background: var(--code); }}
|
|
350
|
+
</style>
|
|
351
|
+
</head>
|
|
352
|
+
<body>
|
|
353
|
+
<main>
|
|
354
|
+
<article>
|
|
355
|
+
{body}
|
|
356
|
+
</article>
|
|
357
|
+
</main>
|
|
358
|
+
</body>
|
|
359
|
+
</html>
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _markdown_to_html(markdown: str) -> str:
|
|
364
|
+
lines = markdown.splitlines()
|
|
365
|
+
blocks: list[str] = []
|
|
366
|
+
paragraph: list[str] = []
|
|
367
|
+
list_items: list[str] = []
|
|
368
|
+
list_kind: str | None = None
|
|
369
|
+
code_lines: list[str] | None = None
|
|
370
|
+
code_language = ""
|
|
371
|
+
index = 0
|
|
372
|
+
|
|
373
|
+
def flush_paragraph() -> None:
|
|
374
|
+
if paragraph:
|
|
375
|
+
blocks.append(f"<p>{_inline_markdown(' '.join(item.strip() for item in paragraph))}</p>")
|
|
376
|
+
paragraph.clear()
|
|
377
|
+
|
|
378
|
+
def flush_list() -> None:
|
|
379
|
+
nonlocal list_kind
|
|
380
|
+
if list_items and list_kind:
|
|
381
|
+
tag = "ol" if list_kind == "ol" else "ul"
|
|
382
|
+
blocks.append(f"<{tag}>\n" + "\n".join(f"<li>{item}</li>" for item in list_items) + f"\n</{tag}>")
|
|
383
|
+
list_items.clear()
|
|
384
|
+
list_kind = None
|
|
385
|
+
|
|
386
|
+
def flush_flow() -> None:
|
|
387
|
+
flush_paragraph()
|
|
388
|
+
flush_list()
|
|
389
|
+
|
|
390
|
+
while index < len(lines):
|
|
391
|
+
line = lines[index]
|
|
392
|
+
fence = _FENCE_RE.match(line)
|
|
393
|
+
if code_lines is not None:
|
|
394
|
+
if fence:
|
|
395
|
+
language_attr = (
|
|
396
|
+
f' class="language-{html.escape(code_language, quote=True)}"' if code_language else ""
|
|
397
|
+
)
|
|
398
|
+
blocks.append(
|
|
399
|
+
f"<pre><code{language_attr}>{html.escape(chr(10).join(code_lines))}</code></pre>"
|
|
400
|
+
)
|
|
401
|
+
code_lines = None
|
|
402
|
+
code_language = ""
|
|
403
|
+
else:
|
|
404
|
+
code_lines.append(line)
|
|
405
|
+
index += 1
|
|
406
|
+
continue
|
|
407
|
+
if fence:
|
|
408
|
+
flush_flow()
|
|
409
|
+
code_lines = []
|
|
410
|
+
code_language = fence.group(2)
|
|
411
|
+
index += 1
|
|
412
|
+
continue
|
|
413
|
+
if not line.strip():
|
|
414
|
+
flush_flow()
|
|
415
|
+
index += 1
|
|
416
|
+
continue
|
|
417
|
+
if _is_table_start(lines, index):
|
|
418
|
+
flush_flow()
|
|
419
|
+
table_lines = [line, lines[index + 1]]
|
|
420
|
+
index += 2
|
|
421
|
+
while index < len(lines) and "|" in lines[index] and lines[index].strip():
|
|
422
|
+
table_lines.append(lines[index])
|
|
423
|
+
index += 1
|
|
424
|
+
blocks.append(_render_table(table_lines))
|
|
425
|
+
continue
|
|
426
|
+
heading = _HEADING_RE.match(line)
|
|
427
|
+
if heading:
|
|
428
|
+
flush_flow()
|
|
429
|
+
level = len(heading.group(1))
|
|
430
|
+
blocks.append(f"<h{level}>{_inline_markdown(heading.group(2))}</h{level}>")
|
|
431
|
+
index += 1
|
|
432
|
+
continue
|
|
433
|
+
if line.strip() in {"---", "***", "___"}:
|
|
434
|
+
flush_flow()
|
|
435
|
+
blocks.append("<hr>")
|
|
436
|
+
index += 1
|
|
437
|
+
continue
|
|
438
|
+
unordered = _UNORDERED_RE.match(line)
|
|
439
|
+
ordered = _ORDERED_RE.match(line)
|
|
440
|
+
if unordered or ordered:
|
|
441
|
+
flush_paragraph()
|
|
442
|
+
if unordered:
|
|
443
|
+
next_kind = "ul"
|
|
444
|
+
item = unordered.group(1)
|
|
445
|
+
else:
|
|
446
|
+
next_kind = "ol"
|
|
447
|
+
assert ordered is not None
|
|
448
|
+
item = ordered.group(1)
|
|
449
|
+
if list_kind != next_kind:
|
|
450
|
+
flush_list()
|
|
451
|
+
list_kind = next_kind
|
|
452
|
+
list_items.append(_inline_markdown(item))
|
|
453
|
+
index += 1
|
|
454
|
+
continue
|
|
455
|
+
if line.lstrip().startswith(">"):
|
|
456
|
+
flush_flow()
|
|
457
|
+
quote_lines = []
|
|
458
|
+
while index < len(lines) and lines[index].lstrip().startswith(">"):
|
|
459
|
+
quote_lines.append(lines[index].lstrip()[1:].strip())
|
|
460
|
+
index += 1
|
|
461
|
+
blocks.append(f"<blockquote><p>{_inline_markdown(' '.join(quote_lines))}</p></blockquote>")
|
|
462
|
+
continue
|
|
463
|
+
flush_list()
|
|
464
|
+
paragraph.append(line)
|
|
465
|
+
index += 1
|
|
466
|
+
|
|
467
|
+
if code_lines is not None:
|
|
468
|
+
language_attr = f' class="language-{html.escape(code_language, quote=True)}"' if code_language else ""
|
|
469
|
+
blocks.append(f"<pre><code{language_attr}>{html.escape(chr(10).join(code_lines))}</code></pre>")
|
|
470
|
+
flush_flow()
|
|
471
|
+
return "\n".join(f" {block}" for block in blocks)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _is_table_start(lines: list[str], index: int) -> bool:
|
|
475
|
+
return (
|
|
476
|
+
index + 1 < len(lines) and "|" in lines[index] and bool(_TABLE_SEPARATOR_RE.match(lines[index + 1]))
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _render_table(table_lines: list[str]) -> str:
|
|
481
|
+
rows = [_split_table_row(line) for line in table_lines]
|
|
482
|
+
header = rows[0]
|
|
483
|
+
body_rows = rows[2:]
|
|
484
|
+
cells = "".join(f"<th>{_inline_markdown(cell)}</th>" for cell in header)
|
|
485
|
+
html_lines = ["<table>", f"<thead><tr>{cells}</tr></thead>", "<tbody>"]
|
|
486
|
+
for row in body_rows:
|
|
487
|
+
row_cells = row + [""] * max(0, len(header) - len(row))
|
|
488
|
+
rendered_cells = "".join(f"<td>{_inline_markdown(cell)}</td>" for cell in row_cells[: len(header)])
|
|
489
|
+
html_lines.append(f"<tr>{rendered_cells}</tr>")
|
|
490
|
+
html_lines.append("</tbody></table>")
|
|
491
|
+
return "\n".join(html_lines)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _split_table_row(line: str) -> list[str]:
|
|
495
|
+
stripped = line.strip()
|
|
496
|
+
if stripped.startswith("|"):
|
|
497
|
+
stripped = stripped[1:]
|
|
498
|
+
if stripped.endswith("|"):
|
|
499
|
+
stripped = stripped[:-1]
|
|
500
|
+
return [cell.strip() for cell in stripped.split("|")]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _inline_fragment(fragment: str) -> str:
|
|
504
|
+
return "\n".join(f" {line}" for line in fragment.splitlines())
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _inline_markdown(text: str) -> str:
|
|
508
|
+
placeholders: list[str] = []
|
|
509
|
+
|
|
510
|
+
def code_repl(match: re.Match[str]) -> str:
|
|
511
|
+
placeholders.append(f"<code>{html.escape(match.group(1))}</code>")
|
|
512
|
+
return f"DOCPULLCODE{len(placeholders) - 1}TOKEN"
|
|
513
|
+
|
|
514
|
+
escaped = html.escape(_CODE_SPAN_RE.sub(code_repl, text), quote=True)
|
|
515
|
+
|
|
516
|
+
def link_repl(match: re.Match[str]) -> str:
|
|
517
|
+
label = match.group(1)
|
|
518
|
+
href = html.unescape(match.group(2))
|
|
519
|
+
safe_href = _safe_href(href)
|
|
520
|
+
return f'<a href="{html.escape(safe_href, quote=True)}" rel="noreferrer">{label}</a>'
|
|
521
|
+
|
|
522
|
+
escaped = _LINK_RE.sub(link_repl, escaped)
|
|
523
|
+
escaped = _BOLD_RE.sub(lambda match: f"<strong>{match.group(1) or match.group(2)}</strong>", escaped)
|
|
524
|
+
escaped = _ITALIC_RE.sub(r"<em>\1</em>", escaped)
|
|
525
|
+
for placeholder_index, replacement in enumerate(placeholders):
|
|
526
|
+
escaped = escaped.replace(f"DOCPULLCODE{placeholder_index}TOKEN", replacement)
|
|
527
|
+
return escaped
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _safe_href(href: str) -> str:
|
|
531
|
+
parsed = urlparse(href.strip())
|
|
532
|
+
if parsed.scheme and parsed.scheme.lower() not in {"http", "https", "mailto"}:
|
|
533
|
+
return "#"
|
|
534
|
+
if not parsed.scheme and not href.startswith(("#", "/")):
|
|
535
|
+
return "#"
|
|
536
|
+
return quote(href, safe="/:#?&=%@+.,;!$'()*[]~")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
__all__ = [
|
|
540
|
+
"ReportHTTPServer",
|
|
541
|
+
"ShareError",
|
|
542
|
+
"create_report_server",
|
|
543
|
+
"create_share_parser",
|
|
544
|
+
"render_report_document",
|
|
545
|
+
"report_url",
|
|
546
|
+
"run_share_cli",
|
|
547
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docpull
|
|
3
|
-
Version: 5.0.
|
|
3
|
+
Version: 5.0.2
|
|
4
4
|
Summary: Convert public web pages into clean Markdown for AI agents and RAG
|
|
5
5
|
Author-email: Zachary Roth <support@raintree.technology>
|
|
6
6
|
Maintainer-email: Raintree Technology <support@raintree.technology>
|
|
@@ -501,7 +501,7 @@ part of the package release contract.
|
|
|
501
501
|
rendering, authenticated-source checks, and cron-friendly monitors:
|
|
502
502
|
`docpull policy`, `docpull discover`, `docpull refresh`,
|
|
503
503
|
`docpull pack audit`, `docpull answer-pack`, `docpull export`,
|
|
504
|
-
`docpull serve`, `docpull render`, `docpull auth check`, and
|
|
504
|
+
`docpull serve`, `docpull share`, `docpull render`, `docpull auth check`, and
|
|
505
505
|
`docpull monitor`.
|
|
506
506
|
- `docpull export` writes local files for OpenAI vector JSONL, LangChain,
|
|
507
507
|
LlamaIndex, DSPy, Sheets CSV/TSV, n8n workflow JSON, Vercel AI SDK JSON,
|
|
@@ -33,6 +33,7 @@ src/docpull/py.typed
|
|
|
33
33
|
src/docpull/rendering.py
|
|
34
34
|
src/docpull/scraper.py
|
|
35
35
|
src/docpull/server.py
|
|
36
|
+
src/docpull/share.py
|
|
36
37
|
src/docpull/skill_export.py
|
|
37
38
|
src/docpull/source_scoring.py
|
|
38
39
|
src/docpull/time_utils.py
|
|
@@ -149,6 +150,7 @@ tests/test_rendering.py
|
|
|
149
150
|
tests/test_save_ndjson.py
|
|
150
151
|
tests/test_save_sqlite.py
|
|
151
152
|
tests/test_security_hardening.py
|
|
153
|
+
tests/test_share.py
|
|
152
154
|
tests/test_special_cases.py
|
|
153
155
|
tests/test_surface_contract.py
|
|
154
156
|
tests/test_time_utils.py
|