visiflow 0.1.0__py3-none-any.whl
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.
- visiflow/__init__.py +5 -0
- visiflow/cli.py +56 -0
- visiflow/core.py +254 -0
- visiflow/playwright.py +114 -0
- visiflow/selenium.py +126 -0
- visiflow/server.py +132 -0
- visiflow/static/index.html +498 -0
- visiflow-0.1.0.dist-info/METADATA +148 -0
- visiflow-0.1.0.dist-info/RECORD +11 -0
- visiflow-0.1.0.dist-info/WHEEL +4 -0
- visiflow-0.1.0.dist-info/entry_points.txt +2 -0
visiflow/__init__.py
ADDED
visiflow/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import webbrowser
|
|
4
|
+
import uvicorn
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .core import VisiFlowDetector, logger
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="visiflow",
|
|
12
|
+
description="VisiFlow: Fast, local visual-driven E2E automation testing tool."
|
|
13
|
+
)
|
|
14
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
15
|
+
|
|
16
|
+
# Subcommand: server
|
|
17
|
+
server_parser = subparsers.add_parser("server", help="Start the VisiFlow local HTTP daemon server")
|
|
18
|
+
server_parser.add_argument("--host", default="127.0.0.1", help="Host IP to bind (default: 127.0.0.1)")
|
|
19
|
+
server_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
|
|
20
|
+
|
|
21
|
+
# Subcommand: ui
|
|
22
|
+
ui_parser = subparsers.add_parser("ui", help="Launch the local interactive Web Playground UI in your browser")
|
|
23
|
+
ui_parser.add_argument("--host", default="127.0.0.1", help="Host IP to bind (default: 127.0.0.1)")
|
|
24
|
+
ui_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
|
|
25
|
+
|
|
26
|
+
# Subcommand: match
|
|
27
|
+
match_parser = subparsers.add_parser("match", help="Test visual target matching directly on an image file")
|
|
28
|
+
match_parser.add_argument("image", help="Path to screenshot image file")
|
|
29
|
+
match_parser.add_argument("query", help="Target text query string to find (e.g. 'Submit')")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
if args.command == "server":
|
|
34
|
+
print(f"🚀 Starting VisiFlow Daemon on http://{args.host}:{args.port}")
|
|
35
|
+
uvicorn.run("visiflow.server:app", host=args.host, port=args.port, log_level="info")
|
|
36
|
+
elif args.command == "ui":
|
|
37
|
+
url = f"http://{args.host}:{args.port}/ui"
|
|
38
|
+
print(f"✨ Opening VisiFlow Web Playground at {url}...")
|
|
39
|
+
webbrowser.open(url)
|
|
40
|
+
uvicorn.run("visiflow.server:app", host=args.host, port=args.port, log_level="info")
|
|
41
|
+
elif args.command == "match":
|
|
42
|
+
img_path = Path(args.image)
|
|
43
|
+
if not img_path.exists():
|
|
44
|
+
print(f"❌ Error: Image file '{args.image}' does not exist.")
|
|
45
|
+
sys.exit(1)
|
|
46
|
+
detector = VisiFlowDetector()
|
|
47
|
+
coords = detector.find_element_by_text(str(img_path), args.query)
|
|
48
|
+
if coords:
|
|
49
|
+
print(f"✅ Match found for '{args.query}' at coordinates (X: {coords[0]}, Y: {coords[1]})")
|
|
50
|
+
else:
|
|
51
|
+
print(f"❌ No match found for '{args.query}'")
|
|
52
|
+
else:
|
|
53
|
+
parser.print_help()
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
visiflow/core.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
import difflib
|
|
4
|
+
import logging
|
|
5
|
+
from typing import List, Dict, Tuple, Optional, Any
|
|
6
|
+
|
|
7
|
+
# Configure logging
|
|
8
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
9
|
+
logger = logging.getLogger("VisiFlow")
|
|
10
|
+
|
|
11
|
+
class VisiFlowDetector:
|
|
12
|
+
def __init__(self, model_path: Optional[str] = "yolov8n.pt", use_yolo: bool = True, languages: List[str] = ["en", "ch_tra"]):
|
|
13
|
+
"""
|
|
14
|
+
Initialize the VisiFlow local visual detector.
|
|
15
|
+
|
|
16
|
+
:param model_path: Path to the YOLO model file (e.g. yolov8n.pt or a custom UI model pt/onnx).
|
|
17
|
+
:param use_yolo: Whether to attempt loading and running YOLO.
|
|
18
|
+
:param languages: List of languages for EasyOCR. Defaults to English and Traditional Chinese.
|
|
19
|
+
"""
|
|
20
|
+
self.use_yolo = use_yolo
|
|
21
|
+
self.model = None
|
|
22
|
+
self.ocr_reader = None
|
|
23
|
+
self.languages = languages
|
|
24
|
+
self._init_yolo(model_path)
|
|
25
|
+
self._init_ocr()
|
|
26
|
+
|
|
27
|
+
def _init_yolo(self, model_path: Optional[str]):
|
|
28
|
+
if not self.use_yolo:
|
|
29
|
+
logger.info("YOLO is disabled by user configuration.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
from ultralytics import YOLO
|
|
34
|
+
logger.info(f"Loading local YOLO model from {model_path}...")
|
|
35
|
+
self.model = YOLO(model_path)
|
|
36
|
+
logger.info("YOLO model loaded successfully.")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.warning(f"Could not load YOLO model (will fallback to OpenCV contour heuristic): {e}")
|
|
39
|
+
self.model = None
|
|
40
|
+
|
|
41
|
+
def _init_ocr(self):
|
|
42
|
+
try:
|
|
43
|
+
import easyocr
|
|
44
|
+
logger.info(f"Initializing local EasyOCR reader for languages: {self.languages}...")
|
|
45
|
+
self.ocr_reader = easyocr.Reader(self.languages, gpu=True) # Automatically detects CUDA/GPU
|
|
46
|
+
logger.info("EasyOCR initialized successfully.")
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Failed to initialize EasyOCR: {e}. Please ensure torch and easyocr are installed.")
|
|
49
|
+
self.ocr_reader = None
|
|
50
|
+
|
|
51
|
+
def detect_contours(self, img: np.ndarray) -> List[Dict[str, Any]]:
|
|
52
|
+
"""
|
|
53
|
+
OpenCV heuristic fallback: detect potential interactive UI elements (buttons, inputs)
|
|
54
|
+
using hierarchical contour detection.
|
|
55
|
+
"""
|
|
56
|
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
57
|
+
# Apply bilateral filter to preserve edges while removing noise
|
|
58
|
+
blurred = cv2.bilateralFilter(gray, 9, 75, 75)
|
|
59
|
+
# Adaptive thresholding to handle different lighting / styles
|
|
60
|
+
thresh = cv2.adaptiveThreshold(
|
|
61
|
+
blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Find contours
|
|
65
|
+
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
|
66
|
+
detected = []
|
|
67
|
+
|
|
68
|
+
height, width = img.shape[:2]
|
|
69
|
+
min_area = 100
|
|
70
|
+
max_area = (width * height) * 0.25 # Ignore containers that are too large (e.g. body/header)
|
|
71
|
+
|
|
72
|
+
for c in contours:
|
|
73
|
+
x, y, w, h = cv2.boundingRect(c)
|
|
74
|
+
area = w * h
|
|
75
|
+
aspect_ratio = float(w) / h
|
|
76
|
+
|
|
77
|
+
# Heuristic filter for buttons and inputs (typically wider than tall, but not too thin)
|
|
78
|
+
if min_area < area < max_area:
|
|
79
|
+
if 0.5 < aspect_ratio < 15 and w > 15 and h > 10:
|
|
80
|
+
detected.append({
|
|
81
|
+
"box": [x, y, x + w, y + h],
|
|
82
|
+
"label": "ui_element",
|
|
83
|
+
"confidence": 0.8,
|
|
84
|
+
"source": "opencv"
|
|
85
|
+
})
|
|
86
|
+
return detected
|
|
87
|
+
|
|
88
|
+
def detect_elements(self, img_path: str) -> List[Dict[str, Any]]:
|
|
89
|
+
"""
|
|
90
|
+
Run YOLO detection on the screenshot.
|
|
91
|
+
"""
|
|
92
|
+
img = cv2.imread(img_path)
|
|
93
|
+
if img is None:
|
|
94
|
+
logger.error(f"Image not found or unable to read: {img_path}")
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
elements = []
|
|
98
|
+
if self.model:
|
|
99
|
+
try:
|
|
100
|
+
results = self.model(img_path, verbose=False)
|
|
101
|
+
for r in results:
|
|
102
|
+
boxes = r.boxes
|
|
103
|
+
for box in boxes:
|
|
104
|
+
x1, y1, x2, y2 = map(int, box.xyxy[0].tolist())
|
|
105
|
+
cls = int(box.cls[0].item())
|
|
106
|
+
label = self.model.names[cls]
|
|
107
|
+
conf = float(box.conf[0].item())
|
|
108
|
+
elements.append({
|
|
109
|
+
"box": [x1, y1, x2, y2],
|
|
110
|
+
"label": label,
|
|
111
|
+
"confidence": conf,
|
|
112
|
+
"source": "yolo"
|
|
113
|
+
})
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.error(f"YOLO inference error: {e}")
|
|
116
|
+
|
|
117
|
+
# Always run OpenCV heuristics as a complementary source or fallback
|
|
118
|
+
contour_elements = self.detect_contours(img)
|
|
119
|
+
|
|
120
|
+
# Merge YOLO and OpenCV detections (remove duplicates/heavily overlapping ones)
|
|
121
|
+
all_elements = elements + contour_elements
|
|
122
|
+
merged_elements = self._non_max_suppression(all_elements, iou_threshold=0.5)
|
|
123
|
+
return merged_elements
|
|
124
|
+
|
|
125
|
+
def _non_max_suppression(self, elements: List[Dict[str, Any]], iou_threshold: float) -> List[Dict[str, Any]]:
|
|
126
|
+
if not elements:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
# Sort by confidence/source priority (YOLO preferred over OpenCV)
|
|
130
|
+
sorted_elements = sorted(
|
|
131
|
+
elements,
|
|
132
|
+
key=lambda e: (1 if e["source"] == "yolo" else 0, e["confidence"]),
|
|
133
|
+
reverse=True
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
keep = []
|
|
137
|
+
while sorted_elements:
|
|
138
|
+
best = sorted_elements.pop(0)
|
|
139
|
+
keep.append(best)
|
|
140
|
+
|
|
141
|
+
remaining = []
|
|
142
|
+
for item in sorted_elements:
|
|
143
|
+
if self._calculate_iou(best["box"], item["box"]) < iou_threshold:
|
|
144
|
+
remaining.append(item)
|
|
145
|
+
sorted_elements = remaining
|
|
146
|
+
return keep
|
|
147
|
+
|
|
148
|
+
def _calculate_iou(self, boxA: List[int], boxB: List[int]) -> float:
|
|
149
|
+
xA = max(boxA[0], boxB[0])
|
|
150
|
+
yA = max(boxA[1], boxB[1])
|
|
151
|
+
xB = min(boxA[2], boxB[2])
|
|
152
|
+
yB = min(boxA[3], boxB[3])
|
|
153
|
+
|
|
154
|
+
interArea = max(0, xB - xA) * max(0, yB - yA)
|
|
155
|
+
boxAArea = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
|
|
156
|
+
boxBArea = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
|
|
157
|
+
|
|
158
|
+
unionArea = boxAArea + boxBArea - interArea
|
|
159
|
+
if unionArea == 0:
|
|
160
|
+
return 0.0
|
|
161
|
+
return interArea / unionArea
|
|
162
|
+
|
|
163
|
+
def run_ocr(self, img_path: str) -> List[Dict[str, Any]]:
|
|
164
|
+
"""
|
|
165
|
+
Run EasyOCR on the screenshot.
|
|
166
|
+
"""
|
|
167
|
+
if not self.ocr_reader:
|
|
168
|
+
logger.warning("OCR reader is not initialized. Skipping OCR.")
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
results = self.ocr_reader.readtext(img_path)
|
|
173
|
+
ocr_elements = []
|
|
174
|
+
for (bbox, text, conf) in results:
|
|
175
|
+
# bbox is [[x0, y0], [x1, y1], [x2, y2], [x3, y3]]
|
|
176
|
+
xs = [pt[0] for pt in bbox]
|
|
177
|
+
ys = [pt[1] for pt in bbox]
|
|
178
|
+
x_min, x_max = int(min(xs)), int(max(xs))
|
|
179
|
+
y_min, y_max = int(min(ys)), int(max(ys))
|
|
180
|
+
|
|
181
|
+
ocr_elements.append({
|
|
182
|
+
"text": text.strip(),
|
|
183
|
+
"box": [x_min, y_min, x_max, y_max],
|
|
184
|
+
"confidence": float(conf)
|
|
185
|
+
})
|
|
186
|
+
return ocr_elements
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.error(f"OCR execution error: {e}")
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
def find_element_by_text(self, img_path: str, query_text: str, fuzzy_threshold: float = 0.6) -> Optional[Tuple[int, int]]:
|
|
192
|
+
"""
|
|
193
|
+
Find target element coordinates by searching for text, matching with YOLO/OpenCV bounding boxes.
|
|
194
|
+
|
|
195
|
+
:param img_path: Path to browser screenshot.
|
|
196
|
+
:param query_text: Target text to look for (e.g. "Submit", "登入").
|
|
197
|
+
:param fuzzy_threshold: Levenshtein/Gestalt similarity threshold (0.0 to 1.0).
|
|
198
|
+
:return: Center coordinates (x, y) of the matched element, or None if not found.
|
|
199
|
+
"""
|
|
200
|
+
ocr_results = self.run_ocr(img_path)
|
|
201
|
+
if not ocr_results:
|
|
202
|
+
logger.warning("No OCR text detected.")
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# 1. Look for fuzzy text match
|
|
206
|
+
best_match = None
|
|
207
|
+
best_score = 0.0
|
|
208
|
+
|
|
209
|
+
query_lower = query_text.lower()
|
|
210
|
+
for ocr_item in ocr_results:
|
|
211
|
+
text = ocr_item["text"]
|
|
212
|
+
text_lower = text.lower()
|
|
213
|
+
|
|
214
|
+
# Simple substring match yields score of 1.0
|
|
215
|
+
if query_lower in text_lower:
|
|
216
|
+
score = 1.0
|
|
217
|
+
else:
|
|
218
|
+
score = difflib.SequenceMatcher(None, query_lower, text_lower).ratio()
|
|
219
|
+
|
|
220
|
+
if score > best_score and score >= fuzzy_threshold:
|
|
221
|
+
best_score = score
|
|
222
|
+
best_match = ocr_item
|
|
223
|
+
|
|
224
|
+
if not best_match:
|
|
225
|
+
logger.warning(f"No text match found for query: '{query_text}' (best score was below threshold {fuzzy_threshold})")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
logger.info(f"Matched text '{best_match['text']}' for query '{query_text}' with score {best_score:.2f}")
|
|
229
|
+
|
|
230
|
+
# 2. Get the bounding box of matched text
|
|
231
|
+
txt_box = best_match["box"] # [x_min, y_min, x_max, y_max]
|
|
232
|
+
txt_center = ((txt_box[0] + txt_box[2]) // 2, (txt_box[1] + txt_box[3]) // 2)
|
|
233
|
+
|
|
234
|
+
# 3. Find if there is a YOLO/contour element bounding box enclosing or heavily overlapping this text
|
|
235
|
+
ui_elements = self.detect_elements(img_path)
|
|
236
|
+
containing_element = None
|
|
237
|
+
|
|
238
|
+
for elem in ui_elements:
|
|
239
|
+
box = elem["box"] # [x_min, y_min, x_max, y_max]
|
|
240
|
+
# Check if text center is inside the UI element box
|
|
241
|
+
if box[0] <= txt_center[0] <= box[2] and box[1] <= txt_center[1] <= box[3]:
|
|
242
|
+
containing_element = elem
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
if containing_element:
|
|
246
|
+
# Return center of the containing UI element (e.g., the actual button border, which is safer for clicking)
|
|
247
|
+
elem_box = containing_element["box"]
|
|
248
|
+
elem_center = ((elem_box[0] + elem_box[2]) // 2, (elem_box[1] + elem_box[3]) // 2)
|
|
249
|
+
logger.info(f"Target text resides inside UI element: {containing_element['label']} {elem_box}. Clicking element center.")
|
|
250
|
+
return elem_center
|
|
251
|
+
else:
|
|
252
|
+
# Fallback to clicking the text center directly
|
|
253
|
+
logger.info("Target text center is not inside any detected UI element container. Clicking text center directly.")
|
|
254
|
+
return txt_center
|
visiflow/playwright.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
import cv2
|
|
5
|
+
from typing import Optional, Any
|
|
6
|
+
from .core import VisiFlowDetector, logger
|
|
7
|
+
|
|
8
|
+
class VisiPlaywrightPage:
|
|
9
|
+
def __init__(self, page: Any, detector: Optional[VisiFlowDetector] = None):
|
|
10
|
+
"""
|
|
11
|
+
Wrapper for Playwright Page to add visual action capabilities.
|
|
12
|
+
|
|
13
|
+
:param page: The playwright Page object
|
|
14
|
+
:param detector: An optional custom VisiFlowDetector instance
|
|
15
|
+
"""
|
|
16
|
+
self.page = page
|
|
17
|
+
self.detector = detector or VisiFlowDetector()
|
|
18
|
+
|
|
19
|
+
def _resolve_coordinates(self, text_or_label: str) -> Optional[tuple]:
|
|
20
|
+
"""
|
|
21
|
+
Take a screenshot, run visual detection, scale coordinates to page viewport, and return them.
|
|
22
|
+
"""
|
|
23
|
+
# 1. Capture screen to a temporary file
|
|
24
|
+
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
|
25
|
+
os.close(fd)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
self.page.screenshot(path=temp_path)
|
|
29
|
+
|
|
30
|
+
# 2. Get image dimensions (physical pixels)
|
|
31
|
+
img = cv2.imread(temp_path)
|
|
32
|
+
if img is None:
|
|
33
|
+
logger.error("Failed to read captured screenshot.")
|
|
34
|
+
return None
|
|
35
|
+
sh, sw = img.shape[:2]
|
|
36
|
+
|
|
37
|
+
# 3. Get viewport size (logical browser pixels)
|
|
38
|
+
viewport = self.page.viewport_size
|
|
39
|
+
if not viewport:
|
|
40
|
+
# Fallback to physical size if viewport is none (e.g. in some browser settings)
|
|
41
|
+
scale_x, scale_y = 1.0, 1.0
|
|
42
|
+
else:
|
|
43
|
+
scale_x = viewport["width"] / sw
|
|
44
|
+
scale_y = viewport["height"] / sh
|
|
45
|
+
|
|
46
|
+
# 4. Detect target element coordinates
|
|
47
|
+
coords = self.detector.find_element_by_text(temp_path, text_or_label)
|
|
48
|
+
if coords:
|
|
49
|
+
cx, cy = coords
|
|
50
|
+
# Scale from screenshot coords to logical browser viewport coords
|
|
51
|
+
px = int(cx * scale_x)
|
|
52
|
+
py = int(cy * scale_y)
|
|
53
|
+
logger.info(f"Resolved visual target '{text_or_label}' from screen ({cx}, {cy}) to logical browser ({px}, {py})")
|
|
54
|
+
return px, py
|
|
55
|
+
return None
|
|
56
|
+
finally:
|
|
57
|
+
# Clean up temp file
|
|
58
|
+
if os.path.exists(temp_path):
|
|
59
|
+
os.remove(temp_path)
|
|
60
|
+
|
|
61
|
+
def visual_click(self, text_or_label: str, timeout_ms: int = 10000) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Locate an element visually by text/label and click it.
|
|
64
|
+
"""
|
|
65
|
+
start = time.time()
|
|
66
|
+
while time.time() - start < (timeout_ms / 1000.0):
|
|
67
|
+
coords = self._resolve_coordinates(text_or_label)
|
|
68
|
+
if coords:
|
|
69
|
+
x, y = coords
|
|
70
|
+
self.page.mouse.click(x, y)
|
|
71
|
+
logger.info(f"Successfully performed visual_click on '{text_or_label}' at ({x}, {y})")
|
|
72
|
+
return True
|
|
73
|
+
time.sleep(0.5)
|
|
74
|
+
|
|
75
|
+
raise TimeoutError(f"Could not locate element with text/label '{text_or_label}' visually within {timeout_ms}ms")
|
|
76
|
+
|
|
77
|
+
def visual_fill(self, text_or_label: str, value: str, timeout_ms: int = 10000) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Locate an input box visually using its text label or placeholder, click it, clear it, and type the value.
|
|
80
|
+
"""
|
|
81
|
+
start = time.time()
|
|
82
|
+
while time.time() - start < (timeout_ms / 1000.0):
|
|
83
|
+
coords = self._resolve_coordinates(text_or_label)
|
|
84
|
+
if coords:
|
|
85
|
+
x, y = coords
|
|
86
|
+
# Click to focus the input field
|
|
87
|
+
self.page.mouse.click(x, y)
|
|
88
|
+
|
|
89
|
+
# Highlight and clear existing text
|
|
90
|
+
# We can perform Triple click to select all text, then type
|
|
91
|
+
self.page.mouse.click(x, y, click_count=3)
|
|
92
|
+
self.page.keyboard.press("Backspace")
|
|
93
|
+
|
|
94
|
+
# Type the new value
|
|
95
|
+
self.page.keyboard.type(value)
|
|
96
|
+
logger.info(f"Successfully performed visual_fill on '{text_or_label}' with value '{value}'")
|
|
97
|
+
return True
|
|
98
|
+
time.sleep(0.5)
|
|
99
|
+
|
|
100
|
+
raise TimeoutError(f"Could not locate input field with text/label '{text_or_label}' visually within {timeout_ms}ms")
|
|
101
|
+
|
|
102
|
+
def visual_wait_for(self, text_or_label: str, timeout_ms: int = 10000) -> bool:
|
|
103
|
+
"""
|
|
104
|
+
Wait for an element to be visually present on the page.
|
|
105
|
+
"""
|
|
106
|
+
start = time.time()
|
|
107
|
+
while time.time() - start < (timeout_ms / 1000.0):
|
|
108
|
+
coords = self._resolve_coordinates(text_or_label)
|
|
109
|
+
if coords:
|
|
110
|
+
logger.info(f"Visual element '{text_or_label}' is now present.")
|
|
111
|
+
return True
|
|
112
|
+
time.sleep(0.5)
|
|
113
|
+
|
|
114
|
+
raise TimeoutError(f"Timed out waiting for visual element '{text_or_label}' to be present within {timeout_ms}ms")
|
visiflow/selenium.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
import cv2
|
|
5
|
+
from typing import Optional, Any
|
|
6
|
+
from .core import VisiFlowDetector, logger
|
|
7
|
+
|
|
8
|
+
class VisiSeleniumDriver:
|
|
9
|
+
def __init__(self, driver: Any, detector: Optional[VisiFlowDetector] = None):
|
|
10
|
+
"""
|
|
11
|
+
Wrapper/Helper for Selenium WebDriver to add visual action capabilities.
|
|
12
|
+
|
|
13
|
+
:param driver: The Selenium WebDriver instance
|
|
14
|
+
:param detector: An optional custom VisiFlowDetector instance
|
|
15
|
+
"""
|
|
16
|
+
self.driver = driver
|
|
17
|
+
self.detector = detector or VisiFlowDetector()
|
|
18
|
+
|
|
19
|
+
def _resolve_coordinates(self, text_or_label: str) -> Optional[tuple]:
|
|
20
|
+
"""
|
|
21
|
+
Take a screenshot, run visual detection, scale coordinates to page viewport, and return them.
|
|
22
|
+
"""
|
|
23
|
+
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
|
24
|
+
os.close(fd)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
self.driver.save_screenshot(temp_path)
|
|
28
|
+
|
|
29
|
+
# 2. Get image dimensions (physical pixels)
|
|
30
|
+
img = cv2.imread(temp_path)
|
|
31
|
+
if img is None:
|
|
32
|
+
logger.error("Failed to read captured screenshot.")
|
|
33
|
+
return None
|
|
34
|
+
sh, sw = img.shape[:2]
|
|
35
|
+
|
|
36
|
+
# 3. Get viewport size (logical browser pixels)
|
|
37
|
+
viewport = self.driver.execute_script(
|
|
38
|
+
"return {width: window.innerWidth, height: window.innerHeight};"
|
|
39
|
+
)
|
|
40
|
+
if not viewport or "width" not in viewport or "height" not in viewport:
|
|
41
|
+
scale_x, scale_y = 1.0, 1.0
|
|
42
|
+
else:
|
|
43
|
+
scale_x = viewport["width"] / sw
|
|
44
|
+
scale_y = viewport["height"] / sh
|
|
45
|
+
|
|
46
|
+
# 4. Detect target element coordinates
|
|
47
|
+
coords = self.detector.find_element_by_text(temp_path, text_or_label)
|
|
48
|
+
if coords:
|
|
49
|
+
cx, cy = coords
|
|
50
|
+
px = int(cx * scale_x)
|
|
51
|
+
py = int(cy * scale_y)
|
|
52
|
+
logger.info(f"Resolved visual target '{text_or_label}' from screen ({cx}, {cy}) to logical browser ({px}, {py})")
|
|
53
|
+
return px, py
|
|
54
|
+
return None
|
|
55
|
+
finally:
|
|
56
|
+
if os.path.exists(temp_path):
|
|
57
|
+
os.remove(temp_path)
|
|
58
|
+
|
|
59
|
+
def visual_click(self, text_or_label: str, timeout_ms: int = 10000) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Locate an element visually by text/label and click it.
|
|
62
|
+
"""
|
|
63
|
+
start = time.time()
|
|
64
|
+
while time.time() - start < (timeout_ms / 1000.0):
|
|
65
|
+
coords = self._resolve_coordinates(text_or_label)
|
|
66
|
+
if coords:
|
|
67
|
+
x, y = coords
|
|
68
|
+
# Execute click via JS at viewport coordinates (reliable across platforms)
|
|
69
|
+
clicked = self.driver.execute_script(
|
|
70
|
+
f"const el = document.elementFromPoint({x}, {y}); if (el) {{ el.click(); return true; }} return false;"
|
|
71
|
+
)
|
|
72
|
+
if clicked:
|
|
73
|
+
logger.info(f"Successfully performed JS visual_click on '{text_or_label}' at ({x}, {y})")
|
|
74
|
+
return True
|
|
75
|
+
else:
|
|
76
|
+
# Fallback to ActionChains relative to body element
|
|
77
|
+
try:
|
|
78
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
79
|
+
body = self.driver.find_element("tag name", "body")
|
|
80
|
+
# Move to body top-left, then offset
|
|
81
|
+
ActionChains(self.driver).move_to_element_with_offset(body, x, y).click().perform()
|
|
82
|
+
logger.info(f"Successfully performed ActionChains visual_click on '{text_or_label}' at ({x}, {y})")
|
|
83
|
+
return True
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.warning(f"ActionChains click fallback failed: {e}")
|
|
86
|
+
time.sleep(0.5)
|
|
87
|
+
|
|
88
|
+
raise TimeoutError(f"Could not locate element with text/label '{text_or_label}' visually within {timeout_ms}ms")
|
|
89
|
+
|
|
90
|
+
def visual_fill(self, text_or_label: str, value: str, timeout_ms: int = 10000) -> bool:
|
|
91
|
+
"""
|
|
92
|
+
Locate an input box visually using its text label or placeholder, click it, clear it, and type the value.
|
|
93
|
+
"""
|
|
94
|
+
start = time.time()
|
|
95
|
+
while time.time() - start < (timeout_ms / 1000.0):
|
|
96
|
+
coords = self._resolve_coordinates(text_or_label)
|
|
97
|
+
if coords:
|
|
98
|
+
x, y = coords
|
|
99
|
+
# Focus the input field and select all text to clear it
|
|
100
|
+
focused = self.driver.execute_script(
|
|
101
|
+
f"""
|
|
102
|
+
const el = document.elementFromPoint({x}, {y});
|
|
103
|
+
if (el) {{
|
|
104
|
+
el.focus();
|
|
105
|
+
if (typeof el.select === 'function') {{
|
|
106
|
+
el.select();
|
|
107
|
+
}}
|
|
108
|
+
return true;
|
|
109
|
+
}}
|
|
110
|
+
return false;
|
|
111
|
+
"""
|
|
112
|
+
)
|
|
113
|
+
if focused:
|
|
114
|
+
# Send text to currently active/focused element
|
|
115
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
116
|
+
from selenium.webdriver.common.keys import Keys
|
|
117
|
+
actions = ActionChains(self.driver)
|
|
118
|
+
# Clear using backspace
|
|
119
|
+
actions.send_keys(Keys.BACKSPACE * 100)
|
|
120
|
+
actions.send_keys(value)
|
|
121
|
+
actions.perform()
|
|
122
|
+
logger.info(f"Successfully performed visual_fill on '{text_or_label}' with value '{value}'")
|
|
123
|
+
return True
|
|
124
|
+
time.sleep(0.5)
|
|
125
|
+
|
|
126
|
+
raise TimeoutError(f"Could not locate input field with text/label '{text_or_label}' visually within {timeout_ms}ms")
|
visiflow/server.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import base64
|
|
4
|
+
import cv2
|
|
5
|
+
import numpy as np
|
|
6
|
+
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
from fastapi.responses import HTMLResponse, FileResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from typing import Optional, List, Dict, Any
|
|
11
|
+
|
|
12
|
+
from .core import VisiFlowDetector, logger
|
|
13
|
+
|
|
14
|
+
app = FastAPI(title="VisiFlow Vision Daemon", version="0.1.0")
|
|
15
|
+
|
|
16
|
+
# Enable CORS for local cross-origin queries (e.g. from browser playground)
|
|
17
|
+
app.add_middleware(
|
|
18
|
+
CORSMiddleware,
|
|
19
|
+
allow_origins=["*"],
|
|
20
|
+
allow_credentials=True,
|
|
21
|
+
allow_methods=["*"],
|
|
22
|
+
allow_headers=["*"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Global lazy-initialized detector
|
|
26
|
+
detector_instance: Optional[VisiFlowDetector] = None
|
|
27
|
+
|
|
28
|
+
def get_detector() -> VisiFlowDetector:
|
|
29
|
+
global detector_instance
|
|
30
|
+
if detector_instance is None:
|
|
31
|
+
logger.info("Initializing global VisiFlowDetector instance for HTTP server...")
|
|
32
|
+
# Use YOLO with fallback
|
|
33
|
+
detector_instance = VisiFlowDetector(use_yolo=True)
|
|
34
|
+
return detector_instance
|
|
35
|
+
|
|
36
|
+
class Base64MatchRequest(BaseModel):
|
|
37
|
+
image_base64: str
|
|
38
|
+
query: str
|
|
39
|
+
fuzzy_threshold: Optional[float] = 0.6
|
|
40
|
+
|
|
41
|
+
@app.get("/")
|
|
42
|
+
def read_root():
|
|
43
|
+
return {"status": "ok", "service": "VisiFlow Vision Daemon", "version": "0.1.0"}
|
|
44
|
+
|
|
45
|
+
@app.post("/match")
|
|
46
|
+
async def match_target(
|
|
47
|
+
file: Optional[UploadFile] = File(None),
|
|
48
|
+
image_base64: Optional[str] = Form(None),
|
|
49
|
+
query: str = Form(...)
|
|
50
|
+
):
|
|
51
|
+
"""
|
|
52
|
+
Match a target query string against a screenshot image and return the resolved viewport coordinates.
|
|
53
|
+
Accepts either file upload or base64 encoded image string.
|
|
54
|
+
"""
|
|
55
|
+
detector = get_detector()
|
|
56
|
+
|
|
57
|
+
# Save image bytes to temp file
|
|
58
|
+
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
|
59
|
+
os.close(fd)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
if file:
|
|
63
|
+
content = await file.read()
|
|
64
|
+
with open(temp_path, "wb") as f:
|
|
65
|
+
f.write(content)
|
|
66
|
+
elif image_base64:
|
|
67
|
+
# Clean base64 prefix if present
|
|
68
|
+
if "," in image_base64:
|
|
69
|
+
image_base64 = image_base64.split(",")[1]
|
|
70
|
+
img_bytes = base64.b64decode(image_base64)
|
|
71
|
+
with open(temp_path, "wb") as f:
|
|
72
|
+
f.write(img_bytes)
|
|
73
|
+
else:
|
|
74
|
+
raise HTTPException(status_code=400, detail="Either 'file' or 'image_base64' must be provided.")
|
|
75
|
+
|
|
76
|
+
coords = detector.find_element_by_text(temp_path, query)
|
|
77
|
+
if coords:
|
|
78
|
+
return {"found": True, "x": coords[0], "y": coords[1], "query": query}
|
|
79
|
+
return {"found": False, "x": None, "y": None, "query": query}
|
|
80
|
+
finally:
|
|
81
|
+
if os.path.exists(temp_path):
|
|
82
|
+
os.remove(temp_path)
|
|
83
|
+
|
|
84
|
+
@app.post("/detect")
|
|
85
|
+
async def detect_all(
|
|
86
|
+
file: Optional[UploadFile] = File(None),
|
|
87
|
+
image_base64: Optional[str] = Form(None)
|
|
88
|
+
):
|
|
89
|
+
"""
|
|
90
|
+
Detect all UI element bounding boxes and OCR texts for the Web Playground.
|
|
91
|
+
"""
|
|
92
|
+
detector = get_detector()
|
|
93
|
+
|
|
94
|
+
fd, temp_path = tempfile.mkstemp(suffix=".png")
|
|
95
|
+
os.close(fd)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if file:
|
|
99
|
+
content = await file.read()
|
|
100
|
+
with open(temp_path, "wb") as f:
|
|
101
|
+
f.write(content)
|
|
102
|
+
elif image_base64:
|
|
103
|
+
if "," in image_base64:
|
|
104
|
+
image_base64 = image_base64.split(",")[1]
|
|
105
|
+
img_bytes = base64.b64decode(image_base64)
|
|
106
|
+
with open(temp_path, "wb") as f:
|
|
107
|
+
f.write(img_bytes)
|
|
108
|
+
else:
|
|
109
|
+
raise HTTPException(status_code=400, detail="Either 'file' or 'image_base64' must be provided.")
|
|
110
|
+
|
|
111
|
+
elements = detector.detect_elements(temp_path)
|
|
112
|
+
ocr_items = detector.run_ocr(temp_path)
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"status": "success",
|
|
116
|
+
"elements": elements,
|
|
117
|
+
"ocr": ocr_items
|
|
118
|
+
}
|
|
119
|
+
finally:
|
|
120
|
+
if os.path.exists(temp_path):
|
|
121
|
+
os.remove(temp_path)
|
|
122
|
+
|
|
123
|
+
@app.get("/ui", response_class=HTMLResponse)
|
|
124
|
+
def get_web_ui():
|
|
125
|
+
"""
|
|
126
|
+
Serve the Web Playground static HTML UI.
|
|
127
|
+
"""
|
|
128
|
+
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
|
129
|
+
html_path = os.path.join(static_dir, "index.html")
|
|
130
|
+
if os.path.exists(html_path):
|
|
131
|
+
return FileResponse(html_path)
|
|
132
|
+
return HTMLResponse("<h1>VisiFlow Web Playground</h1><p>Static UI not found.</p>")
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>VisiFlow Interactive Playground</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
|
|
13
|
+
--panel-bg: rgba(30, 41, 59, 0.7);
|
|
14
|
+
--border-color: rgba(255, 255, 255, 0.1);
|
|
15
|
+
--accent-blue: #38bdf8;
|
|
16
|
+
--accent-emerald: #34d399;
|
|
17
|
+
--accent-pink: #f43f5e;
|
|
18
|
+
--text-main: #f8fafc;
|
|
19
|
+
--text-muted: #94a3b8;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
* {
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
margin: 0;
|
|
25
|
+
padding: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
font-family: 'Outfit', sans-serif;
|
|
30
|
+
background: var(--bg-gradient);
|
|
31
|
+
color: var(--text-main);
|
|
32
|
+
min-height: 100vh;
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
header {
|
|
38
|
+
padding: 20px 40px;
|
|
39
|
+
background: rgba(15, 23, 42, 0.8);
|
|
40
|
+
backdrop-filter: blur(12px);
|
|
41
|
+
border-bottom: 1px solid var(--border-color);
|
|
42
|
+
display: flex;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
align-items: center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.logo-group {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 12px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.logo-icon {
|
|
54
|
+
font-size: 2rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.logo-text {
|
|
58
|
+
font-size: 1.5rem;
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
background: linear-gradient(to right, #38bdf8, #818cf8);
|
|
61
|
+
-webkit-background-clip: text;
|
|
62
|
+
-webkit-text-fill-color: transparent;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.status-badge {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
gap: 8px;
|
|
69
|
+
background: rgba(52, 211, 153, 0.15);
|
|
70
|
+
border: 1px solid rgba(52, 211, 153, 0.3);
|
|
71
|
+
color: var(--accent-emerald);
|
|
72
|
+
padding: 6px 14px;
|
|
73
|
+
border-radius: 20px;
|
|
74
|
+
font-size: 0.85rem;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.pulse-dot {
|
|
79
|
+
width: 8px;
|
|
80
|
+
height: 8px;
|
|
81
|
+
background-color: var(--accent-emerald);
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
box-shadow: 0 0 8px var(--accent-emerald);
|
|
84
|
+
animation: pulse 2s infinite;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@keyframes pulse {
|
|
88
|
+
0% { opacity: 1; transform: scale(1); }
|
|
89
|
+
50% { opacity: 0.4; transform: scale(1.2); }
|
|
90
|
+
100% { opacity: 1; transform: scale(1); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
main {
|
|
94
|
+
flex: 1;
|
|
95
|
+
padding: 30px 40px;
|
|
96
|
+
display: grid;
|
|
97
|
+
grid-template-columns: 1fr 380px;
|
|
98
|
+
gap: 30px;
|
|
99
|
+
max-width: 1600px;
|
|
100
|
+
margin: 0 auto;
|
|
101
|
+
width: 100%;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.stage-card {
|
|
105
|
+
background: var(--panel-bg);
|
|
106
|
+
backdrop-filter: blur(16px);
|
|
107
|
+
border: 1px solid var(--border-color);
|
|
108
|
+
border-radius: 16px;
|
|
109
|
+
padding: 24px;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
align-items: center;
|
|
113
|
+
justify-content: center;
|
|
114
|
+
position: relative;
|
|
115
|
+
min-height: 600px;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.drop-zone {
|
|
120
|
+
border: 2px dashed rgba(56, 189, 248, 0.4);
|
|
121
|
+
border-radius: 12px;
|
|
122
|
+
padding: 60px 40px;
|
|
123
|
+
text-align: center;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
transition: all 0.3s ease;
|
|
126
|
+
background: rgba(15, 23, 42, 0.3);
|
|
127
|
+
width: 100%;
|
|
128
|
+
max-width: 500px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.drop-zone:hover, .drop-zone.dragover {
|
|
132
|
+
border-color: var(--accent-blue);
|
|
133
|
+
background: rgba(56, 189, 248, 0.08);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.drop-icon {
|
|
137
|
+
font-size: 3.5rem;
|
|
138
|
+
margin-bottom: 16px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.canvas-wrapper {
|
|
142
|
+
position: relative;
|
|
143
|
+
max-width: 100%;
|
|
144
|
+
max-height: 75vh;
|
|
145
|
+
overflow: auto;
|
|
146
|
+
display: none;
|
|
147
|
+
border-radius: 8px;
|
|
148
|
+
box-shadow: 0 20px 40px rgba(0,0,0,0.5);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
canvas {
|
|
152
|
+
display: block;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.sidebar {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
gap: 20px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.card {
|
|
162
|
+
background: var(--panel-bg);
|
|
163
|
+
backdrop-filter: blur(16px);
|
|
164
|
+
border: 1px solid var(--border-color);
|
|
165
|
+
border-radius: 16px;
|
|
166
|
+
padding: 20px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.card-title {
|
|
170
|
+
font-size: 1.1rem;
|
|
171
|
+
font-weight: 600;
|
|
172
|
+
margin-bottom: 16px;
|
|
173
|
+
color: #e2e8f0;
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.input-group {
|
|
180
|
+
display: flex;
|
|
181
|
+
gap: 10px;
|
|
182
|
+
margin-bottom: 16px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
input[type="text"] {
|
|
186
|
+
flex: 1;
|
|
187
|
+
padding: 12px;
|
|
188
|
+
background: rgba(15, 23, 42, 0.6);
|
|
189
|
+
border: 1px solid var(--border-color);
|
|
190
|
+
border-radius: 8px;
|
|
191
|
+
color: white;
|
|
192
|
+
font-family: inherit;
|
|
193
|
+
outline: none;
|
|
194
|
+
transition: border-color 0.2s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
input[type="text"]:focus {
|
|
198
|
+
border-color: var(--accent-blue);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
button.btn-primary {
|
|
202
|
+
padding: 12px 20px;
|
|
203
|
+
background: linear-gradient(135deg, #38bdf8, #2563eb);
|
|
204
|
+
border: none;
|
|
205
|
+
border-radius: 8px;
|
|
206
|
+
color: white;
|
|
207
|
+
font-weight: 600;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
transition: transform 0.1s, opacity 0.2s;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
button.btn-primary:hover {
|
|
213
|
+
opacity: 0.9;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
button.btn-primary:active {
|
|
217
|
+
transform: scale(0.98);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.legend {
|
|
221
|
+
display: flex;
|
|
222
|
+
flex-direction: column;
|
|
223
|
+
gap: 10px;
|
|
224
|
+
font-size: 0.9rem;
|
|
225
|
+
margin-bottom: 16px;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.legend-item {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 10px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.color-box {
|
|
235
|
+
width: 14px;
|
|
236
|
+
height: 14px;
|
|
237
|
+
border-radius: 4px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.json-viewer {
|
|
241
|
+
background: rgba(15, 23, 42, 0.8);
|
|
242
|
+
border: 1px solid var(--border-color);
|
|
243
|
+
border-radius: 8px;
|
|
244
|
+
padding: 14px;
|
|
245
|
+
font-family: 'Roboto Mono', monospace;
|
|
246
|
+
font-size: 0.82rem;
|
|
247
|
+
color: #38bdf8;
|
|
248
|
+
max-height: 250px;
|
|
249
|
+
overflow-y: auto;
|
|
250
|
+
white-space: pre-wrap;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.loading-overlay {
|
|
254
|
+
position: absolute;
|
|
255
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
256
|
+
background: rgba(15, 23, 42, 0.8);
|
|
257
|
+
backdrop-filter: blur(8px);
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
align-items: center;
|
|
261
|
+
justify-content: center;
|
|
262
|
+
z-index: 10;
|
|
263
|
+
display: none;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.spinner {
|
|
267
|
+
width: 44px;
|
|
268
|
+
height: 44px;
|
|
269
|
+
border: 4px solid rgba(56, 189, 248, 0.2);
|
|
270
|
+
border-left-color: var(--accent-blue);
|
|
271
|
+
border-radius: 50%;
|
|
272
|
+
animation: spin 1s linear infinite;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@keyframes spin {
|
|
276
|
+
to { transform: rotate(360deg); }
|
|
277
|
+
}
|
|
278
|
+
</style>
|
|
279
|
+
</head>
|
|
280
|
+
<body>
|
|
281
|
+
|
|
282
|
+
<header>
|
|
283
|
+
<div class="logo-group">
|
|
284
|
+
<span class="logo-icon">👁️</span>
|
|
285
|
+
<span class="logo-text">VisiFlow Playground</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="status-badge">
|
|
288
|
+
<div class="pulse-dot"></div>
|
|
289
|
+
<span>Local Engine Active</span>
|
|
290
|
+
</div>
|
|
291
|
+
</header>
|
|
292
|
+
|
|
293
|
+
<main>
|
|
294
|
+
<div class="stage-card" id="stage">
|
|
295
|
+
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click();">
|
|
296
|
+
<div class="drop-icon">📸</div>
|
|
297
|
+
<h3>Drop Screenshot Here</h3>
|
|
298
|
+
<p style="color: var(--text-muted); margin-top: 8px; font-size: 0.9rem;">or click to upload a webpage image</p>
|
|
299
|
+
<input type="file" id="file-input" accept="image/*" style="display: none;">
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<div class="canvas-wrapper" id="canvas-wrapper">
|
|
303
|
+
<canvas id="view-canvas"></canvas>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div class="loading-overlay" id="loading">
|
|
307
|
+
<div class="spinner"></div>
|
|
308
|
+
<p style="margin-top: 16px; font-weight: 600; color: var(--accent-blue);">Running YOLO & OCR Detection...</p>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<div class="sidebar">
|
|
313
|
+
<div class="card">
|
|
314
|
+
<div class="card-title">🎯 Natural Language Target Search</div>
|
|
315
|
+
<div class="input-group">
|
|
316
|
+
<input type="text" id="query-input" placeholder="e.g. Submit, Username, 登入">
|
|
317
|
+
<button class="btn-primary" onclick="findTarget()">Match</button>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="legend">
|
|
320
|
+
<div class="legend-item">
|
|
321
|
+
<div class="color-box" style="background: var(--accent-blue);"></div>
|
|
322
|
+
<span>UI Elements (YOLO / OpenCV)</span>
|
|
323
|
+
</div>
|
|
324
|
+
<div class="legend-item">
|
|
325
|
+
<div class="color-box" style="background: var(--accent-emerald);"></div>
|
|
326
|
+
<span>Extracted Text (EasyOCR)</span>
|
|
327
|
+
</div>
|
|
328
|
+
<div class="legend-item">
|
|
329
|
+
<div class="color-box" style="background: var(--accent-pink);"></div>
|
|
330
|
+
<span>Resolved Click Target</span>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<div class="card">
|
|
336
|
+
<div class="card-title">📊 Detection JSON Output</div>
|
|
337
|
+
<div class="json-viewer" id="json-output">// Upload a screenshot to see bounding box coordinates</div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</main>
|
|
341
|
+
|
|
342
|
+
<script>
|
|
343
|
+
let currentImage = null;
|
|
344
|
+
let detectionData = null;
|
|
345
|
+
let matchedCoords = null;
|
|
346
|
+
|
|
347
|
+
const dropZone = document.getElementById('drop-zone');
|
|
348
|
+
const fileInput = document.getElementById('file-input');
|
|
349
|
+
const canvasWrapper = document.getElementById('canvas-wrapper');
|
|
350
|
+
const canvas = document.getElementById('view-canvas');
|
|
351
|
+
const ctx = canvas.getContext('2d');
|
|
352
|
+
const loading = document.getElementById('loading');
|
|
353
|
+
const jsonOutput = document.getElementById('json-output');
|
|
354
|
+
|
|
355
|
+
// Drag & Drop handlers
|
|
356
|
+
['dragenter', 'dragover'].forEach(name => {
|
|
357
|
+
dropZone.addEventListener(name, (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
|
358
|
+
});
|
|
359
|
+
['dragleave', 'drop'].forEach(name => {
|
|
360
|
+
dropZone.addEventListener(name, (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
|
|
361
|
+
});
|
|
362
|
+
dropZone.addEventListener('drop', (e) => {
|
|
363
|
+
const files = e.dataTransfer.files;
|
|
364
|
+
if (files.length) handleFile(files[0]);
|
|
365
|
+
});
|
|
366
|
+
fileInput.addEventListener('change', (e) => {
|
|
367
|
+
if (e.target.files.length) handleFile(e.target.files[0]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
function handleFile(file) {
|
|
371
|
+
const reader = new FileReader();
|
|
372
|
+
reader.onload = (e) => {
|
|
373
|
+
const img = new Image();
|
|
374
|
+
img.onload = () => {
|
|
375
|
+
currentImage = img;
|
|
376
|
+
runDetection(e.target.result);
|
|
377
|
+
};
|
|
378
|
+
img.src = e.target.result;
|
|
379
|
+
};
|
|
380
|
+
reader.readAsDataURL(file);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function runDetection(base64Data) {
|
|
384
|
+
loading.style.display = 'flex';
|
|
385
|
+
matchedCoords = null;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const formData = new FormData();
|
|
389
|
+
formData.append('image_base64', base64Data);
|
|
390
|
+
|
|
391
|
+
const resp = await fetch('/detect', { method: 'POST', body: formData });
|
|
392
|
+
detectionData = await resp.json();
|
|
393
|
+
|
|
394
|
+
jsonOutput.textContent = JSON.stringify(detectionData, null, 2);
|
|
395
|
+
render();
|
|
396
|
+
dropZone.style.display = 'none';
|
|
397
|
+
canvasWrapper.style.display = 'block';
|
|
398
|
+
} catch (err) {
|
|
399
|
+
alert('Detection failed: ' + err.message);
|
|
400
|
+
} finally {
|
|
401
|
+
loading.style.display = 'none';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function findTarget() {
|
|
406
|
+
const query = document.getElementById('query-input').value.trim();
|
|
407
|
+
if (!query || !currentImage) return;
|
|
408
|
+
|
|
409
|
+
loading.style.display = 'flex';
|
|
410
|
+
try {
|
|
411
|
+
const formData = new FormData();
|
|
412
|
+
formData.append('image_base64', canvas.toDataURL());
|
|
413
|
+
formData.append('query', query);
|
|
414
|
+
|
|
415
|
+
const resp = await fetch('/match', { method: 'POST', body: formData });
|
|
416
|
+
const result = await resp.json();
|
|
417
|
+
|
|
418
|
+
if (result.found) {
|
|
419
|
+
matchedCoords = { x: result.x, y: result.y, query: query };
|
|
420
|
+
jsonOutput.textContent = JSON.stringify(result, null, 2);
|
|
421
|
+
} else {
|
|
422
|
+
matchedCoords = null;
|
|
423
|
+
alert(`No visual element found for query "${query}"`);
|
|
424
|
+
}
|
|
425
|
+
render();
|
|
426
|
+
} catch (err) {
|
|
427
|
+
alert('Match failed: ' + err.message);
|
|
428
|
+
} finally {
|
|
429
|
+
loading.style.display = 'none';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function render() {
|
|
434
|
+
if (!currentImage) return;
|
|
435
|
+
|
|
436
|
+
canvas.width = currentImage.width;
|
|
437
|
+
canvas.height = currentImage.height;
|
|
438
|
+
|
|
439
|
+
// Draw original screenshot
|
|
440
|
+
ctx.drawImage(currentImage, 0, 0);
|
|
441
|
+
|
|
442
|
+
if (!detectionData) return;
|
|
443
|
+
|
|
444
|
+
// 1. Draw UI element bounding boxes (Cyan)
|
|
445
|
+
if (detectionData.elements) {
|
|
446
|
+
ctx.lineWidth = 2;
|
|
447
|
+
ctx.strokeStyle = '#38bdf8';
|
|
448
|
+
ctx.fillStyle = 'rgba(56, 189, 248, 0.15)';
|
|
449
|
+
|
|
450
|
+
detectionData.elements.forEach(elem => {
|
|
451
|
+
const [x1, y1, x2, y2] = elem.box;
|
|
452
|
+
const w = x2 - x1;
|
|
453
|
+
const h = y2 - y1;
|
|
454
|
+
ctx.fillRect(x1, y1, w, h);
|
|
455
|
+
ctx.strokeRect(x1, y1, w, h);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 2. Draw OCR bounding boxes & texts (Emerald)
|
|
460
|
+
if (detectionData.ocr) {
|
|
461
|
+
ctx.lineWidth = 1;
|
|
462
|
+
ctx.strokeStyle = '#34d399';
|
|
463
|
+
ctx.fillStyle = 'rgba(52, 211, 153, 0.8)';
|
|
464
|
+
ctx.font = '14px Outfit, sans-serif';
|
|
465
|
+
|
|
466
|
+
detectionData.ocr.forEach(item => {
|
|
467
|
+
const [x1, y1, x2, y2] = item.box;
|
|
468
|
+
const w = x2 - x1;
|
|
469
|
+
const h = y2 - y1;
|
|
470
|
+
ctx.strokeRect(x1, y1, w, h);
|
|
471
|
+
ctx.fillText(item.text, x1, Math.max(12, y1 - 4));
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 3. Highlight matched target (Pink Circle & Crosshair)
|
|
476
|
+
if (matchedCoords) {
|
|
477
|
+
const { x, y } = matchedCoords;
|
|
478
|
+
|
|
479
|
+
ctx.beginPath();
|
|
480
|
+
ctx.arc(x, y, 18, 0, 2 * Math.PI);
|
|
481
|
+
ctx.fillStyle = 'rgba(244, 63, 94, 0.4)';
|
|
482
|
+
ctx.fill();
|
|
483
|
+
ctx.lineWidth = 3;
|
|
484
|
+
ctx.strokeStyle = '#f43f5e';
|
|
485
|
+
ctx.stroke();
|
|
486
|
+
|
|
487
|
+
// Crosshair
|
|
488
|
+
ctx.beginPath();
|
|
489
|
+
ctx.moveTo(x - 8, y); ctx.lineTo(x + 8, y);
|
|
490
|
+
ctx.moveTo(x, y - 8); ctx.lineTo(x, y + 8);
|
|
491
|
+
ctx.strokeStyle = '#ffffff';
|
|
492
|
+
ctx.lineWidth = 2;
|
|
493
|
+
ctx.stroke();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
</script>
|
|
497
|
+
</body>
|
|
498
|
+
</html>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: visiflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A fast, local, visual-driven E2E automation testing plugin for Playwright and Selenium powered by YOLO and OCR.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yourusername/VisiFlow
|
|
6
|
+
Project-URL: Issues, https://github.com/yourusername/VisiFlow/issues
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
12
|
+
Classifier: Topic :: Software Development :: Testing
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Requires-Dist: easyocr>=1.7.0
|
|
15
|
+
Requires-Dist: numpy>=1.20.0
|
|
16
|
+
Requires-Dist: opencv-python-headless>=4.0.0
|
|
17
|
+
Requires-Dist: pillow>=9.0.0
|
|
18
|
+
Requires-Dist: ultralytics>=8.0.0
|
|
19
|
+
Provides-Extra: playwright
|
|
20
|
+
Requires-Dist: playwright>=1.30.0; extra == 'playwright'
|
|
21
|
+
Provides-Extra: selenium
|
|
22
|
+
Requires-Dist: selenium>=4.0.0; extra == 'selenium'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# 👁️ VisiFlow
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/visiflow/)
|
|
28
|
+
[](https://www.npmjs.com/package/visiflow-js)
|
|
29
|
+
[](https://opensource.org/licenses/MIT)
|
|
30
|
+
[]()
|
|
31
|
+
|
|
32
|
+
**VisiFlow** is a fast, 100% local, visual-driven E2E automation testing framework for **Python** and **Node.js (TypeScript/JavaScript)** ecosystem, built for **Playwright** and **Selenium**.
|
|
33
|
+
|
|
34
|
+
It challenges traditional web automation by throwing away fragile HTML DOM selectors (XPath, IDs, CSS classes) and replacing them with a local computer vision pipeline (YOLO + EasyOCR) to locate and interact with elements exactly how a human does.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## ⚡ Comparison: Why VisiFlow?
|
|
41
|
+
|
|
42
|
+
| Metric / Feature | Traditional DOM Selectors (XPath/CSS) | Cloud AI Automation (GPT-4o Vision API) | **VisiFlow (Our Local Core)** |
|
|
43
|
+
| :--- | :--- | :--- | :--- |
|
|
44
|
+
| **Maintenance Cost** | 🔴 Extremely High (Breaks on CSS/ID refactoring) | 🟢 Extremely Low (Zero selector maintenance) | 🟢 **Zero Maintenance (Visual-driven)** |
|
|
45
|
+
| **Execution Cost** | 🟢 $0 / Free | 🔴 Expensive ($0.01+ per screenshot query) | 🟢 **$0 / 100% Free Local Execution** |
|
|
46
|
+
| **Latency** | 🟢 < 10ms | 🔴 1.5s - 4.0s (Cloud latency & queues) | 🟡 **100ms - 250ms (Sub-second local CV)** |
|
|
47
|
+
| **Data Privacy** | 🟢 100% On-Premise | 🔴 Privacy Risks (Sends UI to external servers) | 🟢 **100% On-Premise & Private (Offline)** |
|
|
48
|
+
| **Ecosystem Support**| Python, JS/TS, Java | Vendor locked APIs | 🟢 **Python & Node.js (Playwright + Selenium)** |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 📦 Installation
|
|
53
|
+
|
|
54
|
+
### Python Package (PyPI)
|
|
55
|
+
```bash
|
|
56
|
+
pip install "visiflow[playwright,selenium]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Node.js Package (npm)
|
|
60
|
+
```bash
|
|
61
|
+
npm install visiflow-js
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 💻 Quick Start
|
|
67
|
+
|
|
68
|
+
### 1. Python + Playwright
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from playwright.sync_api import sync_playwright
|
|
72
|
+
from visiflow import VisiPlaywrightPage
|
|
73
|
+
|
|
74
|
+
with sync_playwright() as p:
|
|
75
|
+
browser = p.chromium.launch()
|
|
76
|
+
page = browser.new_page()
|
|
77
|
+
page.goto("https://example.com/login")
|
|
78
|
+
|
|
79
|
+
# Wrap Playwright Page
|
|
80
|
+
visipage = VisiPlaywrightPage(page)
|
|
81
|
+
|
|
82
|
+
# Visual automation via natural language text labels!
|
|
83
|
+
visipage.visual_fill("Username", "admin_user")
|
|
84
|
+
visipage.visual_fill("Password", "secret_pass")
|
|
85
|
+
visipage.visual_click("Submit")
|
|
86
|
+
|
|
87
|
+
# Verify visually
|
|
88
|
+
if visipage.visual_wait_for("Welcome back!"):
|
|
89
|
+
print("Logged in successfully!")
|
|
90
|
+
|
|
91
|
+
browser.close()
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 2. Node.js (JavaScript / TypeScript) + Playwright
|
|
95
|
+
|
|
96
|
+
First, start the local VisiFlow daemon server in your terminal:
|
|
97
|
+
```bash
|
|
98
|
+
visiflow server
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then in your JavaScript/TypeScript Playwright script:
|
|
102
|
+
```javascript
|
|
103
|
+
const { chromium } = require('playwright');
|
|
104
|
+
const { VisiPage } = require('visiflow-js');
|
|
105
|
+
|
|
106
|
+
(async () => {
|
|
107
|
+
const browser = await chromium.launch();
|
|
108
|
+
const page = await browser.newPage();
|
|
109
|
+
await page.goto('https://example.com/login');
|
|
110
|
+
|
|
111
|
+
// Wrap JS Playwright Page
|
|
112
|
+
const visipage = new VisiPage(page);
|
|
113
|
+
|
|
114
|
+
// Visual actions in JS!
|
|
115
|
+
await visipage.visualFill('Username', 'js_developer');
|
|
116
|
+
await visipage.visualClick('Submit');
|
|
117
|
+
|
|
118
|
+
await browser.close();
|
|
119
|
+
})();
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 🎨 Interactive Web Playground
|
|
125
|
+
|
|
126
|
+
Want to test VisiFlow's object detection & text matching on your webpage screenshots before writing tests? Launch the built-in interactive Web UI:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
visiflow ui
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This opens `http://localhost:8000/ui` in your browser, where you can drag & drop any screenshot image to inspect bounding boxes and test queries live in real-time!
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 🛠️ CLI Reference
|
|
137
|
+
|
|
138
|
+
VisiFlow comes with a CLI tool:
|
|
139
|
+
|
|
140
|
+
- `visiflow server [--port 8000]`: Start the local HTTP vision daemon.
|
|
141
|
+
- `visiflow ui`: Launch the interactive Web Playground UI in your browser.
|
|
142
|
+
- `visiflow match <screenshot_path> <query>`: Test matching query coordinates directly from terminal.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 🛡️ License
|
|
147
|
+
|
|
148
|
+
MIT License. Built for global open-source developers.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
visiflow/__init__.py,sha256=F9m_CkUjsAXyqG7V2YRhcpaq2_sO18jDsu5WVXQWwJM,195
|
|
2
|
+
visiflow/cli.py,sha256=QmTO8-gw6c74gAIygQ5LBFonmtx-YTsJWPmOgfFK9qo,2471
|
|
3
|
+
visiflow/core.py,sha256=YssBCs6K9wauMzFjNa0kRtvc7T_8GKKqJ6wrr4TVQZc,10642
|
|
4
|
+
visiflow/playwright.py,sha256=Cu__uXw25enii5y2uDj8X4V4BzAiqV52Um8zPmdAmOk,4789
|
|
5
|
+
visiflow/selenium.py,sha256=LreLPeVB55BRFf-dWeq1vTzYmArFWfUrhAlYwo0_wfA,5715
|
|
6
|
+
visiflow/server.py,sha256=PRVYBoNGUzVKREdAxfsFd4tkEGD2V5XSMq6EvSa2-_8,4289
|
|
7
|
+
visiflow/static/index.html,sha256=e7A1q4RcYmzuHiak01Ib8iC5AVdRO0Y1WJfASPUZX1w,16185
|
|
8
|
+
visiflow-0.1.0.dist-info/METADATA,sha256=K8Bokk93mRYt87GmLRUxqfwwRWStR522l27Nf8BYgeo,5151
|
|
9
|
+
visiflow-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
visiflow-0.1.0.dist-info/entry_points.txt,sha256=K9q5AaqAnGzOS7pDOSnzHsGaAEpddofGtUalTyKCCZI,47
|
|
11
|
+
visiflow-0.1.0.dist-info/RECORD,,
|