silk-scraper 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.
- silk/__init__.py +14 -0
- silk/actions/__init__.py +48 -0
- silk/actions/base.py +419 -0
- silk/actions/composition.py +347 -0
- silk/actions/context.py +268 -0
- silk/actions/decorators.py +246 -0
- silk/actions/extraction.py +654 -0
- silk/actions/flow.py +458 -0
- silk/actions/input.py +827 -0
- silk/actions/navigation.py +470 -0
- silk/actions/py.typed +0 -0
- silk/browsers/__init__.py +18 -0
- silk/browsers/context.py +310 -0
- silk/browsers/driver.py +716 -0
- silk/browsers/driver_factory.py +74 -0
- silk/browsers/drivers/playwright.py +1378 -0
- silk/browsers/element.py +433 -0
- silk/browsers/manager.py +262 -0
- silk/browsers/py.typed +0 -0
- silk/models/actions.py +0 -0
- silk/models/browser.py +252 -0
- silk/py.typed +0 -0
- silk/selectors/__init__.py +7 -0
- silk/selectors/py.typed +0 -0
- silk/selectors/selector.py +143 -0
- silk_scraper-0.1.0.dist-info/LICENSE +19 -0
- silk_scraper-0.1.0.dist-info/METADATA +647 -0
- silk_scraper-0.1.0.dist-info/RECORD +29 -0
- silk_scraper-0.1.0.dist-info/WHEEL +4 -0
silk/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Silk - A flexible browser automation library
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from expression import Ok, Error, Result, Option, Some, Nothing
|
|
8
|
+
|
|
9
|
+
from . import actions
|
|
10
|
+
from . import browsers
|
|
11
|
+
from . import models
|
|
12
|
+
from . import selectors
|
|
13
|
+
|
|
14
|
+
from .actions.decorators import action
|
silk/actions/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Actions are the building blocks of Silk.
|
|
3
|
+
|
|
4
|
+
They represent pure operations that can be composed together using functional
|
|
5
|
+
programming patterns. Each action returns a Result type that handles both success
|
|
6
|
+
and error cases.
|
|
7
|
+
|
|
8
|
+
Silk provides several ways to compose actions:
|
|
9
|
+
|
|
10
|
+
- sequence: Executes actions in sequence and returns ALL results as a Block
|
|
11
|
+
- compose: Executes actions in sequence and returns ONLY the LAST result
|
|
12
|
+
- pipe: Creates a pipeline where each action can use the result of the previous action
|
|
13
|
+
- parallel: Executes actions concurrently and collects all results
|
|
14
|
+
- fallback: Tries actions in sequence until one succeeds
|
|
15
|
+
|
|
16
|
+
These can also be used through operators:
|
|
17
|
+
- a >> b: Equivalent to compose(a, b)
|
|
18
|
+
- a | b: Equivalent to fallback(a, b)
|
|
19
|
+
- a & b: Similar to parallel(a, b) but returns a tuple of results
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from silk.actions.base import Action
|
|
23
|
+
from silk.actions.composition import compose, fallback, parallel, pipe, sequence
|
|
24
|
+
from silk.actions.flow import (
|
|
25
|
+
branch,
|
|
26
|
+
loop_until,
|
|
27
|
+
retry,
|
|
28
|
+
retry_with_backoff,
|
|
29
|
+
tap,
|
|
30
|
+
with_timeout,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Action",
|
|
35
|
+
# Control
|
|
36
|
+
"branch",
|
|
37
|
+
"loop_until",
|
|
38
|
+
"retry_with_backoff",
|
|
39
|
+
"with_timeout",
|
|
40
|
+
"tap",
|
|
41
|
+
"retry",
|
|
42
|
+
# Composition
|
|
43
|
+
"sequence",
|
|
44
|
+
"parallel",
|
|
45
|
+
"pipe",
|
|
46
|
+
"fallback",
|
|
47
|
+
"compose",
|
|
48
|
+
]
|
silk/actions/base.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import (
|
|
5
|
+
TYPE_CHECKING,
|
|
6
|
+
Any,
|
|
7
|
+
Awaitable,
|
|
8
|
+
Callable,
|
|
9
|
+
Generic,
|
|
10
|
+
Optional,
|
|
11
|
+
ParamSpec,
|
|
12
|
+
Tuple,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Union,
|
|
15
|
+
cast,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from expression.core import Error, Result
|
|
19
|
+
|
|
20
|
+
from silk.models.browser import ActionContext
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from silk.browsers.manager import BrowserManager
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
S = TypeVar("S")
|
|
27
|
+
R = TypeVar("R")
|
|
28
|
+
|
|
29
|
+
P = ParamSpec("P")
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Action(ABC, Generic[T]):
|
|
35
|
+
"""
|
|
36
|
+
Base class for all actions that can be performed in a browser
|
|
37
|
+
|
|
38
|
+
An Action represents a pure operation that can be composed with
|
|
39
|
+
other actions using functional programming patterns.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def execute(self, context: ActionContext) -> Result[T, Exception]:
|
|
44
|
+
"""
|
|
45
|
+
Execute the action using the given context
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
context: Execution context with references to browser and page
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Result containing either the action result or an exception
|
|
52
|
+
"""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
# todo we need a operation that runs a action on a list of items similar to airflow mapped tasks
|
|
56
|
+
# todo improve the doc strings, explaination and examples
|
|
57
|
+
def map(self, f: Callable[[T], S]) -> "Action[S]":
|
|
58
|
+
"""
|
|
59
|
+
Create a new action that maps the result of this action
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
f: Function to transform the result
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A new Action with transformed result
|
|
66
|
+
"""
|
|
67
|
+
original_action = self
|
|
68
|
+
|
|
69
|
+
class MappedAction(Action[S]):
|
|
70
|
+
async def execute(self, context: ActionContext) -> Result[S, Exception]:
|
|
71
|
+
result = await original_action.execute(context)
|
|
72
|
+
try:
|
|
73
|
+
return result.map(f)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return Error(e)
|
|
76
|
+
|
|
77
|
+
return MappedAction()
|
|
78
|
+
|
|
79
|
+
# todo we meed to fix this and_then should pass the context to the next action
|
|
80
|
+
# it should be similar to celery task chaining
|
|
81
|
+
# maybe we use a simlar signature modifier like action.s(immutable_value) ?
|
|
82
|
+
# or action("input",forward_value) or action(accept_value)
|
|
83
|
+
def and_then(self, f: Callable[[Optional[T]], "Action[S]"]) -> "Action[S]":
|
|
84
|
+
"""
|
|
85
|
+
Chain an action after this one, using the result of this action
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
f: Function that takes the result of this action and returns a new action
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A new Action that chains the two actions
|
|
92
|
+
"""
|
|
93
|
+
original_action = self
|
|
94
|
+
|
|
95
|
+
class ChainedAction(Action[S]):
|
|
96
|
+
async def execute(self, context: ActionContext) -> Result[S, Exception]:
|
|
97
|
+
try:
|
|
98
|
+
result = await original_action.execute(context)
|
|
99
|
+
|
|
100
|
+
if result.is_error():
|
|
101
|
+
return cast(Result[S, Exception], result)
|
|
102
|
+
|
|
103
|
+
value = result.default_value(None)
|
|
104
|
+
# Create the next action regardless of whether value is None
|
|
105
|
+
# The function f may or may not use the value
|
|
106
|
+
next_action = f(value)
|
|
107
|
+
|
|
108
|
+
return await next_action.execute(context)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return Error(e)
|
|
111
|
+
|
|
112
|
+
return ChainedAction()
|
|
113
|
+
|
|
114
|
+
def then(self, next_action: "Action[S]") -> "Action[S]":
|
|
115
|
+
"""
|
|
116
|
+
Chain an action after this one, ignoring the result of this action
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
next_action: Action to execute after this one completes
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A new Action that chains the two actions sequentially
|
|
123
|
+
"""
|
|
124
|
+
original_action = self
|
|
125
|
+
|
|
126
|
+
class SequentialAction(Action[S]):
|
|
127
|
+
async def execute(self, context: ActionContext) -> Result[S, Exception]:
|
|
128
|
+
try:
|
|
129
|
+
result = await original_action.execute(context)
|
|
130
|
+
|
|
131
|
+
if result.is_error():
|
|
132
|
+
return cast(Result[S, Exception], result)
|
|
133
|
+
|
|
134
|
+
# Ignore the value and execute the next action
|
|
135
|
+
return await next_action.execute(context)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
return Error(e)
|
|
138
|
+
|
|
139
|
+
return SequentialAction()
|
|
140
|
+
|
|
141
|
+
def retry(self, max_attempts: int = 3, delay_ms: int = 1000) -> "Action[T]":
|
|
142
|
+
"""
|
|
143
|
+
Create a new action that retries this action multiple times until it succeeds
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
max_attempts: Maximum number of retry attempts
|
|
147
|
+
delay_ms: Delay between retries in milliseconds
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A new Action with retry logic
|
|
151
|
+
"""
|
|
152
|
+
original_action = self
|
|
153
|
+
|
|
154
|
+
class RetryAction(Action[T]):
|
|
155
|
+
async def execute(self, context: ActionContext) -> Result[T, Exception]:
|
|
156
|
+
retry_context = context.derive(
|
|
157
|
+
max_retries=max_attempts, retry_delay_ms=delay_ms
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
last_error = None
|
|
161
|
+
|
|
162
|
+
for attempt in range(max_attempts):
|
|
163
|
+
attempt_context = retry_context.derive(retry_count=attempt)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = await original_action.execute(attempt_context)
|
|
167
|
+
if result.is_ok():
|
|
168
|
+
return result
|
|
169
|
+
last_error = result.error if hasattr(result, "error") else None
|
|
170
|
+
except Exception as e:
|
|
171
|
+
last_error = e
|
|
172
|
+
|
|
173
|
+
if attempt < max_attempts - 1:
|
|
174
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
175
|
+
|
|
176
|
+
return Error(
|
|
177
|
+
last_error or Exception(f"All {max_attempts} attempts failed")
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return RetryAction()
|
|
181
|
+
|
|
182
|
+
def with_timeout(self, timeout_ms: int) -> "Action[T]":
|
|
183
|
+
"""
|
|
184
|
+
Create a new action that times out after the specified duration
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
timeout_ms: Timeout in milliseconds
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
A new Action with timeout logic
|
|
191
|
+
"""
|
|
192
|
+
original_action = self
|
|
193
|
+
|
|
194
|
+
class TimeoutAction(Action[T]):
|
|
195
|
+
async def execute(self, context: ActionContext) -> Result[T, Exception]:
|
|
196
|
+
timeout_context = context.derive(timeout_ms=timeout_ms)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
result = await asyncio.wait_for(
|
|
200
|
+
# todo this might block the event loop and prevent
|
|
201
|
+
# the browser from doing anything else in some driver implementations
|
|
202
|
+
original_action.execute(timeout_context),
|
|
203
|
+
timeout=timeout_ms / 1000,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return result
|
|
207
|
+
except asyncio.TimeoutError:
|
|
208
|
+
return Error(Exception(f"Operation timed out after {timeout_ms}ms"))
|
|
209
|
+
except Exception as e:
|
|
210
|
+
return Error(e)
|
|
211
|
+
|
|
212
|
+
return TimeoutAction()
|
|
213
|
+
|
|
214
|
+
def __rshift__(
|
|
215
|
+
self, other: Union[Callable[[T], S], "Action[Any]"]
|
|
216
|
+
) -> "Action[Any]":
|
|
217
|
+
"""
|
|
218
|
+
Overload the >> operator for pipe-like sequencing
|
|
219
|
+
|
|
220
|
+
a >> b is equivalent to:
|
|
221
|
+
- If b is a function: a.map(b)
|
|
222
|
+
- If b is an Action: a.and_then(lambda _: b)
|
|
223
|
+
"""
|
|
224
|
+
if isinstance(other, Action):
|
|
225
|
+
return self.then(other)
|
|
226
|
+
else:
|
|
227
|
+
return self.map(other)
|
|
228
|
+
|
|
229
|
+
def __or__(self, other: "Action[S]") -> "Action[Union[T, S]]":
|
|
230
|
+
"""
|
|
231
|
+
Overload the | operator for fallback behavior
|
|
232
|
+
|
|
233
|
+
a | b means "try action a, if it fails, try action b"
|
|
234
|
+
"""
|
|
235
|
+
first_action = self
|
|
236
|
+
second_action = other
|
|
237
|
+
|
|
238
|
+
class FallbackAction(Action[Union[T, S]]):
|
|
239
|
+
async def execute(
|
|
240
|
+
self, context: ActionContext
|
|
241
|
+
) -> Result[Union[T, S], Exception]:
|
|
242
|
+
try:
|
|
243
|
+
fallback_context = context.derive(
|
|
244
|
+
metadata={"fallback_operation": "trying_first"}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
result = await first_action.execute(fallback_context)
|
|
248
|
+
if result.is_ok():
|
|
249
|
+
return result
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
fallback_context = context.derive(
|
|
254
|
+
metadata={"fallback_operation": "trying_second"}
|
|
255
|
+
)
|
|
256
|
+
return await second_action.execute(fallback_context)
|
|
257
|
+
|
|
258
|
+
return FallbackAction()
|
|
259
|
+
|
|
260
|
+
# todo what should we retyurn
|
|
261
|
+
# todo max number of parallel actions config
|
|
262
|
+
def __and__(self, other: "Action[S]") -> "Action[Tuple[Optional[T], Optional[S]]]":
|
|
263
|
+
"""
|
|
264
|
+
Overload the & operator for parallel execution
|
|
265
|
+
|
|
266
|
+
a & b means "execute actions a and b in parallel with separate contexts"
|
|
267
|
+
"""
|
|
268
|
+
from expression.core import Ok # Make sure Ok is imported
|
|
269
|
+
|
|
270
|
+
first_action = self
|
|
271
|
+
second_action = other
|
|
272
|
+
|
|
273
|
+
class ParallelAction(Action[Tuple[Optional[T], Optional[S]]]):
|
|
274
|
+
async def execute(
|
|
275
|
+
self, context: ActionContext
|
|
276
|
+
) -> Result[Tuple[Optional[T], Optional[S]], Exception]:
|
|
277
|
+
if not context.browser_manager:
|
|
278
|
+
return Error(
|
|
279
|
+
Exception(
|
|
280
|
+
"Cannot execute parallel actions without a browser manager"
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
first_context_id, second_context_id = None, None
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
context_result1 = await context.browser_manager.create_context()
|
|
288
|
+
if context_result1.is_error():
|
|
289
|
+
return Error(context_result1.error)
|
|
290
|
+
browser_context1 = context_result1.default_value(None)
|
|
291
|
+
if browser_context1 is None:
|
|
292
|
+
return Error(Exception("Failed to create first context"))
|
|
293
|
+
first_context_id = browser_context1.id
|
|
294
|
+
|
|
295
|
+
page_result1 = browser_context1.get_page()
|
|
296
|
+
if page_result1.is_error():
|
|
297
|
+
return Error(page_result1.error)
|
|
298
|
+
page1 = page_result1.default_value(None)
|
|
299
|
+
if page1 is None:
|
|
300
|
+
return Error(Exception("Failed to get first page"))
|
|
301
|
+
|
|
302
|
+
action_context1 = context.derive(
|
|
303
|
+
context_id=browser_context1.id,
|
|
304
|
+
page_id=page1.id,
|
|
305
|
+
metadata={"parallel_execution": "first_action"},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
context_result2 = await context.browser_manager.create_context()
|
|
309
|
+
if context_result2.is_error():
|
|
310
|
+
return Error(context_result2.error)
|
|
311
|
+
browser_context2 = context_result2.default_value(None)
|
|
312
|
+
if browser_context2 is None:
|
|
313
|
+
return Error(Exception("Failed to create second context"))
|
|
314
|
+
second_context_id = browser_context2.id
|
|
315
|
+
|
|
316
|
+
page_result2 = browser_context2.get_page()
|
|
317
|
+
if page_result2.is_error():
|
|
318
|
+
return Error(page_result2.error)
|
|
319
|
+
page2 = page_result2.default_value(None)
|
|
320
|
+
if page2 is None:
|
|
321
|
+
return Error(Exception("Failed to get second page"))
|
|
322
|
+
|
|
323
|
+
action_context2 = context.derive(
|
|
324
|
+
context_id=browser_context2.id,
|
|
325
|
+
page_id=page2.id,
|
|
326
|
+
metadata={"parallel_execution": "second_action"},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Execute both actions in parallel
|
|
330
|
+
results = await asyncio.gather(
|
|
331
|
+
first_action.execute(action_context1),
|
|
332
|
+
second_action.execute(action_context2),
|
|
333
|
+
return_exceptions=False,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
result1, result2 = results[0], results[1]
|
|
337
|
+
|
|
338
|
+
if result1.is_error():
|
|
339
|
+
return Error(result1.error)
|
|
340
|
+
if result2.is_error():
|
|
341
|
+
return Error(result2.error)
|
|
342
|
+
|
|
343
|
+
# Extract values from successful results and return a tuple
|
|
344
|
+
# successful tasks might return None
|
|
345
|
+
value1 = result1.default_value(None)
|
|
346
|
+
value2 = result2.default_value(None)
|
|
347
|
+
return Ok((value1, value2))
|
|
348
|
+
except Exception as e:
|
|
349
|
+
return Error(e)
|
|
350
|
+
finally:
|
|
351
|
+
if first_context_id and context.browser_manager:
|
|
352
|
+
await context.browser_manager.close_context(first_context_id)
|
|
353
|
+
if second_context_id and context.browser_manager:
|
|
354
|
+
await context.browser_manager.close_context(second_context_id)
|
|
355
|
+
|
|
356
|
+
# Make sure to return an instance of the ParallelAction class
|
|
357
|
+
return ParallelAction()
|
|
358
|
+
|
|
359
|
+
def __call__(
|
|
360
|
+
self, browser_manager: "BrowserManager"
|
|
361
|
+
) -> Awaitable[Result[T, Exception]]:
|
|
362
|
+
"""
|
|
363
|
+
Make Action instances callable directly with a browser manager
|
|
364
|
+
|
|
365
|
+
This allows using actions like: result = await action(browser_manager)
|
|
366
|
+
"""
|
|
367
|
+
return self.execute_with_manager(browser_manager)
|
|
368
|
+
|
|
369
|
+
async def execute_with_manager(
|
|
370
|
+
self, browser_manager: "BrowserManager"
|
|
371
|
+
) -> Result[T, Exception]:
|
|
372
|
+
"""Execute this action using a BrowserManager to create a new context"""
|
|
373
|
+
context_result = await browser_manager.create_context()
|
|
374
|
+
if context_result.is_error():
|
|
375
|
+
return Error(context_result.error)
|
|
376
|
+
browser_context = context_result.default_value(None)
|
|
377
|
+
if browser_context is None:
|
|
378
|
+
return Error(Exception("Failed to create context"))
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
page_result = browser_context.get_page()
|
|
382
|
+
if page_result.is_error():
|
|
383
|
+
return Error(page_result.error)
|
|
384
|
+
page = page_result.default_value(None)
|
|
385
|
+
if page is None:
|
|
386
|
+
return Error(Exception("Failed to get page"))
|
|
387
|
+
|
|
388
|
+
action_context = ActionContext(
|
|
389
|
+
browser_manager=browser_manager,
|
|
390
|
+
context_id=browser_context.id,
|
|
391
|
+
page_id=page.id,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return await self.execute(action_context)
|
|
395
|
+
finally:
|
|
396
|
+
await browser_manager.close_context(browser_context.id)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def create_action(
|
|
400
|
+
func: Callable[[ActionContext], Awaitable[Result[T, Exception]]],
|
|
401
|
+
) -> Action[T]:
|
|
402
|
+
"""
|
|
403
|
+
Create an action from a function
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
func: Function that takes a context and returns a Result
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
An Action that wraps the function
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
class FunctionalAction(Action[T]):
|
|
413
|
+
async def execute(self, context: ActionContext) -> Result[T, Exception]:
|
|
414
|
+
try:
|
|
415
|
+
return await func(context)
|
|
416
|
+
except Exception as e:
|
|
417
|
+
return Error(e)
|
|
418
|
+
|
|
419
|
+
return FunctionalAction()
|