playforge 1.0.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.
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026, ytcalifax <hello@98w.eu>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19
+ OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: playforge
3
+ Version: 1.0.0
4
+ Summary: Interactive Playwright recorder that turns browser actions into code
5
+ Author-email: "ytcalifax (98w)" <hello@98w.eu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ytcalifax/playforge
8
+ Project-URL: Repository, https://github.com/ytcalifax/playforge.git
9
+ Project-URL: Bug Tracker, https://github.com/ytcalifax/playforge/issues
10
+ Project-URL: Changelog, https://github.com/ytcalifax/playforge/blob/main/CHANGELOG.md
11
+ Keywords: playwright,page-object,recorder,testing,automation
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Natural Language :: English
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE.md
27
+ Requires-Dist: playwright>=1.62.0.0
28
+ Requires-Dist: ruff>=0.16.4.0
29
+ Requires-Dist: structlog>=26.1.0.0
30
+ Dynamic: license-file
31
+
32
+ # ๐ŸŽญ PlayForge
33
+
34
+ > **A Playwright recorder that turns browser actions into reusable Page Object code.**
35
+
36
+ PlayForge records clicks, fills, selects, and reads from a browser session, then writes the result out as Python code you can keep using. It is meant for the boring part of browser automation: set up a page, click through the flow once, and get a generated page object you can reuse instead of hand-writing locators over and over.
37
+
38
+ The project is designed to run as a normal CLI after installation. Install it globally, point it at a URL, interact with the page, and let it capture the flow. When you quit, it writes the generated code to disk.
39
+
40
+ ## โœจ Features
41
+
42
+ - **๐ŸŽฌ Browser Recording**: Capture real user actions from a live page, including clicks, fills, selects, and text reads.
43
+ - **๐Ÿงฉ Page Object Generation**: Turn recorded workflows into Python page objects with reusable methods.
44
+ - **๐Ÿงน Workflow Splitting**: Split one recording session into multiple generated methods when a flow gets too long.
45
+ - **๐Ÿชต Structured Logging**: Uses `structlog` for simple, consistent CLI and runtime logs.
46
+ - **โšก CLI Ready**: Install it globally and run `playforge --help` or `playforge <url>`.
47
+
48
+ ## ๐Ÿ“ฅ Installation
49
+
50
+ ```bash
51
+ pip install playforge
52
+ playwright install
53
+ ```
54
+
55
+ Or, to install directly from source:
56
+
57
+ ```bash
58
+ pip install .
59
+ playwright install
60
+ ```
61
+
62
+ If you want to use it from a local checkout while developing, install it in editable mode:
63
+
64
+ ```bash
65
+ pip install -e .
66
+ playwright install
67
+ ```
68
+
69
+ You need `playwright install` so Playwright downloads an actual browser. Without that, the CLI can install fine but has nothing to launch.
70
+
71
+ ## ๐Ÿš€ Quick Start
72
+
73
+ ```bash
74
+ playforge https://example.com
75
+ ```
76
+
77
+ The recorder opens a browser window, waits for you to interact with the page, and collects actions until you quit. Use `split` in the terminal if you want to break the recording into another generated method.
78
+
79
+ ### Help
80
+
81
+ ```bash
82
+ playforge --help
83
+ ```
84
+
85
+ ### Output file
86
+
87
+ ```bash
88
+ playforge https://example.com -o generated_page.py
89
+ ```
90
+
91
+ ### What gets recorded
92
+
93
+ PlayForge watches for:
94
+
95
+ - clicks on buttons, links, and other interactive elements
96
+ - fills on input fields and text areas
97
+ - select changes
98
+ - double-click reads on readable text elements
99
+
100
+ Each captured workflow becomes a method in the generated page object. Repeated actions are collapsed where possible so the output stays usable instead of turning into a wall of duplicate steps.
101
+
102
+ ## ๐Ÿ Requirements
103
+
104
+ - Python **3.10+**
105
+ - `playwright`
106
+ - `structlog`
107
+ - `ruff`
108
+
109
+ ## ๐Ÿค Contributing
110
+
111
+ Issues and pull requests are welcome. If you hit a weird recorder edge case, open an issue with the page flow and the generated output.
112
+
113
+ ---
114
+ *Built for browser automation and code generation. MIT Licensed.*
@@ -0,0 +1,83 @@
1
+ # ๐ŸŽญ PlayForge
2
+
3
+ > **A Playwright recorder that turns browser actions into reusable Page Object code.**
4
+
5
+ PlayForge records clicks, fills, selects, and reads from a browser session, then writes the result out as Python code you can keep using. It is meant for the boring part of browser automation: set up a page, click through the flow once, and get a generated page object you can reuse instead of hand-writing locators over and over.
6
+
7
+ The project is designed to run as a normal CLI after installation. Install it globally, point it at a URL, interact with the page, and let it capture the flow. When you quit, it writes the generated code to disk.
8
+
9
+ ## โœจ Features
10
+
11
+ - **๐ŸŽฌ Browser Recording**: Capture real user actions from a live page, including clicks, fills, selects, and text reads.
12
+ - **๐Ÿงฉ Page Object Generation**: Turn recorded workflows into Python page objects with reusable methods.
13
+ - **๐Ÿงน Workflow Splitting**: Split one recording session into multiple generated methods when a flow gets too long.
14
+ - **๐Ÿชต Structured Logging**: Uses `structlog` for simple, consistent CLI and runtime logs.
15
+ - **โšก CLI Ready**: Install it globally and run `playforge --help` or `playforge <url>`.
16
+
17
+ ## ๐Ÿ“ฅ Installation
18
+
19
+ ```bash
20
+ pip install playforge
21
+ playwright install
22
+ ```
23
+
24
+ Or, to install directly from source:
25
+
26
+ ```bash
27
+ pip install .
28
+ playwright install
29
+ ```
30
+
31
+ If you want to use it from a local checkout while developing, install it in editable mode:
32
+
33
+ ```bash
34
+ pip install -e .
35
+ playwright install
36
+ ```
37
+
38
+ You need `playwright install` so Playwright downloads an actual browser. Without that, the CLI can install fine but has nothing to launch.
39
+
40
+ ## ๐Ÿš€ Quick Start
41
+
42
+ ```bash
43
+ playforge https://example.com
44
+ ```
45
+
46
+ The recorder opens a browser window, waits for you to interact with the page, and collects actions until you quit. Use `split` in the terminal if you want to break the recording into another generated method.
47
+
48
+ ### Help
49
+
50
+ ```bash
51
+ playforge --help
52
+ ```
53
+
54
+ ### Output file
55
+
56
+ ```bash
57
+ playforge https://example.com -o generated_page.py
58
+ ```
59
+
60
+ ### What gets recorded
61
+
62
+ PlayForge watches for:
63
+
64
+ - clicks on buttons, links, and other interactive elements
65
+ - fills on input fields and text areas
66
+ - select changes
67
+ - double-click reads on readable text elements
68
+
69
+ Each captured workflow becomes a method in the generated page object. Repeated actions are collapsed where possible so the output stays usable instead of turning into a wall of duplicate steps.
70
+
71
+ ## ๐Ÿ Requirements
72
+
73
+ - Python **3.10+**
74
+ - `playwright`
75
+ - `structlog`
76
+ - `ruff`
77
+
78
+ ## ๐Ÿค Contributing
79
+
80
+ Issues and pull requests are welcome. If you hit a weird recorder edge case, open an issue with the page flow and the generated output.
81
+
82
+ ---
83
+ *Built for browser automation and code generation. MIT Licensed.*
@@ -0,0 +1,13 @@
1
+ from playforge.logger.logger import configure_logging, get_logger
2
+ from playforge.workflow.manager import WorkflowManager
3
+ from playforge.workflow.models import Action, Workflow
4
+ from playforge.workflow.sanitizer import LocatorSanitizer
5
+
6
+ __all__ = [
7
+ "Action",
8
+ "LocatorSanitizer",
9
+ "Workflow",
10
+ "WorkflowManager",
11
+ "configure_logging",
12
+ "get_logger",
13
+ ]
@@ -0,0 +1,3 @@
1
+ from playforge.cli.main import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from playforge.logger.logger import configure_logging, get_logger
6
+ from playforge.workflow.manager import WorkflowManager
7
+ from playforge.generation.render.generator import CodeGenerator
8
+ from playforge.recording.capture.recorder import InteractiveRecorder
9
+
10
+
11
+ logger = get_logger(component="cli")
12
+
13
+
14
+ def main() -> None:
15
+ """Run the PlayForge CLI."""
16
+ configure_logging()
17
+ parser = argparse.ArgumentParser(
18
+ description="Interactive Playwright Page Object Recorder"
19
+ )
20
+ parser.add_argument("url", nargs="?", help="Target URL to record against")
21
+ parser.add_argument(
22
+ "-o", "--output", default="generated_page.py", help="Output python file path"
23
+ )
24
+ parser.add_argument(
25
+ "--headless", action="store_true", help="Run browser in headless mode"
26
+ )
27
+ args = parser.parse_args()
28
+
29
+ if not args.url:
30
+ parser.print_help()
31
+ return
32
+
33
+ logger.info("cli_started", url=args.url, output=args.output, headless=args.headless)
34
+ workflow_manager = WorkflowManager()
35
+ recorder = InteractiveRecorder(args.url, workflow_manager, headless=args.headless)
36
+ try:
37
+ completed = recorder.run()
38
+ except KeyboardInterrupt:
39
+ logger.info("cli_interrupted")
40
+ return
41
+ if not completed:
42
+ logger.info("cli_interrupted")
43
+ return
44
+ CodeGenerator(workflow_manager).generate(args.output)
45
+ logger.info("cli_finished", output=args.output)
@@ -0,0 +1,3 @@
1
+ from playforge.generation.render.generator import CodeGenerator
2
+
3
+ __all__ = ["CodeGenerator"]
@@ -0,0 +1,3 @@
1
+ from playforge.generation.render.generator import CodeGenerator
2
+
3
+ __all__ = ["CodeGenerator"]
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from playforge.logger.logger import get_logger
6
+ from playforge.workflow.manager import WorkflowManager
7
+ from playforge.workflow.models import Action
8
+ from playforge.workflow.sanitizer import LocatorSanitizer
9
+
10
+
11
+ class CodeGenerator:
12
+ """Render workflows into a generated Playwright page object."""
13
+
14
+ def __init__(self, workflow_manager: WorkflowManager):
15
+ self.workflow_manager = workflow_manager
16
+ self.logger = get_logger(component="generator")
17
+
18
+ @staticmethod
19
+ def _locator_expr_for_action(act: Action) -> str:
20
+ if act.id:
21
+ if act.class_name:
22
+ classes = "." + ".".join([c for c in act.class_name.split() if c])
23
+ return f"page.locator(\"{classes} [id='{LocatorSanitizer.escape_selector_value(act.id)}']\")"
24
+ return f"page.locator(\"[id='{LocatorSanitizer.escape_selector_value(act.id)}']\")"
25
+ if act.text:
26
+ safe_text = LocatorSanitizer.escape_selector_value(
27
+ LocatorSanitizer.normalize_text(act.text)
28
+ )
29
+ return f'page.locator("{LocatorSanitizer.quote_text_selector(safe_text)}")'
30
+ return f'page.locator("{act.tag_name}")'
31
+
32
+ def generate(self, output_path: str) -> None:
33
+ global_locators = {}
34
+ functions_output = []
35
+ workflows = self.workflow_manager.get_workflows()
36
+ used_names: set[str] = set()
37
+
38
+ for idx, workflow in enumerate(workflows):
39
+ func_name = WorkflowManager.deduce_workflow_name(workflow.actions, idx + 1)
40
+ method_steps = []
41
+ param_list = []
42
+ param_counts = {}
43
+
44
+ for act_idx, act in enumerate(workflow.actions):
45
+ is_lambda = False
46
+ if act.is_lambda_role:
47
+ is_lambda = True
48
+ base_var_name = "ACTION_TYPE"
49
+ loc_expr = "lambda item_text: page.locator(f\"//li[@role]/a[text()='{item_text}']\")"
50
+ param_name = "job_type"
51
+ elif act.id:
52
+ base_var_name = LocatorSanitizer.sanitize_var_name(act.id)
53
+ loc_expr = self._locator_expr_for_action(act)
54
+ param_name = LocatorSanitizer.sanitize_param_name(act.id)
55
+ elif act.text:
56
+ base_var_name = LocatorSanitizer.sanitize_var_name(
57
+ f"{act.tag_name}_{act.text[:25]}"
58
+ )
59
+ loc_expr = self._locator_expr_for_action(act)
60
+ param_name = f"text_{act_idx}"
61
+ else:
62
+ base_var_name = f"ELEMENT_{act_idx}"
63
+ loc_expr = self._locator_expr_for_action(act)
64
+ param_name = f"val_{act_idx}"
65
+
66
+ var_name = LocatorSanitizer.unique_name(base_var_name, used_names)
67
+ global_locators[var_name] = loc_expr
68
+
69
+ if act.type == "click":
70
+ if is_lambda:
71
+ if param_name not in [p.split(":")[0] for p in param_list]:
72
+ param_list.append(f"{param_name}: str")
73
+ method_steps.append(
74
+ f'self.{var_name}({param_name}).wait_for(state="visible", timeout=30000)'
75
+ )
76
+ method_steps.append(f"self.{var_name}({param_name}).click()")
77
+ else:
78
+ method_steps.append(f"self.{var_name}.click()")
79
+ elif act.type in {"fill", "select"}:
80
+ if param_name not in [p.split(":")[0] for p in param_list]:
81
+ param_counts[param_name] = 1
82
+ curr_param = param_name
83
+ else:
84
+ param_counts[param_name] = param_counts.get(param_name, 1) + 1
85
+ curr_param = f"{param_name}_{param_counts[param_name]}"
86
+ param_list.append(f"{curr_param}: str")
87
+ if act.type == "fill":
88
+ method_steps.append(
89
+ f'self.{var_name}.wait_for(state="visible", timeout=30000)'
90
+ )
91
+ method_steps.append(f"self.{var_name}.fill({curr_param})")
92
+ else:
93
+ method_steps.append(
94
+ f"self.{var_name}.select_option({curr_param})"
95
+ )
96
+ elif act.type == "get":
97
+ method_steps.append(
98
+ f'self.{var_name}.wait_for(state="visible", timeout=30000)'
99
+ )
100
+ method_steps.append(
101
+ f"return self.{var_name}.inner_text().strip() or self.{var_name}.text_content() or ''"
102
+ )
103
+
104
+ unique_params = []
105
+ seen = set()
106
+ for p in param_list:
107
+ p_name = p.split(":")[0]
108
+ if p_name not in seen:
109
+ seen.add(p_name)
110
+ unique_params.append(p)
111
+
112
+ params_str = ", ".join(["self"] + unique_params)
113
+ func_def = [f" def {func_name}({params_str}):"]
114
+ for step in method_steps:
115
+ func_def.append(f" {step}")
116
+ functions_output.append("\n".join(func_def))
117
+
118
+ code_lines = [
119
+ "from playwright.sync_api import Page",
120
+ "",
121
+ "class GeneratedPage:",
122
+ " def __init__(self, page: Page):",
123
+ " self.page = page",
124
+ ]
125
+ for var, expr in global_locators.items():
126
+ code_lines.append(f" self.{var} = {expr}")
127
+ code_lines.append("")
128
+ code_lines.extend(functions_output)
129
+ Path(output_path).write_text("\n".join(code_lines), encoding="utf-8")
130
+ self.logger.info("generated_page_written", output_path=output_path)
@@ -0,0 +1,3 @@
1
+ from playforge.logger.logger import configure_logging, get_logger
2
+
3
+ __all__ = ["configure_logging", "get_logger"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ import structlog
7
+
8
+
9
+ _logger_configured = False
10
+
11
+
12
+ def configure_logging() -> None:
13
+ global _logger_configured
14
+ if _logger_configured:
15
+ return
16
+
17
+ structlog.configure(
18
+ processors=[
19
+ structlog.processors.add_log_level,
20
+ structlog.processors.TimeStamper(fmt="iso"),
21
+ structlog.processors.StackInfoRenderer(),
22
+ structlog.processors.format_exc_info,
23
+ structlog.processors.KeyValueRenderer(key_order=["event"]),
24
+ ],
25
+ wrapper_class=structlog.make_filtering_bound_logger(20),
26
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
27
+ cache_logger_on_first_use=True,
28
+ )
29
+ _logger_configured = True
30
+
31
+
32
+ def get_logger(**context: Any):
33
+ configure_logging()
34
+ return structlog.get_logger().bind(**context)
@@ -0,0 +1,3 @@
1
+ from playforge.cli.main import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,3 @@
1
+ from playforge.recording.capture.recorder import InteractiveRecorder
2
+
3
+ __all__ = ["InteractiveRecorder"]
@@ -0,0 +1,3 @@
1
+ from playforge.recording.capture.recorder import InteractiveRecorder
2
+
3
+ __all__ = ["InteractiveRecorder"]
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from typing import Any
6
+
7
+ from playwright.sync_api import Error, Page, sync_playwright
8
+
9
+ from playforge.logger.logger import get_logger
10
+ from playforge.workflow.manager import WorkflowManager
11
+
12
+
13
+ class InteractiveRecorder:
14
+ """Capture browser interactions and append them to workflows."""
15
+
16
+ def __init__(
17
+ self, url: str, workflow_manager: WorkflowManager, headless: bool = False
18
+ ):
19
+ self.url = url
20
+ self.workflow_manager = workflow_manager
21
+ self.headless = headless
22
+ self.stop_recording = False
23
+ self.logger = get_logger(component="recorder")
24
+
25
+ def _on_console(self, msg: Any) -> None:
26
+ if not msg.text.startswith("RECORD_ACTION:"):
27
+ return
28
+ try:
29
+ action = json.loads(msg.text.split("RECORD_ACTION:", 1)[1])
30
+ self.workflow_manager.add_action(action)
31
+ self.logger.info(
32
+ "recorded_action",
33
+ action_type=action.get("type"),
34
+ tag_name=action.get("tagName"),
35
+ element_id=action.get("id"),
36
+ )
37
+ except (ValueError, TypeError, KeyError):
38
+ self.logger.warning("record_action_parse_failed")
39
+
40
+ def _attach_recorder(self, page: Page) -> None:
41
+ page.on("console", self._on_console)
42
+ page.add_init_script(r"""
43
+ (() => {
44
+ const sendAction = (data) => console.log("RECORD_ACTION:" + JSON.stringify(data));
45
+ const getText = (el) => {
46
+ if (!el) return '';
47
+ return (el.textContent || '').replace(/\s+/g, ' ').trim();
48
+ };
49
+ const getElementId = (el) => {
50
+ if (!el) return '';
51
+ if (el.id) return el.id;
52
+ const labeledBy = el.getAttribute && el.getAttribute('aria-label');
53
+ if (labeledBy) return labeledBy.trim();
54
+ if ('name' in el && typeof el.name === 'string' && el.name.trim()) return el.name.trim();
55
+ if ('placeholder' in el && typeof el.placeholder === 'string' && el.placeholder.trim()) return el.placeholder.trim();
56
+ if (el.getAttribute) {
57
+ const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || el.getAttribute('data-qa');
58
+ if (testId) return testId.trim();
59
+ }
60
+ const text = getText(el);
61
+ if (text) return text;
62
+ return el.tagName ? el.tagName.toLowerCase() : '';
63
+ };
64
+ const isInteractive = (el) => {
65
+ if (!el || !el.tagName) return false;
66
+ const tag = el.tagName.toLowerCase();
67
+ return ['input', 'textarea', 'select', 'button', 'a'].includes(tag) || tag === 'label';
68
+ };
69
+ window.addEventListener('DOMContentLoaded', () => {
70
+ document.addEventListener('click', (e) => {
71
+ const el = e.target;
72
+ if (!el || !isInteractive(el)) return;
73
+ sendAction({
74
+ type: 'click',
75
+ tagName: el.tagName ? el.tagName.toLowerCase() : '',
76
+ id: getElementId(el),
77
+ className: typeof el.className === 'string' ? el.className.trim() : '',
78
+ text: getText(el),
79
+ isLambdaRole: Boolean(el.tagName && el.tagName.toLowerCase() === 'a' && el.parentElement && el.parentElement.getAttribute('role')),
80
+ value: el.value || ''
81
+ });
82
+ }, true);
83
+ document.addEventListener('dblclick', (e) => {
84
+ const el = e.target;
85
+ if (!el) return;
86
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
87
+ if (!['label', 'p', 'span', 'div', 'li', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'small'].includes(tag)) return;
88
+ const textVal = getText(el);
89
+ if (!textVal) return;
90
+ sendAction({ type: 'get', tagName: tag, id: getElementId(el), className: typeof el.className === 'string' ? el.className.trim() : '', text: textVal, isLambdaRole: false, value: '' });
91
+ }, true);
92
+ document.addEventListener('change', (e) => {
93
+ const el = e.target;
94
+ if (!el) return;
95
+ const isSelect = el.tagName && el.tagName.toLowerCase() === 'select';
96
+ sendAction({ type: isSelect ? 'select' : 'fill', tagName: el.tagName ? el.tagName.toLowerCase() : '', id: getElementId(el), className: typeof el.className === 'string' ? el.className.trim() : '', text: getText(el), isLambdaRole: false, value: el.value || '' });
97
+ }, true);
98
+ });
99
+ })();
100
+ """)
101
+
102
+ def run(self) -> bool:
103
+ self.logger.info("recorder_started", url=self.url, headless=self.headless)
104
+ interrupted = False
105
+ try:
106
+ with sync_playwright() as p:
107
+ browser = p.chromium.launch(headless=self.headless)
108
+ context = browser.new_context(ignore_https_errors=True)
109
+ page = context.new_page()
110
+ self._attach_recorder(page)
111
+ context.on("page", self._attach_recorder)
112
+ try:
113
+ page.goto(self.url, wait_until="domcontentloaded")
114
+ except Error:
115
+ self.logger.warning("initial_navigation_failed", url=self.url)
116
+
117
+ self.logger.info(
118
+ "recorder_ready",
119
+ commands="split: new function block, clear: remove last action, quit: stop and generate",
120
+ )
121
+
122
+ def terminal_listener() -> None:
123
+ while not self.stop_recording:
124
+ try:
125
+ cmd = input().strip().lower()
126
+ if cmd in {"quit", ""}:
127
+ self.stop_recording = True
128
+ break
129
+ if cmd == "split":
130
+ self.workflow_manager.split_workflow()
131
+ self.logger.info("workflow_split")
132
+ elif cmd == "clear":
133
+ self.workflow_manager.clear_last_action()
134
+ self.logger.info("workflow_action_cleared")
135
+ else:
136
+ self.logger.warning("unknown_command", command=cmd)
137
+ except (KeyboardInterrupt, EOFError):
138
+ self.stop_recording = True
139
+ break
140
+
141
+ threading.Thread(target=terminal_listener, daemon=True).start()
142
+ while not self.stop_recording:
143
+ try:
144
+ page.wait_for_timeout(100)
145
+ except Error:
146
+ break
147
+ try:
148
+ context.close()
149
+ browser.close()
150
+ except Error:
151
+ self.logger.warning("browser_close_failed")
152
+ except KeyboardInterrupt:
153
+ interrupted = True
154
+ self.stop_recording = True
155
+ self.logger.info("recorder_interrupted", url=self.url)
156
+
157
+ self.logger.info("recorder_stopped", url=self.url, interrupted=interrupted)
158
+ return not interrupted
@@ -0,0 +1,5 @@
1
+ from playforge.workflow.manager import WorkflowManager
2
+ from playforge.workflow.models import Action, Workflow
3
+ from playforge.workflow.sanitizer import LocatorSanitizer
4
+
5
+ __all__ = ["Action", "LocatorSanitizer", "Workflow", "WorkflowManager"]
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from .models import Action, Workflow
4
+
5
+
6
+ class WorkflowManager:
7
+ """Collect and shape recorded actions into workflows."""
8
+
9
+ def __init__(self) -> None:
10
+ self._workflows: list[Workflow] = [Workflow()]
11
+
12
+ def add_action(self, action: dict) -> None:
13
+ normalized = Action.from_mapping(action)
14
+ if not normalized.type:
15
+ return
16
+ if normalized.type not in {"click", "fill", "select", "get"}:
17
+ return
18
+ if (
19
+ normalized.type == "click"
20
+ and not normalized.id
21
+ and not normalized.text
22
+ and not normalized.class_name
23
+ and normalized.tag_name in {"span", "div", "i", "b", "p"}
24
+ ):
25
+ return
26
+
27
+ current = self._workflows[-1].actions
28
+ if current:
29
+ last = current[-1]
30
+ if (
31
+ last.type == normalized.type
32
+ and last.id == normalized.id
33
+ and last.tag_name == normalized.tag_name
34
+ and last.text == normalized.text
35
+ ):
36
+ return
37
+ if normalized.type in {"fill", "select"} and last.type == "click":
38
+ if (
39
+ last.id == normalized.id and last.tag_name == normalized.tag_name
40
+ ) or (not normalized.id and last.tag_name == normalized.tag_name):
41
+ current.pop()
42
+
43
+ current.append(normalized)
44
+
45
+ def split_workflow(self) -> None:
46
+ self._workflows.append(Workflow())
47
+
48
+ def clear_last_action(self) -> None:
49
+ current = self._workflows[-1].actions
50
+ if current:
51
+ current.pop()
52
+
53
+ def get_workflows(self) -> list[Workflow]:
54
+ return [workflow for workflow in self._workflows if workflow.actions]
55
+
56
+ @staticmethod
57
+ def deduce_workflow_name(actions: list[Action], index: int) -> str:
58
+ if not actions:
59
+ return f"sequence_{index}"
60
+ block_text = " ".join(f"{act.text} {act.id}" for act in actions).lower()
61
+ if "log" in block_text or "signin" in block_text:
62
+ return "log_in"
63
+ if "add" in block_text or "create" in block_text:
64
+ if (
65
+ "job" in block_text
66
+ or "backup" in block_text
67
+ or "retention" in block_text
68
+ ):
69
+ return "create_backup_job"
70
+ return "create_item"
71
+ for act in reversed(actions):
72
+ if act.type == "click":
73
+ raw_name = act.text.strip() or act.id.strip()
74
+ if raw_name:
75
+ cleaned = "".join(
76
+ c if c.isalnum() else "_" for c in raw_name
77
+ ).lower()
78
+ return "_".join(filter(None, cleaned.split("_")))[:30]
79
+ return f"sequence_{index}"
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class Action:
9
+ """Normalized action captured from the browser."""
10
+
11
+ type: str
12
+ tag_name: str = ""
13
+ id: str = ""
14
+ class_name: str = ""
15
+ text: str = ""
16
+ is_lambda_role: bool = False
17
+ value: str = ""
18
+
19
+ @classmethod
20
+ def from_mapping(cls, action: dict[str, Any]) -> "Action":
21
+ """Build an action from raw recorder payload data."""
22
+
23
+ return cls(
24
+ type=str(action.get("type", "")).strip().lower(),
25
+ tag_name=str(action.get("tagName", "") or "").strip().lower(),
26
+ id=str(action.get("id", "") or "").strip(),
27
+ class_name=str(action.get("className", "") or "").strip(),
28
+ text=str(action.get("text", "") or "").replace("\xa0", " ").strip(),
29
+ is_lambda_role=bool(action.get("isLambdaRole", False)),
30
+ value=str(action.get("value", "") or ""),
31
+ )
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class Workflow:
36
+ """Ordered actions that belong to one generated workflow."""
37
+
38
+ actions: list[Action] = field(default_factory=list)
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import re
5
+
6
+
7
+ class LocatorSanitizer:
8
+ """Normalize text and build safe Python identifiers."""
9
+
10
+ READABLE_TAGS = {
11
+ "label",
12
+ "p",
13
+ "span",
14
+ "div",
15
+ "li",
16
+ "td",
17
+ "th",
18
+ "h1",
19
+ "h2",
20
+ "h3",
21
+ "h4",
22
+ "h5",
23
+ "h6",
24
+ "strong",
25
+ "small",
26
+ }
27
+
28
+ @staticmethod
29
+ def normalize_text(value: Any) -> str:
30
+ if value is None:
31
+ return ""
32
+ text = str(value).replace("\xa0", " ").strip()
33
+ return " ".join(text.split())
34
+
35
+ @staticmethod
36
+ def escape_selector_value(value: str) -> str:
37
+ return value.replace("\\", "\\\\").replace("'", "\\'")
38
+
39
+ @staticmethod
40
+ def quote_text_selector(value: str) -> str:
41
+ return f"text='{LocatorSanitizer.escape_selector_value(value)}'"
42
+
43
+ @staticmethod
44
+ def sanitize_var_name(text: str) -> str:
45
+ cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", str(text).strip())
46
+ cleaned = "_".join(filter(None, cleaned.split("_"))).upper()
47
+ if not cleaned:
48
+ cleaned = "ELEMENT"
49
+ if cleaned[0].isdigit():
50
+ cleaned = f"EL_{cleaned}"
51
+ return cleaned
52
+
53
+ @staticmethod
54
+ def sanitize_param_name(id_str: str) -> str:
55
+ if "." in id_str:
56
+ id_str = id_str.split(".")[-1]
57
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", id_str)
58
+ cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
59
+ cleaned = "".join([c if c.isalnum() else "_" for c in cleaned])
60
+ return "_".join(filter(None, cleaned.split("_"))) or "val"
61
+
62
+ @staticmethod
63
+ def unique_name(base_name: str, used_names: set[str]) -> str:
64
+ candidate = base_name
65
+ suffix = 2
66
+ while candidate in used_names:
67
+ candidate = f"{base_name}_{suffix}"
68
+ suffix += 1
69
+ used_names.add(candidate)
70
+ return candidate
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: playforge
3
+ Version: 1.0.0
4
+ Summary: Interactive Playwright recorder that turns browser actions into code
5
+ Author-email: "ytcalifax (98w)" <hello@98w.eu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ytcalifax/playforge
8
+ Project-URL: Repository, https://github.com/ytcalifax/playforge.git
9
+ Project-URL: Bug Tracker, https://github.com/ytcalifax/playforge/issues
10
+ Project-URL: Changelog, https://github.com/ytcalifax/playforge/blob/main/CHANGELOG.md
11
+ Keywords: playwright,page-object,recorder,testing,automation
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Natural Language :: English
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE.md
27
+ Requires-Dist: playwright>=1.62.0.0
28
+ Requires-Dist: ruff>=0.16.4.0
29
+ Requires-Dist: structlog>=26.1.0.0
30
+ Dynamic: license-file
31
+
32
+ # ๐ŸŽญ PlayForge
33
+
34
+ > **A Playwright recorder that turns browser actions into reusable Page Object code.**
35
+
36
+ PlayForge records clicks, fills, selects, and reads from a browser session, then writes the result out as Python code you can keep using. It is meant for the boring part of browser automation: set up a page, click through the flow once, and get a generated page object you can reuse instead of hand-writing locators over and over.
37
+
38
+ The project is designed to run as a normal CLI after installation. Install it globally, point it at a URL, interact with the page, and let it capture the flow. When you quit, it writes the generated code to disk.
39
+
40
+ ## โœจ Features
41
+
42
+ - **๐ŸŽฌ Browser Recording**: Capture real user actions from a live page, including clicks, fills, selects, and text reads.
43
+ - **๐Ÿงฉ Page Object Generation**: Turn recorded workflows into Python page objects with reusable methods.
44
+ - **๐Ÿงน Workflow Splitting**: Split one recording session into multiple generated methods when a flow gets too long.
45
+ - **๐Ÿชต Structured Logging**: Uses `structlog` for simple, consistent CLI and runtime logs.
46
+ - **โšก CLI Ready**: Install it globally and run `playforge --help` or `playforge <url>`.
47
+
48
+ ## ๐Ÿ“ฅ Installation
49
+
50
+ ```bash
51
+ pip install playforge
52
+ playwright install
53
+ ```
54
+
55
+ Or, to install directly from source:
56
+
57
+ ```bash
58
+ pip install .
59
+ playwright install
60
+ ```
61
+
62
+ If you want to use it from a local checkout while developing, install it in editable mode:
63
+
64
+ ```bash
65
+ pip install -e .
66
+ playwright install
67
+ ```
68
+
69
+ You need `playwright install` so Playwright downloads an actual browser. Without that, the CLI can install fine but has nothing to launch.
70
+
71
+ ## ๐Ÿš€ Quick Start
72
+
73
+ ```bash
74
+ playforge https://example.com
75
+ ```
76
+
77
+ The recorder opens a browser window, waits for you to interact with the page, and collects actions until you quit. Use `split` in the terminal if you want to break the recording into another generated method.
78
+
79
+ ### Help
80
+
81
+ ```bash
82
+ playforge --help
83
+ ```
84
+
85
+ ### Output file
86
+
87
+ ```bash
88
+ playforge https://example.com -o generated_page.py
89
+ ```
90
+
91
+ ### What gets recorded
92
+
93
+ PlayForge watches for:
94
+
95
+ - clicks on buttons, links, and other interactive elements
96
+ - fills on input fields and text areas
97
+ - select changes
98
+ - double-click reads on readable text elements
99
+
100
+ Each captured workflow becomes a method in the generated page object. Repeated actions are collapsed where possible so the output stays usable instead of turning into a wall of duplicate steps.
101
+
102
+ ## ๐Ÿ Requirements
103
+
104
+ - Python **3.10+**
105
+ - `playwright`
106
+ - `structlog`
107
+ - `ruff`
108
+
109
+ ## ๐Ÿค Contributing
110
+
111
+ Issues and pull requests are welcome. If you hit a weird recorder edge case, open an issue with the page flow and the generated output.
112
+
113
+ ---
114
+ *Built for browser automation and code generation. MIT Licensed.*
@@ -0,0 +1,25 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ playforge/__init__.py
5
+ playforge/playforge.py
6
+ playforge.egg-info/PKG-INFO
7
+ playforge.egg-info/SOURCES.txt
8
+ playforge.egg-info/dependency_links.txt
9
+ playforge.egg-info/entry_points.txt
10
+ playforge.egg-info/requires.txt
11
+ playforge.egg-info/top_level.txt
12
+ playforge/cli/__init__.py
13
+ playforge/cli/main.py
14
+ playforge/generation/__init__.py
15
+ playforge/generation/render/__init__.py
16
+ playforge/generation/render/generator.py
17
+ playforge/logger/__init__.py
18
+ playforge/logger/logger.py
19
+ playforge/recording/__init__.py
20
+ playforge/recording/capture/__init__.py
21
+ playforge/recording/capture/recorder.py
22
+ playforge/workflow/__init__.py
23
+ playforge/workflow/manager.py
24
+ playforge/workflow/models.py
25
+ playforge/workflow/sanitizer.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ playforge = playforge.cli.main:main
@@ -0,0 +1,3 @@
1
+ playwright>=1.62.0.0
2
+ ruff>=0.16.4.0
3
+ structlog>=26.1.0.0
@@ -0,0 +1 @@
1
+ playforge
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "playforge"
7
+ version = "1.0.0"
8
+ description = "Interactive Playwright recorder that turns browser actions into code"
9
+ readme = {file = "README.md", content-type = "text/markdown"}
10
+ authors = [{name = "ytcalifax (98w)", email = "hello@98w.eu"}]
11
+ requires-python = ">=3.10"
12
+ license = "MIT"
13
+ license-files = ["LICENSE.md"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "Natural Language :: English",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Testing",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Topic :: Utilities",
27
+ ]
28
+ keywords = ["playwright", "page-object", "recorder", "testing", "automation"]
29
+ dependencies = ["playwright>=1.62.0.0", "ruff>=0.16.4.0", "structlog>=26.1.0.0"]
30
+
31
+ [project.scripts]
32
+ playforge = "playforge.cli.main:main"
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ytcalifax/playforge"
36
+ Repository = "https://github.com/ytcalifax/playforge.git"
37
+ "Bug Tracker" = "https://github.com/ytcalifax/playforge/issues"
38
+ Changelog = "https://github.com/ytcalifax/playforge/blob/main/CHANGELOG.md"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["."]
42
+ include = ["playforge*"]
43
+ exclude = ["*__pycache__*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+