docpull 4.0.1__tar.gz → 4.1.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.
Files changed (104) hide show
  1. {docpull-4.0.1/src/docpull.egg-info → docpull-4.1.0}/PKG-INFO +151 -8
  2. {docpull-4.0.1 → docpull-4.1.0}/README.md +147 -7
  3. {docpull-4.0.1 → docpull-4.1.0}/pyproject.toml +6 -2
  4. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/__init__.py +1 -1
  5. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/cli.py +12 -0
  6. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/conversion/chunking.py +56 -23
  7. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/conversion/extractor.py +129 -1
  8. docpull-4.1.0/src/docpull/conversion/markdown.py +516 -0
  9. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/conversion/special_cases.py +108 -3
  10. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/core/fetcher.py +6 -4
  11. docpull-4.1.0/src/docpull/fixtures/__init__.py +1 -0
  12. docpull-4.1.0/src/docpull/fixtures/parallel-search-extract.json +103 -0
  13. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/http/client.py +80 -11
  14. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/mcp/server.py +299 -1
  15. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/mcp/sources.py +1 -0
  16. docpull-4.1.0/src/docpull/pack_tools.py +500 -0
  17. docpull-4.1.0/src/docpull/parallel_workflows.py +5809 -0
  18. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/convert.py +7 -0
  19. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/fetch.py +8 -24
  20. docpull-4.1.0/src/docpull/security/__init__.py +13 -0
  21. docpull-4.1.0/src/docpull/security/download_policy.py +339 -0
  22. docpull-4.1.0/src/docpull/source_scoring.py +98 -0
  23. {docpull-4.0.1 → docpull-4.1.0/src/docpull.egg-info}/PKG-INFO +151 -8
  24. {docpull-4.0.1 → docpull-4.1.0}/src/docpull.egg-info/SOURCES.txt +8 -0
  25. {docpull-4.0.1 → docpull-4.1.0}/src/docpull.egg-info/requires.txt +4 -0
  26. {docpull-4.0.1 → docpull-4.1.0}/tests/test_chunking.py +24 -0
  27. {docpull-4.0.1 → docpull-4.1.0}/tests/test_cli.py +30 -0
  28. {docpull-4.0.1 → docpull-4.1.0}/tests/test_conversion.py +88 -0
  29. {docpull-4.0.1 → docpull-4.1.0}/tests/test_convert_step_new.py +25 -0
  30. {docpull-4.0.1 → docpull-4.1.0}/tests/test_mcp_server.py +13 -0
  31. {docpull-4.0.1 → docpull-4.1.0}/tests/test_mcp_tools.py +1 -0
  32. docpull-4.1.0/tests/test_pack_tools.py +241 -0
  33. docpull-4.1.0/tests/test_parallel_workflows.py +2205 -0
  34. {docpull-4.0.1 → docpull-4.1.0}/tests/test_security_hardening.py +237 -0
  35. {docpull-4.0.1 → docpull-4.1.0}/tests/test_special_cases.py +99 -1
  36. docpull-4.0.1/src/docpull/conversion/markdown.py +0 -280
  37. docpull-4.0.1/src/docpull/security/__init__.py +0 -6
  38. {docpull-4.0.1 → docpull-4.1.0}/LICENSE +0 -0
  39. {docpull-4.0.1 → docpull-4.1.0}/setup.cfg +0 -0
  40. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/__main__.py +0 -0
  41. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/cache/__init__.py +0 -0
  42. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/cache/frontier.py +0 -0
  43. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/cache/manager.py +0 -0
  44. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/cache/streaming_dedup.py +0 -0
  45. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/conversion/__init__.py +0 -0
  46. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/conversion/protocols.py +0 -0
  47. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/conversion/trafilatura_extractor.py +0 -0
  48. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/core/__init__.py +0 -0
  49. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/__init__.py +0 -0
  50. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/_fetch.py +0 -0
  51. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/composite.py +0 -0
  52. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/crawler.py +0 -0
  53. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/filters.py +0 -0
  54. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/link_extractors/__init__.py +0 -0
  55. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/link_extractors/enhanced.py +0 -0
  56. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/link_extractors/protocols.py +0 -0
  57. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/link_extractors/static.py +0 -0
  58. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/protocols.py +0 -0
  59. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/discovery/sitemap.py +0 -0
  60. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/doctor.py +0 -0
  61. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/http/__init__.py +0 -0
  62. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/http/protocols.py +0 -0
  63. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/http/rate_limiter.py +0 -0
  64. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/mcp/__init__.py +0 -0
  65. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/mcp/tools.py +0 -0
  66. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/metadata_extractor.py +0 -0
  67. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/models/__init__.py +0 -0
  68. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/models/config.py +0 -0
  69. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/models/document.py +0 -0
  70. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/models/events.py +0 -0
  71. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/models/profiles.py +0 -0
  72. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/models/run.py +0 -0
  73. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/__init__.py +0 -0
  74. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/base.py +0 -0
  75. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/manifest.py +0 -0
  76. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/__init__.py +0 -0
  77. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/chunk.py +0 -0
  78. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/dedup.py +0 -0
  79. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/metadata.py +0 -0
  80. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/save.py +0 -0
  81. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/save_json.py +0 -0
  82. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/save_ndjson.py +0 -0
  83. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/save_sqlite.py +0 -0
  84. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/pipeline/steps/validate.py +0 -0
  85. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/py.typed +0 -0
  86. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/security/robots.py +0 -0
  87. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/security/url_validator.py +0 -0
  88. {docpull-4.0.1 → docpull-4.1.0}/src/docpull/time_utils.py +0 -0
  89. {docpull-4.0.1 → docpull-4.1.0}/src/docpull.egg-info/dependency_links.txt +0 -0
  90. {docpull-4.0.1 → docpull-4.1.0}/src/docpull.egg-info/entry_points.txt +0 -0
  91. {docpull-4.0.1 → docpull-4.1.0}/src/docpull.egg-info/top_level.txt +0 -0
  92. {docpull-4.0.1 → docpull-4.1.0}/tests/test_cache_conditional_get.py +0 -0
  93. {docpull-4.0.1 → docpull-4.1.0}/tests/test_ci_policy.py +0 -0
  94. {docpull-4.0.1 → docpull-4.1.0}/tests/test_discovery.py +0 -0
  95. {docpull-4.0.1 → docpull-4.1.0}/tests/test_frontier_resume.py +0 -0
  96. {docpull-4.0.1 → docpull-4.1.0}/tests/test_integration.py +0 -0
  97. {docpull-4.0.1 → docpull-4.1.0}/tests/test_link_extractors.py +0 -0
  98. {docpull-4.0.1 → docpull-4.1.0}/tests/test_naming.py +0 -0
  99. {docpull-4.0.1 → docpull-4.1.0}/tests/test_outputs_e2e.py +0 -0
  100. {docpull-4.0.1 → docpull-4.1.0}/tests/test_pipeline.py +0 -0
  101. {docpull-4.0.1 → docpull-4.1.0}/tests/test_real_site_regressions.py +0 -0
  102. {docpull-4.0.1 → docpull-4.1.0}/tests/test_save_ndjson.py +0 -0
  103. {docpull-4.0.1 → docpull-4.1.0}/tests/test_save_sqlite.py +0 -0
  104. {docpull-4.0.1 → docpull-4.1.0}/tests/test_time_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docpull
