pipulate 2.76__tar.gz → 2.77__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.
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/journal/references/index.md +12 -7
- {pipulate-2.76 → pipulate-2.77}/PKG-INFO +1 -1
- {pipulate-2.76 → pipulate-2.77}/__init__.py +2 -2
- {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/PKG-INFO +1 -1
- {pipulate-2.76 → pipulate-2.77}/pyproject.toml +1 -1
- {pipulate-2.76 → pipulate-2.77}/tests/test_mck_rep2.py +43 -14
- {pipulate-2.76 → pipulate-2.77}/tools/scraper_tools.py +116 -4
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/gsc-readonly/SKILL.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/hello-workflow/SKILL.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/journal/SKILL.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/pipulate/SKILL.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/roles/SKILL.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/.agents/skills/sheets-readonly/SKILL.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/AGENTS.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/AUDIT.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/LICENSE +0 -0
- {pipulate-2.76 → pipulate-2.77}/MANIFEST.in +0 -0
- {pipulate-2.76 → pipulate-2.77}/README.md +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/010_introduction.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/015_config.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/020_profiles.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/025_aspect.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/030_roles.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/040_hello_workflow.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/050_documentation.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/060_tasks.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/070_history.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/110_parameter_buster.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/120_link_graph.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/130_gap_analysis.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/200_workflow_genesis.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/210_widget_examples.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/220_roadmap.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/230_dev_assistant.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/240_simon_mcp.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/300_blank_placeholder.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/400_botify_trifecta.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/440_browser_automation.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/450_stream_simulator.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/510_text_field.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/520_text_area.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/530_dropdown.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/540_checkboxes.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/550_radios.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/560_range.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/570_switch.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/580_upload.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/610_markdown.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/620_mermaid.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/630_prism.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/640_javascript.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/710_pandas.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/720_rich.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/730_matplotlib.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/810_webbrowser.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/820_selenium.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/apps/830_pico_slider.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/cli.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/config.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/__init__.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/ai_dictdb.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/ai_tool_discovery_simple_parser.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/append_only_conversation.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/ascii_displays.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/botify/__init__.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/botify/code_generators.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/botify/true_schema_discoverer.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/botify_code_generation.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/crud.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/database_safety_wrapper.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/dom_processing/__init__.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/dom_processing/ai_dom_beautifier.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/dom_processing/enhanced_dom_processor.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/durable_backup_system.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/mcp_orchestrator.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/server_logging.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/stream_orchestrator.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/imports/voice_synthesis.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate/__init__.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate/core.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/SOURCES.txt +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/dependency_links.txt +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/entry_points.txt +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/requires.txt +0 -0
- {pipulate-2.76 → pipulate-2.77}/pipulate.egg-info/top_level.txt +0 -0
- {pipulate-2.76 → pipulate-2.77}/server.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/setup.cfg +0 -0
- {pipulate-2.76 → pipulate-2.77}/tests/test_apply_airlock.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/__init__.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/advanced_automation_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/botify_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/connector_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/conversation_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/dom_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/keychain_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/llm_optics.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/mcp_tools.py +0 -0
- {pipulate-2.76 → pipulate-2.77}/tools/system_tools.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# The Pipulate journal, indexed
|
|
2
2
|
|
|
3
|
-
> Auto-generated on 2026-10-
|
|
3
|
+
> Auto-generated on 2026-10-04 by `scripts/articles/generate_ai_context.py` and
|
|
4
4
|
> rewritten from scratch on every release. If this date looks stale, assume
|
|
5
|
-
> the rest of the repo is newer than this map.
|
|
5
|
+
> the rest of the repo is newer than this map. 1515 entries indexed.
|
|
6
6
|
|
|
7
7
|
This repository holds the *machinery*. The *reasoning*, the running journal
|
|
8
8
|
that explains why every piece exists, lives on a separate website and not in
|
|
@@ -27,6 +27,11 @@ The first 20 entries include full `index.md` URLs to establish
|
|
|
27
27
|
the link pattern. All remaining entries are bare slugs. Reconstruct any full
|
|
28
28
|
URL as: `https://mikelev.in/futureproof/{slug}/index.md`
|
|
29
29
|
|
|
30
|
+
- [2026-10-04] [The Terminal House Style: Quieting the Console Without Silencing the Evidence](https://mikelev.in/futureproof/terminal-house-style-quiet-console-evidence/index.md)
|
|
31
|
+
- [2026-10-03] [Mechanical Advantage and the Active Root: Authoritative Prefixes for Replayable AI Pipelines](https://mikelev.in/futureproof/mechanical-advantage-and-the-active-root/index.md)
|
|
32
|
+
- [2026-10-03] [The Single-Owner Rule and the Verifiable Deed: Quieting Pipeline Noise in the Age of AI](https://mikelev.in/futureproof/single-owner-rule-and-verifiable-deeds/index.md)
|
|
33
|
+
- [2026-10-03] [The Render Canary and the Quiet Compiler: Distinguishing Exposure from Corruption in the Age of AI](https://mikelev.in/futureproof/render-canary-and-the-quiet-compiler/index.md)
|
|
34
|
+
- [2026-10-02] [The Enter Fence and the Bell Cue: Building Replayable Browser Walks in the Age of AI](https://mikelev.in/futureproof/the-enter-fence-and-the-bell-cue/index.md)
|
|
30
35
|
- [2026-10-02] [The Clear Cups Protocol: Auditing MCP Calls with Replayable Receipts](https://mikelev.in/futureproof/clear-cups-protocol-auditing-mcp-calls/index.md)
|
|
31
36
|
- [2026-10-02] [The Door Table and the Moving Twig: Replayable Installs and the Stick Bug Secret](https://mikelev.in/futureproof/the-door-table-and-the-moving-twig/index.md)
|
|
32
37
|
- [2026-10-01] [The Scrollback Rule: Engineering Full-Width Terminal Dividers for Replayable Logs](https://mikelev.in/futureproof/scrollback-rule-clear-x-receipts/index.md)
|
|
@@ -42,16 +47,16 @@ URL as: `https://mikelev.in/futureproof/{slug}/index.md`
|
|
|
42
47
|
- [2026-09-28] [Pinball, Pachinko, and the Art of Replayable AI Workflows](https://mikelev.in/futureproof/pinball-pachinko-replayable-ai-workflows/index.md)
|
|
43
48
|
- [2026-09-28] [The Three-Folder Boundary: Ergonomic Workspaces and the Blank Stare in the Age of AI](https://mikelev.in/futureproof/three-folder-boundary-and-the-blank-stare/index.md)
|
|
44
49
|
- [2026-09-26] [Closing the Loop: Fixing Patch Parser Failures and Actuator Surprises](https://mikelev.in/futureproof/fixing-patch-parser-failures-and-actuator-surprises/index.md)
|
|
45
|
-
- [2026-09-26] [The Three Plain Words: Unifying Context and Building Verifiable Receipts](https://mikelev.in/futureproof/plain-words-verifiable-ai-context/index.md)
|
|
46
|
-
- [2026-09-24] [When the Power Returns: Reconciling Diverged Git Branches and Single-Point Failures](https://mikelev.in/futureproof/when-the-power-returns-verifiable-git-resync/index.md)
|
|
47
|
-
- [2026-09-24] [Local Chrome Profile Inventory and Typed Matrix Resolution](https://mikelev.in/futureproof/local-chrome-profile-inventory-and-typed-matrix-resolution/index.md)
|
|
48
|
-
- [2026-09-21] [Local-First Development and Resilience in the Age of AI](https://mikelev.in/futureproof/local-first-resilience-age-of-ai/index.md)
|
|
49
|
-
- [2026-09-20] [The Letting-Go Moment: Building Vim Training Wheels for the Age of AI](https://mikelev.in/futureproof/vim-training-wheels-and-the-letting-go-moment/index.md)
|
|
50
50
|
|
|
51
51
|
## Compact slug index -- pattern: https://mikelev.in/futureproof/{slug}/index.md
|
|
52
52
|
|
|
53
53
|
Format: `[date] [size] slug` -- fetch any entry as `https://mikelev.in/futureproof/{slug}/index.md`
|
|
54
54
|
|
|
55
|
+
- [2026-09-26] [460k] plain-words-verifiable-ai-context
|
|
56
|
+
- [2026-09-24] [88k] when-the-power-returns-verifiable-git-resync
|
|
57
|
+
- [2026-09-24] [159k] local-chrome-profile-inventory-and-typed-matrix-resolution
|
|
58
|
+
- [2026-09-21] [184k] local-first-resilience-age-of-ai
|
|
59
|
+
- [2026-09-20] [219k] vim-training-wheels-and-the-letting-go-moment
|
|
55
60
|
- [2026-09-20] [327k] disciplined-forgetting-and-the-memory-hierarchy
|
|
56
61
|
- [2026-09-19] [175k] four-models-one-matchbook-prompt-fanout
|
|
57
62
|
- [2026-09-19] [48k] dual-model-failover-independent-backoff
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipulate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.77
|
|
4
4
|
Summary: The context compiler that helps you prompt well -- local-first, Nix-reproducible, replayable. The successor to AI SEO software.
|
|
5
5
|
Author-email: Mike Levin <pipulate@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -12,7 +12,7 @@ Usage:
|
|
|
12
12
|
pipulate
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
-
__version__ = "2.
|
|
15
|
+
__version__ = "2.77"
|
|
16
16
|
# APOSTROPHES RESTORED (2026-08-04). They were stripped as a workaround for
|
|
17
17
|
# flake.nix's descMatch regex, whose character class excluded ' from the
|
|
18
18
|
# CAPTURE and truncated the banner to "(So)". That regex was fixed in the same
|
|
@@ -23,7 +23,7 @@ __version__ = "2.76"
|
|
|
23
23
|
# is not a property of the system; the regex is. Blast radius is one banner:
|
|
24
24
|
# nothing but flake.nix reads this name -- version_sync.py syncs __version__
|
|
25
25
|
# and __description__, never this. So'wI' chu' -- "engage the cloaking device."
|
|
26
|
-
__version_description__ = "
|
|
26
|
+
__version_description__ = "Learning to Walk"
|
|
27
27
|
# SPDX expression, single source of truth, synced into pyproject.toml by
|
|
28
28
|
# scripts/release/version_sync.py. "-or-later" (not bare AGPL-3.0, which is
|
|
29
29
|
# deprecated SPDX) because the header below grants "any later version".
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipulate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.77
|
|
4
4
|
Summary: The context compiler that helps you prompt well -- local-first, Nix-reproducible, replayable. The successor to AI SEO software.
|
|
5
5
|
Author-email: Mike Levin <pipulate@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pipulate"
|
|
7
7
|
# VERSION NOTE: Synced from pipulate/__init__.py.__version__ via python version_sync.py
|
|
8
|
-
version = "2.
|
|
8
|
+
version = "2.77"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Mike Levin", email="pipulate@gmail.com" },
|
|
11
11
|
]
|
|
@@ -10,43 +10,70 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
10
10
|
sys.path.insert(0, str(REPO_ROOT))
|
|
11
11
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# prompt_foo is imported inside the two resolver tests (2026-10-04), so the
|
|
14
|
+
# trail tests below load neither the compiler nor the browser stack.
|
|
14
15
|
import walk
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class MotherCatRep2Tests(unittest.TestCase):
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
trail
|
|
19
|
+
def test_public_walk_yaml_is_the_default_trail(self):
|
|
20
|
+
# THREE WALKS (2026-10-04): the PageWorkers trail this test once read
|
|
21
|
+
# was purged with five others; it pinned a trail's shape, never the
|
|
22
|
+
# Rep 2 witness. The bundled trail is public_walk, YAML since that day.
|
|
23
|
+
# Its JSON twin stays out of this test, so removing the twin moves no line.
|
|
24
|
+
self.assertEqual(walk.DEFAULT_TRAIL.name, "public_walk.yaml")
|
|
25
|
+
trail = walk.load_trail(walk.DEFAULT_TRAIL)
|
|
21
26
|
|
|
22
27
|
self.assertEqual(trail["schema_version"], 1)
|
|
23
|
-
self.assertEqual(trail["name"], "
|
|
28
|
+
self.assertEqual(trail["name"], "public_walk")
|
|
24
29
|
self.assertEqual(
|
|
25
|
-
[stop["
|
|
26
|
-
[
|
|
27
|
-
"PIPULATE_TRAIL_BOTIFY_OPTIMIZATION_URL",
|
|
28
|
-
"PIPULATE_TRAIL_BOTIFY_MONITORING_URL",
|
|
29
|
-
"PIPULATE_TRAIL_BOTIFY_REPORTING_URL",
|
|
30
|
-
],
|
|
30
|
+
[stop["name"] for stop in trail["stops"]],
|
|
31
|
+
["the_word", "the_receipt", "the_two_pages"],
|
|
31
32
|
)
|
|
33
|
+
self.assertNotIn("introduction", trail)
|
|
32
34
|
self.assertEqual(
|
|
33
35
|
{
|
|
34
36
|
"headless": trail["defaults"]["headless"],
|
|
35
37
|
"persistent": trail["defaults"]["persistent"],
|
|
36
38
|
"override_cache": trail["defaults"]["override_cache"],
|
|
37
|
-
"is_notebook_context": trail["defaults"]["is_notebook_context"],
|
|
38
39
|
"profile_name": trail["defaults"]["profile_name"],
|
|
39
40
|
},
|
|
40
41
|
{
|
|
41
42
|
"headless": False,
|
|
42
43
|
"persistent": True,
|
|
43
44
|
"override_cache": True,
|
|
44
|
-
"
|
|
45
|
-
"profile_name": "botify",
|
|
45
|
+
"profile_name": "default",
|
|
46
46
|
},
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
+
def test_introduction_is_optional_and_shares_the_walk_namespace(self):
|
|
50
|
+
# THE INTRODUCTION IS SKIPPED BY DEFAULT (2026-10-04): an optional list
|
|
51
|
+
# of stops, validated like the walk's own; a name is unique across both
|
|
52
|
+
# lists, because both land in one capture archive.
|
|
53
|
+
import yaml
|
|
54
|
+
|
|
55
|
+
raw = yaml.safe_load(walk.DEFAULT_TRAIL.read_text(encoding="utf-8"))
|
|
56
|
+
first = raw["stops"][0]
|
|
57
|
+
fresh = dict(first, name="first_look", target_slot="first_slot")
|
|
58
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
59
|
+
path = Path(temp_dir) / "intro.yaml"
|
|
60
|
+
path.write_text(
|
|
61
|
+
yaml.safe_dump(dict(raw, introduction=[fresh])), encoding="utf-8"
|
|
62
|
+
)
|
|
63
|
+
trail = walk.load_trail(path)
|
|
64
|
+
self.assertEqual(
|
|
65
|
+
[stop["name"] for stop in trail["introduction"]], ["first_look"]
|
|
66
|
+
)
|
|
67
|
+
self.assertEqual(len(trail["stops"]), 3)
|
|
68
|
+
path.write_text(
|
|
69
|
+
yaml.safe_dump(dict(raw, introduction=[first])), encoding="utf-8"
|
|
70
|
+
)
|
|
71
|
+
with self.assertRaisesRegex(walk.TrailError, "must be unique"):
|
|
72
|
+
walk.load_trail(path)
|
|
73
|
+
|
|
49
74
|
def test_resolver_matches_requested_and_final_guided_urls(self):
|
|
75
|
+
import prompt_foo
|
|
76
|
+
|
|
50
77
|
requested_url = "https://app.example.com/project/page?analysis=1"
|
|
51
78
|
final_url = requested_url + "&context=24h"
|
|
52
79
|
|
|
@@ -111,6 +138,8 @@ class MotherCatRep2Tests(unittest.TestCase):
|
|
|
111
138
|
)
|
|
112
139
|
|
|
113
140
|
def test_resolver_falls_back_to_legacy_prompt_foo_cache(self):
|
|
141
|
+
import prompt_foo
|
|
142
|
+
|
|
114
143
|
target_url = "https://example.com/a/b?variant=1"
|
|
115
144
|
|
|
116
145
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
@@ -415,6 +415,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
415
415
|
output_dir = base_dir / domain / url_path_slug
|
|
416
416
|
artifacts = {}
|
|
417
417
|
final_url = None
|
|
418
|
+
seconds_to_enter = None
|
|
418
419
|
|
|
419
420
|
def failure(message):
|
|
420
421
|
return {
|
|
@@ -663,6 +664,18 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
663
664
|
driver.execute_cdp_cmd("Network.setCacheDisabled", {"cacheDisabled": True})
|
|
664
665
|
except Exception as exc:
|
|
665
666
|
logger.warning(f"⚠️ Could not turn off the browser cache; this page may come from it: {exc}")
|
|
667
|
+
# A BLANK START (2026-10-02): the window opened on the browser's
|
|
668
|
+
# new-tab page, and its chrome:// and chrome-untrusted:// loads sat at
|
|
669
|
+
# the top of every network log. Open about:blank, then read the
|
|
670
|
+
# performance log once and drop what it holds. The page under test
|
|
671
|
+
# has not been requested yet, so this read can take only the start
|
|
672
|
+
# page's traffic; the one read of the flight is still the drain
|
|
673
|
+
# further down.
|
|
674
|
+
try:
|
|
675
|
+
driver.get("about:blank")
|
|
676
|
+
driver.get_log("performance")
|
|
677
|
+
except Exception as exc:
|
|
678
|
+
logger.warning(f"⚠️ Could not clear the start page from the network log: {exc}")
|
|
666
679
|
logger.info(f"Navigating to: {url}")
|
|
667
680
|
driver.get(url)
|
|
668
681
|
|
|
@@ -686,6 +699,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
686
699
|
# supplied one, play the ready sound exactly once.
|
|
687
700
|
_stop_scrape_music(music_proc, ding=True)
|
|
688
701
|
music_proc = None
|
|
702
|
+
bell_at = time.monotonic()
|
|
689
703
|
|
|
690
704
|
if checkpoint is not None:
|
|
691
705
|
try:
|
|
@@ -702,6 +716,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
702
716
|
checkpoint_result.get("error")
|
|
703
717
|
or "interactive checkpoint was not confirmed"
|
|
704
718
|
)
|
|
719
|
+
seconds_to_enter = round(time.monotonic() - bell_at, 1)
|
|
705
720
|
|
|
706
721
|
try:
|
|
707
722
|
final_url = driver.current_url
|
|
@@ -752,9 +767,12 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
752
767
|
# Silent, plausible, and it inverts the one measurement the triptych
|
|
753
768
|
# exists to take.
|
|
754
769
|
#
|
|
755
|
-
# get_log() DRAINS: this is the one
|
|
756
|
-
# get_log("performance")
|
|
757
|
-
#
|
|
770
|
+
# get_log() DRAINS: this is the one read of the flight. A second
|
|
771
|
+
# get_log("performance") returns only what came after the first, so
|
|
772
|
+
# any debug drain added between driver.get(url) and here steals the
|
|
773
|
+
# flight and leaves the ledger empty. The start-page read just before
|
|
774
|
+
# driver.get(url) is the one exception (2026-10-02): it runs before
|
|
775
|
+
# the page under test is requested.
|
|
758
776
|
if verbose: logger.info("🛜 Draining CDP performance log (network flight recorder)...")
|
|
759
777
|
cdp_events = []
|
|
760
778
|
try:
|
|
@@ -901,6 +919,10 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
901
919
|
# not as bad -- fail-open on purpose, because marking years of
|
|
902
920
|
# legacy captures suspect is noise wearing caution's hat.
|
|
903
921
|
"source_provenance": source_provenance,
|
|
922
|
+
# SECONDS FROM THE BELL TO ENTER (2026-10-02): how long the human
|
|
923
|
+
# spent on the page after it loaded, so a glance can be told from
|
|
924
|
+
# two clicks and a wait. None when no human was asked.
|
|
925
|
+
"seconds_bell_to_enter": seconds_to_enter,
|
|
904
926
|
"headers": actual_headers
|
|
905
927
|
}
|
|
906
928
|
headers_path = output_dir / "headers.json"
|
|
@@ -909,9 +931,99 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
909
931
|
|
|
910
932
|
if take_screenshot:
|
|
911
933
|
screenshot_path = output_dir / "screenshot.png"
|
|
912
|
-
|
|
934
|
+
# THE WHOLE PAGE, NOT THE WINDOW (2026-10-02, walk 3: the operator
|
|
935
|
+
# had scrolled down, and the screenshot held only the lower part).
|
|
936
|
+
# save_screenshot takes the viewport as it stands. CDP takes the
|
|
937
|
+
# page: scroll to the top, read the page's CSS size, and capture
|
|
938
|
+
# beyond the viewport with a clip of that size. Height is capped at
|
|
939
|
+
# 16384 px so a very long page cannot ask for a giant image. Any
|
|
940
|
+
# failure falls back to the window, and the log says so.
|
|
941
|
+
try:
|
|
942
|
+
import base64
|
|
943
|
+
driver.execute_script("window.scrollTo({top: 0, left: 0, behavior: 'instant'});")
|
|
944
|
+
metrics = driver.execute_cdp_cmd("Page.getLayoutMetrics", {})
|
|
945
|
+
size = metrics.get("cssContentSize") or metrics.get("contentSize") or {}
|
|
946
|
+
width = max(1, int(size.get("width") or 1920))
|
|
947
|
+
height = max(1, min(int(size.get("height") or 1080), 16384))
|
|
948
|
+
shot = driver.execute_cdp_cmd("Page.captureScreenshot", {
|
|
949
|
+
"format": "png",
|
|
950
|
+
"captureBeyondViewport": True,
|
|
951
|
+
"clip": {"x": 0, "y": 0, "width": width, "height": height, "scale": 1},
|
|
952
|
+
})
|
|
953
|
+
screenshot_path.write_bytes(base64.b64decode(shot["data"]))
|
|
954
|
+
except Exception as exc:
|
|
955
|
+
logger.warning(f"⚠️ Full-page screenshot failed; saving the window instead: {exc}")
|
|
956
|
+
driver.save_screenshot(str(screenshot_path))
|
|
913
957
|
artifacts['screenshot'] = str(screenshot_path)
|
|
914
958
|
|
|
959
|
+
# --- Response bodies: first-party scripts and data, bounded ---
|
|
960
|
+
# WHERE DID THIS TEXT COME FROM (2026-10-02, the PocketRender ride): a
|
|
961
|
+
# single-page app sends a small shell and builds every view from
|
|
962
|
+
# scripts and fetches, so the answer is in those bodies, not in
|
|
963
|
+
# source.html, and the browser forgets them when it closes. Kept:
|
|
964
|
+
# Script, XHR and Fetch responses whose host ends in the final URL's
|
|
965
|
+
# last two labels (app.botify.com and irf.production.botify.com both
|
|
966
|
+
# end in botify.com; a co.uk-style host takes in more, never less),
|
|
967
|
+
# read off the drained ledger with Network.getResponseBody, so nothing
|
|
968
|
+
# is requested twice. Bounds: 60 files, 2 MB each (cut, and marked),
|
|
969
|
+
# 20 MB in all, 120 tries. Only bodies/index.jsonl (address without
|
|
970
|
+
# its query, status, type, bytes) is an artifact; the bodies stay
|
|
971
|
+
# beside the capture, under the same gitignored browser_cache/ as
|
|
972
|
+
# everything else the page held.
|
|
973
|
+
try:
|
|
974
|
+
import base64
|
|
975
|
+
final_host = (urlparse(final_url).hostname or "").lower()
|
|
976
|
+
site = ".".join(final_host.split(".")[-2:])
|
|
977
|
+
bodies_dir = output_dir / "bodies"
|
|
978
|
+
rows, seen, kept, total = [], set(), 0, 0
|
|
979
|
+
for ev in cdp_events:
|
|
980
|
+
params_ev = ev.get("params", {})
|
|
981
|
+
response = params_ev.get("response", {})
|
|
982
|
+
body_url = response.get("url", "")
|
|
983
|
+
host = (urlparse(body_url).hostname or "").lower()
|
|
984
|
+
request_id = params_ev.get("requestId")
|
|
985
|
+
kind = params_ev.get("type")
|
|
986
|
+
if (ev.get("method") != "Network.responseReceived"
|
|
987
|
+
or kind not in ("Script", "XHR", "Fetch")
|
|
988
|
+
or not request_id or request_id in seen
|
|
989
|
+
or not site or not (host == site or host.endswith("." + site))):
|
|
990
|
+
continue
|
|
991
|
+
if kept >= 60 or total >= 20_000_000 or len(rows) >= 120:
|
|
992
|
+
break
|
|
993
|
+
seen.add(request_id)
|
|
994
|
+
row = {"url": body_url.split("#", 1)[0].split("?", 1)[0],
|
|
995
|
+
"status": response.get("status"), "type": kind,
|
|
996
|
+
"mime": response.get("mimeType", "")}
|
|
997
|
+
try:
|
|
998
|
+
got = driver.execute_cdp_cmd("Network.getResponseBody", {"requestId": request_id})
|
|
999
|
+
except Exception as exc:
|
|
1000
|
+
row["error"] = (str(exc).strip().splitlines() or [type(exc).__name__])[0][:200]
|
|
1001
|
+
rows.append(row)
|
|
1002
|
+
continue
|
|
1003
|
+
if got.get("base64Encoded"):
|
|
1004
|
+
data, ext = base64.b64decode(got.get("body", "")), ".bin"
|
|
1005
|
+
else:
|
|
1006
|
+
data = got.get("body", "").encode("utf-8")
|
|
1007
|
+
ext = ".js" if kind == "Script" else ".json" if "json" in row["mime"] else ".txt"
|
|
1008
|
+
cut = len(data) > 2_000_000
|
|
1009
|
+
data = data[:2_000_000]
|
|
1010
|
+
name = f"{kept + 1:03d}-{kind.lower()}-{hashlib.sha256(body_url.encode('utf-8')).hexdigest()[:10]}{ext}"
|
|
1011
|
+
bodies_dir.mkdir(exist_ok=True)
|
|
1012
|
+
(bodies_dir / name).write_bytes(data)
|
|
1013
|
+
kept += 1
|
|
1014
|
+
total += len(data)
|
|
1015
|
+
row.update({"file": name, "bytes": len(data), "cut": cut})
|
|
1016
|
+
rows.append(row)
|
|
1017
|
+
if rows:
|
|
1018
|
+
bodies_dir.mkdir(exist_ok=True)
|
|
1019
|
+
index_path = bodies_dir / "index.jsonl"
|
|
1020
|
+
index_path.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
|
|
1021
|
+
artifacts['bodies_index'] = str(index_path)
|
|
1022
|
+
if verbose:
|
|
1023
|
+
logger.info(f"📦 Kept {kept} first-party response bodies ({total} bytes) in {bodies_dir.name}/")
|
|
1024
|
+
except Exception as exc:
|
|
1025
|
+
logger.warning(f"⚠️ Could not keep response bodies: {exc}")
|
|
1026
|
+
|
|
915
1027
|
|
|
916
1028
|
|
|
917
1029
|
# 2. Create LLM-Optimized Simplified DOMs (The Symmetrical Lens)
|
|
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
|
|
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
|
|
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
|
|
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
|