3
- Version: 4.0.1
3
+ Version: 4.1.0
4
4
  Summary: Pull documentation from the web and convert to clean markdown
5
5
  Author-email: Zachary Roth <support@raintree.technology>
6
6
  Maintainer-email: Raintree Technology <support@raintree.technology>
@@ -62,6 +62,8 @@ Requires-Dist: mcp>=1.0.0; extra == "mcp"
62
62
  Requires-Dist: pyjwt>=2.13.0; extra == "mcp"
63
63
  Requires-Dist: python-multipart>=0.0.27; extra == "mcp"
64
64
  Requires-Dist: starlette>=1.0.1; extra == "mcp"
65
+ Provides-Extra: parallel
66
+ Requires-Dist: parallel-web>=1.0.1; extra == "parallel"
65
67
  Provides-Extra: llm
66
68
  Requires-Dist: tiktoken>=0.7.0; extra == "llm"
67
69
  Provides-Extra: all
@@ -73,6 +75,7 @@ Requires-Dist: mcp>=1.0.0; extra == "all"
73
75
  Requires-Dist: pyjwt>=2.13.0; extra == "all"
74
76
  Requires-Dist: python-multipart>=0.0.27; extra == "all"
75
77
  Requires-Dist: starlette>=1.0.1; extra == "all"
78
+ Requires-Dist: parallel-web>=1.0.1; extra == "all"
76
79
  Provides-Extra: dev
77
80
  Requires-Dist: pytest>=7.0.0; extra == "dev"
78
81
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -220,6 +223,141 @@ docpull https://site.com --profile mirror # Full archive, polite, cached, hier
220
223
  docpull https://site.com --profile quick # Sampling: 50 pages, depth 2.
221
224
  ```
222
225
 
226
+ ## Parallel context packs
227
+
228
+ `docpull[parallel]` adds an optional source-discovery and research layer on top
229
+ of Parallel web intelligence APIs. Use the core crawler when you already know
230
+ the docs URL. Use Parallel packs when an agent needs current web sources found,
231
+ extracted, researched, organized, and loaded as durable local context.
232
+
233
+ Parallel handles live Search, Extract, Task, Entity Search, FindAll, TaskGroup,
234
+ and Monitor calls. docpull turns those results into local, inspectable packs
235
+ with Markdown, NDJSON, manifests, hashes, source indexes, workflow metadata, and
236
+ an `AGENT_CONTEXT.md` load plan so agents do not need to reverse-engineer raw API
237
+ responses before using the pack.
238
+
239
+ Live calls use your own Parallel API key. docpull never proxies requests through
240
+ a shared account or writes the key into pack artifacts. For normal PyPI installs,
241
+ `docpull parallel init` stores the key in a local secrets file with restricted
242
+ permissions so it works across shells and agent sessions. Pack artifacts can
243
+ still contain source content, workflow inputs/outputs, selected URLs, and
244
+ metadata, so treat them as research data.
245
+
246
+ ```bash
247
+ pip install 'docpull[parallel]'
248
+
249
+ # Recommended local setup. Prompts securely and writes ~/.config/docpull/secrets.env.
250
+ docpull parallel init
251
+ docpull parallel auth --json
252
+
253
+ # Optional project-local setup for agent worktrees. Writes .env.local and
254
+ # adds it to .gitignore.
255
+ docpull parallel init --project
256
+
257
+ # CI can still use a normal environment secret instead.
258
+ export PARALLEL_API_KEY="<your-parallel-api-key>"
259
+
260
+ docpull parallel context-pack "Compare AI web-search APIs for agents" \
261
+ --query "AI web search API" \
262
+ --query "agent web extraction API" \
263
+ --include-domain parallel.ai \
264
+ --exclude-domain onparallel.com \
265
+ --output-dir ./packs/ai-web-search \
266
+ --mode advanced \
267
+ --extract-limit 8 \
268
+ --max-estimated-cost 0.05 \
269
+ --task-brief
270
+ ```
271
+
272
+ `docpull parallel auth` checks local SDK/key presence without printing the key
273
+ value and reports the key source (`env`, `project_env`, `user_config`, or
274
+ `missing`). It does not make a live Parallel call or prove the key is valid. Use
275
+ `--json` when an agent or CI job needs machine-readable configuration status.
276
+
277
+ The pack contains:
278
+
279
+ - `AGENT_CONTEXT.md` — agent load plan with source order, pack signals, warnings, errors, and artifact map.
280
+ - `documents.ndjson` — chunked records for agents and RAG pipelines.
281
+ - `corpus.manifest.json` — stable docpull record manifest.
282
+ - `parallel.pack.json` — Parallel workflow metadata, selected URLs, errors, IDs, and usage.
283
+ - `sources.md` and `sources/*.md` — human-readable sources and extracted Markdown.
284
+ - `brief.md` when `--task-brief` is enabled.
285
+
286
+ That makes the Parallel layer more than a pass-through wrapper: Parallel finds
287
+ and enriches live web sources, while docpull normalizes them into a repeatable
288
+ context pack that can be scored, diffed, audited, and loaded offline.
289
+
290
+ For planning without spending credits, use `--dry-run`. For repeatable packs,
291
+ use `--include-domain`, `--exclude-domain`, `--after-date`, and `--max-search-results`
292
+ to pin the source policy that appears later in `parallel.pack.json`.
293
+ Use `--fetch-max-age-seconds`, `--fetch-timeout-seconds`,
294
+ `--disable-cache-fallback`, `--excerpt-chars-per-result`, and `--location`
295
+ when you need Parallel's freshness, excerpt-size, and geo controls.
296
+
297
+ Offline fixtures are supported for demos and tests without a Parallel account:
298
+
299
+ ```bash
300
+ docpull parallel demo --output-dir ./packs/demo
301
+ ```
302
+
303
+ When working from the source checkout, the same fixture can also be imported
304
+ directly with `docpull parallel import ./docs/examples/parallel-search-extract.json`.
305
+
306
+ The core workflow is Search + Extract + optional Task. Broader pack workflows
307
+ cover Search-only snapshots, docs discovery plans, Extract-known-URL packs,
308
+ core-fetch-with-Parallel-fallback packs, diff briefs, Task lifecycle snapshots,
309
+ Entity Search, FindAll lifecycle actions, TaskGroup batches, Monitor
310
+ metadata/events, API specs, and local source scoring.
311
+ See [docs/parallel.md](docs/parallel.md) for the Parallel product cross-reference
312
+ and the boundary between docpull, Parallel MCP, and planned workflows.
313
+
314
+ Additional Parallel-backed artifact commands:
315
+
316
+ ```bash
317
+ # Readiness and API-key setup checks.
318
+ docpull parallel auth
319
+ docpull parallel init
320
+ docpull parallel init --project
321
+
322
+ # Search + Extract + optional Task brief.
323
+ docpull parallel context-pack "Compare AI web-search APIs for agents" \
324
+ --query "AI web search API" \
325
+ --include-domain parallel.ai \
326
+ --dry-run
327
+
328
+ # Search-only, docs-discovery, known-URL extract, fallback, and structured Task packs.
329
+ docpull parallel search-pack "Parallel Search API docs" --query "Parallel Search API" --dry-run
330
+ docpull parallel discover-docs "Find Parallel API docs" \
331
+ --query "Parallel Search API docs" \
332
+ --include-domain docs.parallel.ai \
333
+ --dry-run
334
+ docpull parallel extract-pack https://docs.parallel.ai/api-reference/search/search --dry-run
335
+ docpull parallel fallback-pack https://docs.parallel.ai/api-reference/search/search --dry-run
336
+ docpull parallel task-pack "Research Parallel Search API" \
337
+ --source-include-domain docs.parallel.ai \
338
+ --output-schema-json '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' \
339
+ --dry-run
340
+
341
+ # Entity dossiers, FindAll lifecycle, batch enrichment, monitors, and API specs.
342
+ docpull parallel entity-pack "AI developer infrastructure companies" --entity-type companies
343
+ docpull parallel findall-pack "AI developer infrastructure companies" --generator preview --dry-run
344
+ docpull parallel findall-ingest-pack "Find companies with public API docs"
345
+ docpull parallel findall-result-pack findall_123 --output-dir ./packs/findall-result
346
+ docpull parallel taskgroup-pack ./companies.json --prompt-template "Research {company}" --dry-run
347
+ docpull parallel taskgroup-pack ./companies.json --prompt-template "Research {company}" --wait
348
+ docpull parallel monitor-pack create "New vendor pricing changes" --dry-run
349
+ docpull parallel monitor-pack create --type snapshot --task-run-id run_123 --dry-run
350
+ docpull parallel monitor-pack list --status active --output-dir ./packs/monitors
351
+ docpull parallel monitor-pack events monitor_123 --cursor next_cursor --output-dir ./packs/events
352
+ docpull parallel api-pack https://docs.parallel.ai/public-openapi.json --output-dir ./packs/parallel-openapi
353
+ docpull parallel run ./docs/examples/parallel-llms-api-pack.yaml --dry-run
354
+ docpull parallel run ./docs/examples/parallel-openapi-api-pack.yaml --dry-run
355
+ docpull pack score ./packs/parallel-openapi
356
+ docpull pack sources ./packs/parallel-openapi --require-domain docs.parallel.ai
357
+ docpull pack diff ./packs/old ./packs/new --markdown ./packs/changes.md
358
+ docpull parallel diff-brief ./packs/old ./packs/new --dry-run
359
+ ```
360
+
223
361
  ## MCP server
224
362
 
225
363
  docpull ships an MCP (Model Context Protocol) server so AI agents can call it
@@ -271,17 +409,21 @@ pip install 'docpull[mcp]'
271
409
 
272
410
  See [plugin/README.md](plugin/README.md) for details.
273
411
 
274
- Tools exposed (8 total — read tools advertise `readOnlyHint` so hosts that auto-approve safe tools won't prompt):
412
+ Tools exposed (12 total — read tools advertise `readOnlyHint` so hosts that auto-approve safe tools won't prompt):
275
413
 
276
414
  Read:
277
415
  - `fetch_url(url, max_tokens?)` — one-shot fetch, no crawl. HTTPS-only, SSRF-validated.
278
- - `list_sources(category?)` — show available aliases (react, nextjs, fastapi, …)
416
+ - `list_sources(category?)` — show available aliases (react, nextjs, parallel, fastapi, …)
279
417
  - `list_indexed()` — what has been fetched locally, with last-fetched age
280
418
  - `grep_docs(pattern, library?, limit?, context?)` — regex search across fetched Markdown (length-capped + wall-clock budgeted to mitigate ReDoS)
281
419
  - `read_doc(library, path, line_start?, line_end?)` — read a specific cached file, optionally line-sliced
420
+ - `pack_score(pack_dir, required_domains?)` — score a local context pack for agent readiness.
421
+ - `pack_diff(old_pack_dir, new_pack_dir)` — compare pack URL sets and content hashes.
282
422
 
283
423
  Write:
284
424
  - `ensure_docs(source, force?, profile?)` — fetch a named library (cached 7 days). Forwards progress to clients that supply a `progressToken`.
425
+ - `parallel_context_pack(...)` — build or dry-run a Parallel Search + Extract pack without shelling out.
426
+ - `parallel_api_pack(source, kind?, output_dir?)` — build a pack from `llms.txt` or OpenAPI.
285
427
  - `add_source(name, url, description?, category?, max_pages?, force?)` — register a user alias (HTTPS-only, atomic write to `sources.yaml`).
286
428
  - `remove_source(name, delete_cache?)` — drop a user alias and (optionally) its cached docs.
287
429
 
@@ -388,15 +530,16 @@ End-to-end numbers from `tests/benchmarks/test_10k_pages.py` against a
388
530
  synthetic 10,000-page localhost site (RAG profile, `max_concurrent=50`,
389
531
  `per_host_concurrent=50`, HTTP keep-alive, 5% injected duplicate content).
390
532
  The benchmark emits progress every 1,000 pages plus a final JSON report for
391
- trend tooling.
533
+ trend tooling. Wall time varies by machine; the value below is the latest
534
+ local audit run.
392
535
 
393
536
  | Metric | Value |
394
537
  |---|---|
395
- | Total wall time | ~333 s |
538
+ | Total wall time | ~309 s |
396
539
  | Pages fetched / skipped / failed | 9,501 / 499 / 0 |
397
- | Time to first saved page | ~130 ms |
398
- | Per-page latency p50 / p95 / p99 | ~0 / 166 / 232 ms |
399
- | Peak RSS delta from baseline | ~94 MB |
540
+ | Time to first saved page | ~119 ms |
541
+ | Per-page latency p50 / p95 / p99 | ~0 / 168 / 271 ms |
542
+ | Peak RSS delta from baseline | ~93 MB |
400
543
  | Cache manifest size on disk | ~8.9 MB |
401
544
  | Duplicates detected (5% injected) | 499 / 500 |
402
545
 
@@ -130,6 +130,141 @@ docpull https://site.com --profile mirror # Full archive, polite, cached, hier
130
130
  docpull https://site.com --profile quick # Sampling: 50 pages, depth 2.
131
131
  ```
132
132
 
133
+ ## Parallel context packs
134
+
135
+ `docpull[parallel]` adds an optional source-discovery and research layer on top
136
+ of Parallel web intelligence APIs. Use the core crawler when you already know
137
+ the docs URL. Use Parallel packs when an agent needs current web sources found,
138
+ extracted, researched, organized, and loaded as durable local context.
139
+
140
+ Parallel handles live Search, Extract, Task, Entity Search, FindAll, TaskGroup,
141
+ and Monitor calls. docpull turns those results into local, inspectable packs
142
+ with Markdown, NDJSON, manifests, hashes, source indexes, workflow metadata, and
143
+ an `AGENT_CONTEXT.md` load plan so agents do not need to reverse-engineer raw API
144
+ responses before using the pack.
145
+
146
+ Live calls use your own Parallel API key. docpull never proxies requests through
147
+ a shared account or writes the key into pack artifacts. For normal PyPI installs,
148
+ `docpull parallel init` stores the key in a local secrets file with restricted
149
+ permissions so it works across shells and agent sessions. Pack artifacts can
150
+ still contain source content, workflow inputs/outputs, selected URLs, and
151
+ metadata, so treat them as research data.
152
+
153
+ ```bash
154
+ pip install 'docpull[parallel]'
155
+
156
+ # Recommended local setup. Prompts securely and writes ~/.config/docpull/secrets.env.
157
+ docpull parallel init
158
+ docpull parallel auth --json
159
+
160
+ # Optional project-local setup for agent worktrees. Writes .env.local and
161
+ # adds it to .gitignore.
162
+ docpull parallel init --project
163
+
164
+ # CI can still use a normal environment secret instead.
165
+ export PARALLEL_API_KEY="<your-parallel-api-key>"
166
+
167
+ docpull parallel context-pack "Compare AI web-search APIs for agents" \
168
+ --query "AI web search API" \
169
+ --query "agent web extraction API" \
170
+ --include-domain parallel.ai \
171
+ --exclude-domain onparallel.com \
172
+ --output-dir ./packs/ai-web-search \
173
+ --mode advanced \
174
+ --extract-limit 8 \
175
+ --max-estimated-cost 0.05 \
176
+ --task-brief
177
+ ```
178
+
179
+ `docpull parallel auth` checks local SDK/key presence without printing the key
180
+ value and reports the key source (`env`, `project_env`, `user_config`, or
181
+ `missing`). It does not make a live Parallel call or prove the key is valid. Use
182
+ `--json` when an agent or CI job needs machine-readable configuration status.
183
+
184
+ The pack contains:
185
+
186
+ - `AGENT_CONTEXT.md` — agent load plan with source order, pack signals, warnings, errors, and artifact map.
187
+ - `documents.ndjson` — chunked records for agents and RAG pipelines.
188
+ - `corpus.manifest.json` — stable docpull record manifest.
189
+ - `parallel.pack.json` — Parallel workflow metadata, selected URLs, errors, IDs, and usage.
190
+ - `sources.md` and `sources/*.md` — human-readable sources and extracted Markdown.
191
+ - `brief.md` when `--task-brief` is enabled.
192
+
193
+ That makes the Parallel layer more than a pass-through wrapper: Parallel finds
194
+ and enriches live web sources, while docpull normalizes them into a repeatable
195
+ context pack that can be scored, diffed, audited, and loaded offline.
196
+
197
+ For planning without spending credits, use `--dry-run`. For repeatable packs,
198
+ use `--include-domain`, `--exclude-domain`, `--after-date`, and `--max-search-results`
199
+ to pin the source policy that appears later in `parallel.pack.json`.
200
+ Use `--fetch-max-age-seconds`, `--fetch-timeout-seconds`,
201
+ `--disable-cache-fallback`, `--excerpt-chars-per-result`, and `--location`
202
+ when you need Parallel's freshness, excerpt-size, and geo controls.
203
+
204
+ Offline fixtures are supported for demos and tests without a Parallel account:
205
+
206
+ ```bash
207
+ docpull parallel demo --output-dir ./packs/demo
208
+ ```
209
+
210
+ When working from the source checkout, the same fixture can also be imported
211
+ directly with `docpull parallel import ./docs/examples/parallel-search-extract.json`.
212
+
213
+ The core workflow is Search + Extract + optional Task. Broader pack workflows
214
+ cover Search-only snapshots, docs discovery plans, Extract-known-URL packs,
215
+ core-fetch-with-Parallel-fallback packs, diff briefs, Task lifecycle snapshots,
216
+ Entity Search, FindAll lifecycle actions, TaskGroup batches, Monitor
217
+ metadata/events, API specs, and local source scoring.
218
+ See [docs/parallel.md](docs/parallel.md) for the Parallel product cross-reference
219
+ and the boundary between docpull, Parallel MCP, and planned workflows.
220
+
221
+ Additional Parallel-backed artifact commands:
222
+
223
+ ```bash
224
+ # Readiness and API-key setup checks.
225
+ docpull parallel auth
226
+ docpull parallel init
227
+ docpull parallel init --project
228
+
229
+ # Search + Extract + optional Task brief.
230
+ docpull parallel context-pack "Compare AI web-search APIs for agents" \
231
+ --query "AI web search API" \
232
+ --include-domain parallel.ai \
233
+ --dry-run
234
+
235
+ # Search-only, docs-discovery, known-URL extract, fallback, and structured Task packs.
236
+ docpull parallel search-pack "Parallel Search API docs" --query "Parallel Search API" --dry-run
237
+ docpull parallel discover-docs "Find Parallel API docs" \
238
+ --query "Parallel Search API docs" \
239
+ --include-domain docs.parallel.ai \
240
+ --dry-run
241
+ docpull parallel extract-pack https://docs.parallel.ai/api-reference/search/search --dry-run
242
+ docpull parallel fallback-pack https://docs.parallel.ai/api-reference/search/search --dry-run
243
+ docpull parallel task-pack "Research Parallel Search API" \
244
+ --source-include-domain docs.parallel.ai \
245
+ --output-schema-json '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' \
246
+ --dry-run
247
+
248
+ # Entity dossiers, FindAll lifecycle, batch enrichment, monitors, and API specs.
249
+ docpull parallel entity-pack "AI developer infrastructure companies" --entity-type companies
250
+ docpull parallel findall-pack "AI developer infrastructure companies" --generator preview --dry-run
251
+ docpull parallel findall-ingest-pack "Find companies with public API docs"
252
+ docpull parallel findall-result-pack findall_123 --output-dir ./packs/findall-result
253
+ docpull parallel taskgroup-pack ./companies.json --prompt-template "Research {company}" --dry-run
254
+ docpull parallel taskgroup-pack ./companies.json --prompt-template "Research {company}" --wait
255
+ docpull parallel monitor-pack create "New vendor pricing changes" --dry-run
256
+ docpull parallel monitor-pack create --type snapshot --task-run-id run_123 --dry-run
257
+ docpull parallel monitor-pack list --status active --output-dir ./packs/monitors
258
+ docpull parallel monitor-pack events monitor_123 --cursor next_cursor --output-dir ./packs/events
259
+ docpull parallel api-pack https://docs.parallel.ai/public-openapi.json --output-dir ./packs/parallel-openapi
260
+ docpull parallel run ./docs/examples/parallel-llms-api-pack.yaml --dry-run
261
+ docpull parallel run ./docs/examples/parallel-openapi-api-pack.yaml --dry-run
262
+ docpull pack score ./packs/parallel-openapi
263
+ docpull pack sources ./packs/parallel-openapi --require-domain docs.parallel.ai
264
+ docpull pack diff ./packs/old ./packs/new --markdown ./packs/changes.md
265
+ docpull parallel diff-brief ./packs/old ./packs/new --dry-run
266
+ ```
267
+
133
268
  ## MCP server
134
269
 
135
270
  docpull ships an MCP (Model Context Protocol) server so AI agents can call it
@@ -181,17 +316,21 @@ pip install 'docpull[mcp]'
181
316
 
182
317
  See [plugin/README.md](plugin/README.md) for details.
183
318
 
184
- Tools exposed (8 total — read tools advertise `readOnlyHint` so hosts that auto-approve safe tools won't prompt):
319
+ Tools exposed (12 total — read tools advertise `readOnlyHint` so hosts that auto-approve safe tools won't prompt):
185
320
 
186
321
  Read:
187
322
  - `fetch_url(url, max_tokens?)` — one-shot fetch, no crawl. HTTPS-only, SSRF-validated.
188
- - `list_sources(category?)` — show available aliases (react, nextjs, fastapi, …)
323
+ - `list_sources(category?)` — show available aliases (react, nextjs, parallel, fastapi, …)
189
324
  - `list_indexed()` — what has been fetched locally, with last-fetched age
190
325
  - `grep_docs(pattern, library?, limit?, context?)` — regex search across fetched Markdown (length-capped + wall-clock budgeted to mitigate ReDoS)
191
326
  - `read_doc(library, path, line_start?, line_end?)` — read a specific cached file, optionally line-sliced
327
+ - `pack_score(pack_dir, required_domains?)` — score a local context pack for agent readiness.
328
+ - `pack_diff(old_pack_dir, new_pack_dir)` — compare pack URL sets and content hashes.
192
329
 
193
330
  Write:
194
331
  - `ensure_docs(source, force?, profile?)` — fetch a named library (cached 7 days). Forwards progress to clients that supply a `progressToken`.
332
+ - `parallel_context_pack(...)` — build or dry-run a Parallel Search + Extract pack without shelling out.
333
+ - `parallel_api_pack(source, kind?, output_dir?)` — build a pack from `llms.txt` or OpenAPI.
195
334
  - `add_source(name, url, description?, category?, max_pages?, force?)` — register a user alias (HTTPS-only, atomic write to `sources.yaml`).
196
335
  - `remove_source(name, delete_cache?)` — drop a user alias and (optionally) its cached docs.
197
336
 
@@ -298,15 +437,16 @@ End-to-end numbers from `tests/benchmarks/test_10k_pages.py` against a
298
437
  synthetic 10,000-page localhost site (RAG profile, `max_concurrent=50`,
299
438
  `per_host_concurrent=50`, HTTP keep-alive, 5% injected duplicate content).
300
439
  The benchmark emits progress every 1,000 pages plus a final JSON report for
301
- trend tooling.
440
+ trend tooling. Wall time varies by machine; the value below is the latest
441
+ local audit run.
302
442
 
303
443
  | Metric | Value |
304
444
  |---|---|
305
- | Total wall time | ~333 s |
445
+ | Total wall time | ~309 s |
306
446
  | Pages fetched / skipped / failed | 9,501 / 499 / 0 |
307
- | Time to first saved page | ~130 ms |
308
- | Per-page latency p50 / p95 / p99 | ~0 / 166 / 232 ms |
309
- | Peak RSS delta from baseline | ~94 MB |
447
+ | Time to first saved page | ~119 ms |
448
+ | Per-page latency p50 / p95 / p99 | ~0 / 168 / 271 ms |
449
+ | Peak RSS delta from baseline | ~93 MB |
310
450
  | Cache manifest size on disk | ~8.9 MB |
311
451
  | Duplicates detected (5% injected) | 499 / 500 |
312
452
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "docpull"
7
- version = "4.0.1"
7
+ version = "4.1.0"
8
8
  dynamic = []
9
9
  description = "Pull documentation from the web and convert to clean markdown"
10
10
  readme = {file = "README.md", content-type = "text/markdown"}
@@ -94,6 +94,9 @@ mcp = [
94
94
  "python-multipart>=0.0.27",
95
95
  "starlette>=1.0.1",
96
96
  ]
97
+ parallel = [
98
+ "parallel-web>=1.0.1",
99
+ ]
97
100
  llm = [
98
101
  "tiktoken>=0.7.0",
99
102
  ]
@@ -106,6 +109,7 @@ all = [
106
109
  "pyjwt>=2.13.0",
107
110
  "python-multipart>=0.0.27",
108
111
  "starlette>=1.0.1",
112
+ "parallel-web>=1.0.1",
109
113
  ]
110
114
  dev = [
111
115
  "pytest>=7.0.0",
@@ -138,7 +142,7 @@ where = ["src"]
138
142
  include = ["docpull*"]
139
143
 
140
144
  [tool.setuptools.package-data]
141
- docpull = ["py.typed"]
145
+ docpull = ["py.typed", "fixtures/*.json"]
142
146
 
143
147
  [tool.ruff]
144
148
  line-length = 110
@@ -14,7 +14,7 @@ Usage:
14
14
  print(event)
15
15
  """
16
16
 
17
- __version__ = "4.0.1"
17
+ __version__ = "4.1.0"
18
18
 
19
19
  from .cache import CacheManager, StreamingDeduplicator
20
20
  from .conversion.chunking import Chunk, TokenCounter, chunk_markdown
@@ -557,6 +557,10 @@ def run_fetcher(args: argparse.Namespace) -> int:
557
557
  failure_skips = {
558
558
  SkipReason.URL_VALIDATION_FAILED,
559
559
  SkipReason.ROBOTS_DISALLOWED,
560
+ SkipReason.HTTP_ERROR,
561
+ SkipReason.INVALID_CONTENT_TYPE,
562
+ SkipReason.NO_CONTENT_EXTRACTED,
563
+ SkipReason.NO_CONTENT_TO_SAVE,
560
564
  }
561
565
  return 1 if ctx.skip_code in failure_skips else 0
562
566
  if not args.quiet:
@@ -662,6 +666,14 @@ def main(argv: list[str] | None = None) -> int:
662
666
  from .mcp.server import run_mcp_server
663
667
 
664
668
  return run_mcp_server(raw_argv[1:])
669
+ if raw_argv and raw_argv[0] == "parallel":
670
+ from .parallel_workflows import run_parallel_cli
671
+
672
+ return run_parallel_cli(raw_argv[1:])
673
+ if raw_argv and raw_argv[0] == "pack":
674
+ from .pack_tools import run_pack_cli
675
+
676
+ return run_pack_cli(raw_argv[1:])
665
677
 
666
678
  parser = create_parser()
667
679
  args = parser.parse_args(raw_argv)
@@ -14,7 +14,7 @@ from dataclasses import dataclass
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
 
17
- _HEADING_RE = re.compile(r"^(#{1,6})\s+", re.MULTILINE)
17
+ _HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
18
18
 
19
19
 
20
20
  @dataclass
@@ -88,19 +88,38 @@ def _split_on_headings(body: str) -> list[tuple[str | None, str]]:
88
88
  The first tuple may have ``heading=None`` for any preamble before the
89
89
  first heading.
90
90
  """
91
- matches = list(_HEADING_RE.finditer(body))
91
+ matches: list[tuple[int, str]] = []
92
+ in_fence = False
93
+ fence_marker = ""
94
+ offset = 0
95
+ for line in body.splitlines(keepends=True):
96
+ stripped = line.strip()
97
+ if stripped.startswith(("```", "~~~")):
98
+ marker = stripped[:3]
99
+ if not in_fence:
100
+ in_fence = True
101
+ fence_marker = marker
102
+ elif stripped.startswith(fence_marker):
103
+ in_fence = False
104
+ fence_marker = ""
105
+ offset += len(line)
106
+ continue
107
+ if not in_fence:
108
+ match = _HEADING_RE.match(line.rstrip("\n"))
109
+ if match:
110
+ matches.append((offset, match.group(2).strip()))
111
+ offset += len(line)
112
+
92
113
  if not matches:
93
114
  return [(None, body)]
94
115
 
95
116
  sections: list[tuple[str | None, str]] = []
96
- if matches[0].start() > 0:
97
- sections.append((None, body[: matches[0].start()]))
117
+ if matches[0][0] > 0:
118
+ sections.append((None, body[: matches[0][0]]))
98
119
 
99
- for i, m in enumerate(matches):
100
- start = m.start()
101
- end = matches[i + 1].start() if i + 1 < len(matches) else len(body)
120
+ for i, (start, heading_line) in enumerate(matches):
121
+ end = matches[i + 1][0] if i + 1 < len(matches) else len(body)
102
122
  chunk = body[start:end]
103
- heading_line = chunk.split("\n", 1)[0].lstrip("#").strip()
104
123
  sections.append((heading_line, chunk))
105
124
  return sections
106
125
 
@@ -109,10 +128,18 @@ def _split_paragraphs(section: str) -> list[str]:
109
128
  # Split on blank lines while preserving code blocks intact.
110
129
  parts: list[str] = []
111
130
  in_code = False
131
+ fence_marker = ""
112
132
  buf: list[str] = []
113
133
  for line in section.split("\n"):
114
- if line.strip().startswith("```"):
115
- in_code = not in_code
134
+ stripped = line.strip()
135
+ if stripped.startswith(("```", "~~~")):
136
+ marker = stripped[:3]
137
+ if not in_code:
138
+ in_code = True
139
+ fence_marker = marker
140
+ elif stripped.startswith(fence_marker):
141
+ in_code = False
142
+ fence_marker = ""
116
143
  if line.strip() == "" and not in_code and buf:
117
144
  parts.append("\n".join(buf))
118
145
  buf = []
@@ -155,16 +182,17 @@ def chunk_markdown(
155
182
  chunks: list[Chunk] = []
156
183
  buf_parts: list[str] = []
157
184
  buf_tokens = 0
158
- current_heading: str | None = None
185
+ buf_heading: str | None = None
159
186
 
160
187
  def flush() -> None:
161
- nonlocal buf_parts, buf_tokens
188
+ nonlocal buf_parts, buf_tokens, buf_heading
162
189
  if not buf_parts:
163
190
  return
164
191
  text = "\n\n".join(part.strip() for part in buf_parts if part.strip())
165
192
  if not text:
166
193
  buf_parts = []
167
194
  buf_tokens = 0
195
+ buf_heading = None
168
196
  return
169
197
  prefix = frontmatter if keep_frontmatter_in_first and not chunks else ""
170
198
  final = (prefix + text).strip() + "\n"
@@ -173,25 +201,31 @@ def chunk_markdown(
173
201
  index=len(chunks),
174
202
  text=final,
175
203
  token_count=counter.count(final),
176
- heading=current_heading,
204
+ heading=buf_heading,
177
205
  )
178
206
  )
179
207
  buf_parts = []
180
208
  buf_tokens = 0
209
+ buf_heading = None
210
+
211
+ def append_part(part: str, token_count: int, heading: str | None) -> None:
212
+ nonlocal buf_tokens, buf_heading
213
+ if not part.strip():
214
+ return
215
+ if buf_heading is None and heading is not None:
216
+ buf_heading = heading
217
+ buf_parts.append(part)
218
+ buf_tokens += token_count
181
219
 
182
220
  for heading, section in sections:
183
- if heading is not None:
184
- current_heading = heading
185
221
  section_tokens = counter.count(section)
186
222
  if section_tokens <= max_tokens and buf_tokens + section_tokens <= max_tokens:
187
- buf_parts.append(section)
188
- buf_tokens += section_tokens
223
+ append_part(section, section_tokens, heading)
189
224
  continue
190
225
  # Section alone fits but buffer is full: flush then add.
191
226
  if section_tokens <= max_tokens:
192
227
  flush()
193
- buf_parts.append(section)
194
- buf_tokens = section_tokens
228
+ append_part(section, section_tokens, heading)
195
229
  continue
196
230
  # Section too large: flush current buffer, then split paragraphs.
197
231
  flush()
@@ -207,15 +241,14 @@ def chunk_markdown(
207
241
  Chunk(
208
242
  index=len(chunks),
209
243
  text=text,
210
- token_count=p_tokens,
211
- heading=current_heading,
244
+ token_count=counter.count(text),
245
+ heading=heading,
212
246
  )
213
247
  )
214
248
  continue
215
249
  if buf_tokens + p_tokens > max_tokens:
216
250
  flush()
217
- buf_parts.append(para)
218
- buf_tokens += p_tokens
251
+ append_part(para, p_tokens, heading)
219
252
 
220
253
  flush()
221
254
  return chunks