Workflow Fixes (#156)
This commit is contained in:
@@ -43,7 +43,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# S3 bucket settings
|
||||
AWS_REGION: str = "us-east-1"
|
||||
AWS_S3_BUCKET_DOWNLOADS: str = "skyvern-downloads"
|
||||
AWS_S3_BUCKET_UPLOADS: str = "skyvern-uploads"
|
||||
|
||||
SKYVERN_TELEMETRY: bool = True
|
||||
ANALYTICS_ID: str = "anonymous"
|
||||
@@ -54,6 +54,9 @@ class Settings(BaseSettings):
|
||||
BROWSER_WIDTH: int = 1920
|
||||
BROWSER_HEIGHT: int = 1080
|
||||
|
||||
# Workflow constant parameters
|
||||
WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY: str = "SKYVERN_DOWNLOAD_DIRECTORY"
|
||||
|
||||
#####################
|
||||
# LLM Configuration #
|
||||
#####################
|
||||
|
||||
@@ -16,10 +16,11 @@ Reply in JSON format with the following keys:
|
||||
[{
|
||||
"reasoning": str, // The reasoning behind the action. Be specific, referencing any user information and their fields and element ids in your reasoning. Mention why you chose the action type, and why you chose the element id. Keep the reasoning short and to the point.
|
||||
"confidence_float": float, // The confidence of the action. Pick a number between 0.0 and 1.0. 0.0 means no confidence, 1.0 means full confidence
|
||||
"action_type": str, // It's a string enum: "CLICK", "INPUT_TEXT", "UPLOAD_FILE", "SELECT_OPTION", "WAIT", "SOLVE_CAPTCHA", "COMPLETE", "TERMINATE". "CLICK" is an element you'd like to click. "INPUT_TEXT" is an element you'd like to input text into. "UPLOAD_FILE" is an element you'd like to upload a file into. "SELECT_OPTION" is an element you'd like to select an option from. "WAIT" action should be used if there are no actions to take and there is some indication on screen that waiting could yield more actions. "WAIT" should not be used if there are actions to take. "SOLVE_CAPTCHA" should be used if there's a captcha to solve on the screen. "COMPLETE" is used when the user goal has been achieved AND if there's any data extraction goal, you should be able to get data from the page. If there is any other action to take, do not add "COMPLETE" type at all. "TERMINATE" is used to terminate the whole task with a failure when it doesn't seem like the user goal can be achieved. Do not use "TERMINATE" if waiting could lead the user towards the goal. Only return "TERMINATE" if you are on a page where the user goal cannot be achieved. All other actions are ignored when "TERMINATE" is returned.
|
||||
"action_type": str, // It's a string enum: "CLICK", "INPUT_TEXT", "UPLOAD_FILE", "DOWNLOAD_FILE", "SELECT_OPTION", "WAIT", "SOLVE_CAPTCHA", "COMPLETE", "TERMINATE". "CLICK" is an element you'd like to click. "INPUT_TEXT" is an element you'd like to input text into. "UPLOAD_FILE" is an element you'd like to upload a file into. "DOWNLOAD_FILE" is an element you'd like to download a file from, and the file will be saved with the name provided in the "file_name" field. You can download multiple files in one action by returning multiple "DOWNLOAD_FILE" actions in one step. The "file_name" field should be unique for each file download action. Do not download the same file multiple times, check action history to see if the file has already been downloaded. "SELECT_OPTION" is an element you'd like to select an option from. "WAIT" action should be used if there are no actions to take and there is some indication on screen that waiting could yield more actions. "WAIT" should not be used if there are actions to take. "SOLVE_CAPTCHA" should be used if there's a captcha to solve on the screen. "COMPLETE" is used when the user goal has been achieved AND if there's any data extraction goal, you should be able to get data from the page. "TERMINATE" is used to terminate the whole task with a failure when it doesn't seem like the user goal can be achieved. Do not use "TERMINATE" if waiting could lead the user towards the goal. Only return "TERMINATE" if you are on a page where the user goal cannot be achieved. All other actions are ignored when "TERMINATE" is returned.
|
||||
"id": int, // The id of the element to take action on. The id has to be one from the elements list
|
||||
"text": str, // Text for INPUT_TEXT action only
|
||||
"file_url": str, // The url of the file to upload if applicable. This field must be present for UPLOAD_FILE but can also be present for CLICK only if the click is to upload the file. It should be null otherwise.
|
||||
"file_name": str, // The name of the file to save the downloaded file as. This field must be present for DOWNLOAD_FILE only. It should be null otherwise.
|
||||
"option": { // The option to select for SELECT_OPTION action only. null if not SELECT_OPTION action
|
||||
"label": str, // the label of the option if any. MAKE SURE YOU USE THIS LABEL TO SELECT THE OPTION. DO NOT PUT ANYTHING OTHER THAN A VALID OPTION LABEL HERE
|
||||
"index": int, // the id corresponding to the optionIndex under the the select element.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from skyvern.constants import SKYVERN_DIR
|
||||
from skyvern.exceptions import DownloadFileMaxSizeExceeded
|
||||
|
||||
LOG = structlog.get_logger()
|
||||
@@ -62,3 +64,7 @@ def zip_files(files_path: str, zip_file_path: str) -> str:
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
return zip_file_path
|
||||
|
||||
|
||||
def get_path_for_workflow_download_directory(workflow_run_id: str) -> Path:
|
||||
return Path(f"{SKYVERN_DIR}/downloads/{workflow_run_id}/")
|
||||
|
||||
@@ -469,7 +469,10 @@ async def get_workflow_run(
|
||||
analytics.capture("skyvern-oss-agent-workflow-run-get")
|
||||
request["agent"]
|
||||
return await app.WORKFLOW_SERVICE.build_workflow_run_status_response(
|
||||
workflow_id=workflow_id, workflow_run_id=workflow_run_id, organization_id=current_org.organization_id
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
last_block_result=None,
|
||||
organization_id=current_org.organization_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import smtplib
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
@@ -23,7 +24,7 @@ from skyvern.exceptions import (
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.prompts import prompt_engine
|
||||
from skyvern.forge.sdk.api.aws import AsyncAWSClient
|
||||
from skyvern.forge.sdk.api.files import download_file
|
||||
from skyvern.forge.sdk.api.files import download_file, get_path_for_workflow_download_directory
|
||||
from skyvern.forge.sdk.api.llm.api_handler_factory import LLMAPIHandlerFactory
|
||||
from skyvern.forge.sdk.schemas.tasks import TaskStatus
|
||||
from skyvern.forge.sdk.settings_manager import SettingsManager
|
||||
@@ -46,9 +47,17 @@ class BlockType(StrEnum):
|
||||
CODE = "code"
|
||||
TEXT_PROMPT = "text_prompt"
|
||||
DOWNLOAD_TO_S3 = "download_to_s3"
|
||||
UPLOAD_TO_S3 = "upload_to_s3"
|
||||
SEND_EMAIL = "send_email"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BlockResult:
|
||||
success: bool
|
||||
output_parameter: OutputParameter | None = None
|
||||
output_parameter_value: dict[str, Any] | list | str | None = None
|
||||
|
||||
|
||||
class Block(BaseModel, abc.ABC):
|
||||
# Must be unique within workflow definition
|
||||
label: str
|
||||
@@ -68,12 +77,26 @@ class Block(BaseModel, abc.ABC):
|
||||
return app.WORKFLOW_CONTEXT_MANAGER.aws_client
|
||||
|
||||
@abc.abstractmethod
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
pass
|
||||
|
||||
async def execute_safe(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
try:
|
||||
return await self.execute(workflow_run_id, **kwargs)
|
||||
except Exception:
|
||||
LOG.exception(
|
||||
"Block execution failed",
|
||||
exc_info=True,
|
||||
workflow_run_id=workflow_run_id,
|
||||
block_label=self.label,
|
||||
block_type=self.block_type,
|
||||
)
|
||||
return BlockResult(success=False)
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
pass
|
||||
|
||||
@@ -93,8 +116,15 @@ class TaskBlock(Block):
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
return self.parameters
|
||||
parameters = self.parameters
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
|
||||
if self.url and workflow_run_context.has_parameter(self.url):
|
||||
parameters.append(workflow_run_context.get_parameter(self.url))
|
||||
|
||||
return parameters
|
||||
|
||||
@staticmethod
|
||||
async def get_task_order(workflow_run_id: str, current_retry: int) -> tuple[int, int]:
|
||||
@@ -126,7 +156,7 @@ class TaskBlock(Block):
|
||||
|
||||
return order, retry + 1
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
current_retry = 0
|
||||
# initial value for will_retry is True, so that the loop runs at least once
|
||||
@@ -198,7 +228,6 @@ class TaskBlock(Block):
|
||||
if not updated_task.status.is_final():
|
||||
raise UnexpectedTaskStatus(task_id=updated_task.task_id, status=updated_task.status)
|
||||
if updated_task.status == TaskStatus.completed:
|
||||
will_retry = False
|
||||
LOG.info(
|
||||
f"Task completed",
|
||||
task_id=updated_task.task_id,
|
||||
@@ -224,7 +253,12 @@ class TaskBlock(Block):
|
||||
workflow_id=workflow.workflow_id,
|
||||
task_id=updated_task.task_id,
|
||||
)
|
||||
return self.output_parameter
|
||||
return BlockResult(
|
||||
success=True,
|
||||
output_parameter=self.output_parameter,
|
||||
output_parameter_value=updated_task.extracted_information,
|
||||
)
|
||||
return BlockResult(success=True)
|
||||
else:
|
||||
current_retry += 1
|
||||
will_retry = current_retry <= self.max_retries
|
||||
@@ -239,7 +273,8 @@ class TaskBlock(Block):
|
||||
current_retry=current_retry,
|
||||
max_retries=self.max_retries,
|
||||
)
|
||||
return None
|
||||
|
||||
return BlockResult(success=False)
|
||||
|
||||
|
||||
class ForLoopBlock(Block):
|
||||
@@ -251,15 +286,16 @@ class ForLoopBlock(Block):
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
return self.loop_block.get_all_parameters() + [self.loop_over]
|
||||
return self.loop_block.get_all_parameters(workflow_run_id) + [self.loop_over]
|
||||
|
||||
def get_loop_block_context_parameters(self, workflow_run_id: str, loop_data: Any) -> list[ContextParameter]:
|
||||
if not isinstance(loop_data, dict):
|
||||
# TODO (kerem): Should we add support for other types?
|
||||
raise ValueError("loop_data should be a dictionary")
|
||||
|
||||
loop_block_parameters = self.loop_block.get_all_parameters()
|
||||
loop_block_parameters = self.loop_block.get_all_parameters(workflow_run_id)
|
||||
context_parameters = [
|
||||
parameter for parameter in loop_block_parameters if isinstance(parameter, ContextParameter)
|
||||
]
|
||||
@@ -286,7 +322,7 @@ class ForLoopBlock(Block):
|
||||
# TODO (kerem): Implement this for context parameters
|
||||
raise NotImplementedError
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
loop_over_values = self.get_loop_over_parameter_values(workflow_run_context)
|
||||
LOG.info(
|
||||
@@ -296,20 +332,29 @@ class ForLoopBlock(Block):
|
||||
num_loop_over_values=len(loop_over_values),
|
||||
)
|
||||
outputs_with_loop_values = []
|
||||
block_outputs = []
|
||||
for loop_over_value in loop_over_values:
|
||||
context_parameters_with_value = self.get_loop_block_context_parameters(workflow_run_id, loop_over_value)
|
||||
for context_parameter in context_parameters_with_value:
|
||||
workflow_run_context.set_value(context_parameter.key, context_parameter.value)
|
||||
await self.loop_block.execute(workflow_run_id=workflow_run_id)
|
||||
if self.loop_block.output_parameter:
|
||||
try:
|
||||
block_output = await self.loop_block.execute(workflow_run_id=workflow_run_id)
|
||||
block_outputs.append(block_output)
|
||||
except Exception as e:
|
||||
LOG.error("ForLoopBlock: Failed to execute loop block", exc_info=True)
|
||||
raise e
|
||||
if block_output.output_parameter:
|
||||
outputs_with_loop_values.append(
|
||||
{
|
||||
"loop_value": loop_over_value,
|
||||
"output_parameter": self.loop_block.output_parameter,
|
||||
"output_value": workflow_run_context.get_value(self.loop_block.output_parameter.key),
|
||||
"output_parameter": block_output.output_parameter,
|
||||
"output_value": workflow_run_context.get_value(block_output.output_parameter.key),
|
||||
}
|
||||
)
|
||||
|
||||
# If all block outputs are successful, the loop is successful
|
||||
success = all([block_output.success for block_output in block_outputs])
|
||||
|
||||
if self.output_parameter:
|
||||
await workflow_run_context.register_output_parameter_value_post_execution(
|
||||
parameter=self.output_parameter,
|
||||
@@ -320,9 +365,11 @@ class ForLoopBlock(Block):
|
||||
output_parameter_id=self.output_parameter.output_parameter_id,
|
||||
value=outputs_with_loop_values,
|
||||
)
|
||||
return self.output_parameter
|
||||
return BlockResult(
|
||||
success=success, output_parameter=self.output_parameter, output_parameter_value=outputs_with_loop_values
|
||||
)
|
||||
|
||||
return None
|
||||
return BlockResult(success=success)
|
||||
|
||||
|
||||
class CodeBlock(Block):
|
||||
@@ -333,10 +380,11 @@ class CodeBlock(Block):
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
return self.parameters
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
# get workflow run context
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
# get all parameters into a dictionary
|
||||
@@ -362,9 +410,9 @@ class CodeBlock(Block):
|
||||
output_parameter_id=self.output_parameter.output_parameter_id,
|
||||
value=result,
|
||||
)
|
||||
return self.output_parameter
|
||||
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=result)
|
||||
|
||||
return None
|
||||
return BlockResult(success=True)
|
||||
|
||||
|
||||
class TextPromptBlock(Block):
|
||||
@@ -377,6 +425,7 @@ class TextPromptBlock(Block):
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
return self.parameters
|
||||
|
||||
@@ -406,7 +455,7 @@ class TextPromptBlock(Block):
|
||||
LOG.info("TextPromptBlock: Received response from LLM", response=response)
|
||||
return response
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
# get workflow run context
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
# get all parameters into a dictionary
|
||||
@@ -430,9 +479,9 @@ class TextPromptBlock(Block):
|
||||
output_parameter_id=self.output_parameter.output_parameter_id,
|
||||
value=response,
|
||||
)
|
||||
return self.output_parameter
|
||||
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=response)
|
||||
|
||||
return None
|
||||
return BlockResult(success=True)
|
||||
|
||||
|
||||
class DownloadToS3Block(Block):
|
||||
@@ -442,7 +491,13 @@ class DownloadToS3Block(Block):
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
|
||||
if self.url and workflow_run_context.has_parameter(self.url):
|
||||
return [workflow_run_context.get_parameter(self.url)]
|
||||
|
||||
return []
|
||||
|
||||
async def _upload_file_to_s3(self, uri: str, file_path: str) -> None:
|
||||
@@ -453,7 +508,7 @@ class DownloadToS3Block(Block):
|
||||
# Clean up the temporary file since it's created with delete=False
|
||||
os.unlink(file_path)
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
# get workflow run context
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
# get all parameters into a dictionary
|
||||
@@ -493,10 +548,82 @@ class DownloadToS3Block(Block):
|
||||
output_parameter_id=self.output_parameter.output_parameter_id,
|
||||
value=uri,
|
||||
)
|
||||
return self.output_parameter
|
||||
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=uri)
|
||||
|
||||
LOG.info("DownloadToS3Block: No output parameter defined, returning None")
|
||||
return None
|
||||
return BlockResult(success=True)
|
||||
|
||||
|
||||
class UploadToS3Block(Block):
|
||||
block_type: Literal[BlockType.UPLOAD_TO_S3] = BlockType.UPLOAD_TO_S3
|
||||
|
||||
# TODO (kerem): A directory upload is supported but we should also support a list of files
|
||||
path: str | None = None
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
|
||||
if self.path and workflow_run_context.has_parameter(self.path):
|
||||
return [workflow_run_context.get_parameter(self.path)]
|
||||
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _get_s3_uri(workflow_run_id: str, path: str) -> str:
|
||||
s3_bucket = SettingsManager.get_settings().AWS_S3_BUCKET_UPLOADS
|
||||
s3_key = f"{SettingsManager.get_settings().ENV}/{workflow_run_id}/{uuid.uuid4()}_{Path(path).name}"
|
||||
return f"s3://{s3_bucket}/{s3_key}"
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
# get workflow run context
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
# get all parameters into a dictionary
|
||||
if self.path and workflow_run_context.has_parameter(self.path) and workflow_run_context.has_value(self.path):
|
||||
file_path_parameter_value = workflow_run_context.get_value(self.path)
|
||||
if file_path_parameter_value:
|
||||
LOG.info(
|
||||
"UploadToS3Block: File path is parameterized, using parameter value",
|
||||
file_path_parameter_value=file_path_parameter_value,
|
||||
file_path_parameter_key=self.path,
|
||||
)
|
||||
self.path = file_path_parameter_value
|
||||
# if the path is WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY, use the download directory for the workflow run
|
||||
elif self.path == SettingsManager.get_settings().WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY:
|
||||
self.path = str(get_path_for_workflow_download_directory(workflow_run_id).absolute())
|
||||
|
||||
if not self.path or not os.path.exists(self.path):
|
||||
raise FileNotFoundError(f"UploadToS3Block: File not found at path: {self.path}")
|
||||
|
||||
try:
|
||||
client = self.get_async_aws_client()
|
||||
# is the file path a file or a directory?
|
||||
if os.path.isdir(self.path):
|
||||
# get all files in the directory, if there are more than 10 files, we will not upload them
|
||||
files = os.listdir(self.path)
|
||||
if len(files) > 10:
|
||||
raise ValueError("Too many files in the directory, not uploading")
|
||||
for file in files:
|
||||
# if the file is a directory, we will not upload it
|
||||
if os.path.isdir(os.path.join(self.path, file)):
|
||||
LOG.warning("UploadToS3Block: Skipping directory", file=file)
|
||||
continue
|
||||
file_path = os.path.join(self.path, file)
|
||||
await client.upload_file_from_path(
|
||||
uri=self._get_s3_uri(workflow_run_id, file_path), file_path=file_path
|
||||
)
|
||||
else:
|
||||
await client.upload_file_from_path(
|
||||
uri=self._get_s3_uri(workflow_run_id, self.path), file_path=self.path
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception("UploadToS3Block: Failed to upload file to S3", file_path=self.path, exc_info=True)
|
||||
raise e
|
||||
|
||||
LOG.info("UploadToS3Block: File(s) uploaded to S3", file_path=self.path)
|
||||
return BlockResult(success=True)
|
||||
|
||||
|
||||
class SendEmailBlock(Block):
|
||||
@@ -515,8 +642,23 @@ class SendEmailBlock(Block):
|
||||
|
||||
def get_all_parameters(
|
||||
self,
|
||||
workflow_run_id: str,
|
||||
) -> list[PARAMETER_TYPE]:
|
||||
return [self.smtp_host, self.smtp_port, self.smtp_username, self.smtp_password]
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
parameters = [self.smtp_host, self.smtp_port, self.smtp_username, self.smtp_password]
|
||||
|
||||
if self.file_attachments:
|
||||
for file_path in self.file_attachments:
|
||||
if workflow_run_context.has_parameter(file_path):
|
||||
parameters.append(workflow_run_context.get_parameter(file_path))
|
||||
|
||||
if self.subject and workflow_run_context.has_parameter(self.subject):
|
||||
parameters.append(workflow_run_context.get_parameter(self.subject))
|
||||
|
||||
if self.body and workflow_run_context.has_parameter(self.body):
|
||||
parameters.append(workflow_run_context.get_parameter(self.body))
|
||||
|
||||
return parameters
|
||||
|
||||
def _decrypt_smtp_parameters(self, workflow_run_context: WorkflowRunContext) -> tuple[str, int, str, str]:
|
||||
obfuscated_smtp_host_value = workflow_run_context.get_value(self.smtp_host.key)
|
||||
@@ -545,21 +687,44 @@ class SendEmailBlock(Block):
|
||||
|
||||
return smtp_host_value, smtp_port_value, smtp_username_value, smtp_password_value
|
||||
|
||||
def _get_file_paths(self, workflow_run_context: WorkflowRunContext) -> list[str]:
|
||||
def _get_file_paths(self, workflow_run_context: WorkflowRunContext, workflow_run_id: str) -> list[str]:
|
||||
file_paths = []
|
||||
for file_path in self.file_attachments:
|
||||
if not workflow_run_context.has_parameter(file_path):
|
||||
file_paths.append(file_path)
|
||||
continue
|
||||
for path in self.file_attachments:
|
||||
# if the file path is a parameter, get the value from the workflow run context first
|
||||
if workflow_run_context.has_parameter(path):
|
||||
file_path_parameter_value = workflow_run_context.get_value(path)
|
||||
# if the file path is a secret, get the original secret value from the workflow run context
|
||||
file_path_parameter_secret_value = workflow_run_context.get_original_secret_value_or_none(
|
||||
file_path_parameter_value
|
||||
)
|
||||
if file_path_parameter_secret_value:
|
||||
path = file_path_parameter_secret_value
|
||||
else:
|
||||
path = file_path_parameter_value
|
||||
|
||||
file_path_parameter_value = workflow_run_context.get_value(file_path)
|
||||
file_path_parameter_secret_value = workflow_run_context.get_original_secret_value_or_none(
|
||||
file_path_parameter_value
|
||||
)
|
||||
if file_path_parameter_secret_value:
|
||||
file_paths.append(file_path_parameter_secret_value)
|
||||
if path == SettingsManager.get_settings().WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY:
|
||||
# if the path is WORKFLOW_DOWNLOAD_DIRECTORY_PARAMETER_KEY, use download directory for the workflow run
|
||||
path = str(get_path_for_workflow_download_directory(workflow_run_id).absolute())
|
||||
LOG.info(
|
||||
"SendEmailBlock: Using download directory for the workflow run",
|
||||
workflow_run_id=workflow_run_id,
|
||||
file_path=path,
|
||||
)
|
||||
|
||||
# if the file path is a directory, add all files in the directory, skip directories, limit to 10 files
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
if len(os.listdir(path)) > 10:
|
||||
LOG.warning("SendEmailBlock: Too many files in the directory, not attaching to email")
|
||||
continue
|
||||
for file in os.listdir(path):
|
||||
if os.path.isdir(os.path.join(path, file)):
|
||||
LOG.warning("SendEmailBlock: Skipping directory", file=file)
|
||||
continue
|
||||
file_path = os.path.join(path, file)
|
||||
file_paths.append(file_path)
|
||||
else:
|
||||
file_paths.append(file_path_parameter_value)
|
||||
# covers the case where the file path is a single file, a url, or an S3 uri
|
||||
file_paths.append(path)
|
||||
|
||||
return file_paths
|
||||
|
||||
@@ -577,9 +742,14 @@ class SendEmailBlock(Block):
|
||||
msg["Subject"] = self.subject + f" - Workflow Run ID: {workflow_run_id}"
|
||||
msg["To"] = ", ".join(self.recipients)
|
||||
msg["From"] = self.sender
|
||||
msg.set_content(self.body)
|
||||
if self.body and workflow_run_context.has_parameter(self.body) and workflow_run_context.has_value(self.body):
|
||||
# We're purposely not decrypting the body parameter value here because we don't want to expose secrets
|
||||
body_parameter_value = workflow_run_context.get_value(self.body)
|
||||
msg.set_content(body_parameter_value)
|
||||
else:
|
||||
msg.set_content(self.body)
|
||||
|
||||
for filename in self._get_file_paths(workflow_run_context):
|
||||
for filename in self._get_file_paths(workflow_run_context, workflow_run_id):
|
||||
path = None
|
||||
try:
|
||||
if filename.startswith("s3://"):
|
||||
@@ -587,7 +757,7 @@ class SendEmailBlock(Block):
|
||||
elif filename.startswith("http://") or filename.startswith("https://"):
|
||||
path = await download_file(filename)
|
||||
else:
|
||||
LOG.error("SendEmailBlock: Looking for file locally", filename=filename)
|
||||
LOG.info("SendEmailBlock: Looking for file locally", filename=filename)
|
||||
if not os.path.exists(filename):
|
||||
raise FileNotFoundError(f"File not found: {filename}")
|
||||
if not os.path.isfile(filename):
|
||||
@@ -635,7 +805,7 @@ class SendEmailBlock(Block):
|
||||
|
||||
return msg
|
||||
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> OutputParameter | None:
|
||||
async def execute(self, workflow_run_id: str, **kwargs: dict) -> BlockResult:
|
||||
workflow_run_context = self.get_workflow_run_context(workflow_run_id)
|
||||
smtp_host_value, smtp_port_value, smtp_username_value, smtp_password_value = self._decrypt_smtp_parameters(
|
||||
workflow_run_context
|
||||
@@ -652,47 +822,43 @@ class SendEmailBlock(Block):
|
||||
smtp_host.send_message(message)
|
||||
LOG.info("SendEmailBlock: Email sent")
|
||||
except Exception as e:
|
||||
LOG.error("SendEmailBlock: Failed to send email", error=str(e))
|
||||
LOG.error("SendEmailBlock: Failed to send email", exc_info=True)
|
||||
if self.output_parameter:
|
||||
result_dict = {"success": False, "error": str(e)}
|
||||
await workflow_run_context.register_output_parameter_value_post_execution(
|
||||
parameter=self.output_parameter,
|
||||
value={
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
},
|
||||
value=result_dict,
|
||||
)
|
||||
await app.DATABASE.create_workflow_run_output_parameter(
|
||||
workflow_run_id=workflow_run_id,
|
||||
output_parameter_id=self.output_parameter.output_parameter_id,
|
||||
value={
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
},
|
||||
value=result_dict,
|
||||
)
|
||||
return BlockResult(
|
||||
success=False, output_parameter=self.output_parameter, output_parameter_value=result_dict
|
||||
)
|
||||
return self.output_parameter
|
||||
raise e
|
||||
finally:
|
||||
if smtp_host:
|
||||
smtp_host.quit()
|
||||
|
||||
result_dict = {"success": True}
|
||||
if self.output_parameter:
|
||||
await workflow_run_context.register_output_parameter_value_post_execution(
|
||||
parameter=self.output_parameter,
|
||||
value={
|
||||
"success": True,
|
||||
},
|
||||
value=result_dict,
|
||||
)
|
||||
await app.DATABASE.create_workflow_run_output_parameter(
|
||||
workflow_run_id=workflow_run_id,
|
||||
output_parameter_id=self.output_parameter.output_parameter_id,
|
||||
value={
|
||||
"success": True,
|
||||
},
|
||||
value=result_dict,
|
||||
)
|
||||
return self.output_parameter
|
||||
return BlockResult(success=True, output_parameter=self.output_parameter, output_parameter_value=result_dict)
|
||||
|
||||
return None
|
||||
return BlockResult(success=True)
|
||||
|
||||
|
||||
BlockSubclasses = Union[ForLoopBlock, TaskBlock, CodeBlock, TextPromptBlock, DownloadToS3Block, SendEmailBlock]
|
||||
BlockSubclasses = Union[
|
||||
ForLoopBlock, TaskBlock, CodeBlock, TextPromptBlock, DownloadToS3Block, UploadToS3Block, SendEmailBlock
|
||||
]
|
||||
BlockTypeVar = Annotated[BlockSubclasses, Field(discriminator="block_type")]
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
||||
|
||||
from skyvern.forge.sdk.schemas.tasks import ProxyLocation
|
||||
from skyvern.forge.sdk.workflow.exceptions import WorkflowDefinitionHasDuplicateBlockLabels
|
||||
from skyvern.forge.sdk.workflow.models.block import BlockTypeVar
|
||||
from skyvern.forge.sdk.workflow.models.block import BlockResult, BlockTypeVar
|
||||
|
||||
|
||||
class WorkflowRequestBody(BaseModel):
|
||||
@@ -93,3 +93,4 @@ class WorkflowRunStatusResponse(BaseModel):
|
||||
screenshot_urls: list[str] | None = None
|
||||
recording_url: str | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
output: BlockResult | None = None
|
||||
|
||||
@@ -132,6 +132,12 @@ class DownloadToS3BlockYAML(BlockYAML):
|
||||
url: str
|
||||
|
||||
|
||||
class UploadToS3BlockYAML(BlockYAML):
|
||||
block_type: Literal[BlockType.UPLOAD_TO_S3] = BlockType.UPLOAD_TO_S3 # type: ignore
|
||||
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class SendEmailBlockYAML(BlockYAML):
|
||||
# There is a mypy bug with Literal. Without the type: ignore, mypy will raise an error:
|
||||
# Parameter 1 of Literal[...] cannot be of type "Any"
|
||||
@@ -160,7 +166,13 @@ PARAMETER_YAML_SUBCLASSES = (
|
||||
PARAMETER_YAML_TYPES = Annotated[PARAMETER_YAML_SUBCLASSES, Field(discriminator="parameter_type")]
|
||||
|
||||
BLOCK_YAML_SUBCLASSES = (
|
||||
TaskBlockYAML | ForLoopBlockYAML | CodeBlockYAML | TextPromptBlockYAML | DownloadToS3BlockYAML | SendEmailBlockYAML
|
||||
TaskBlockYAML
|
||||
| ForLoopBlockYAML
|
||||
| CodeBlockYAML
|
||||
| TextPromptBlockYAML
|
||||
| DownloadToS3BlockYAML
|
||||
| UploadToS3BlockYAML
|
||||
| SendEmailBlockYAML
|
||||
)
|
||||
BLOCK_YAML_TYPES = Annotated[BLOCK_YAML_SUBCLASSES, Field(discriminator="block_type")]
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from skyvern.forge.sdk.models import Step
|
||||
from skyvern.forge.sdk.schemas.tasks import Task, TaskStatus
|
||||
from skyvern.forge.sdk.workflow.exceptions import WorkflowDefinitionHasDuplicateParameterKeys
|
||||
from skyvern.forge.sdk.workflow.models.block import (
|
||||
BlockResult,
|
||||
BlockType,
|
||||
BlockTypeVar,
|
||||
CodeBlock,
|
||||
@@ -30,6 +31,7 @@ from skyvern.forge.sdk.workflow.models.block import (
|
||||
SendEmailBlock,
|
||||
TaskBlock,
|
||||
TextPromptBlock,
|
||||
UploadToS3Block,
|
||||
)
|
||||
from skyvern.forge.sdk.workflow.models.parameter import (
|
||||
AWSSecretParameter,
|
||||
@@ -150,9 +152,10 @@ class WorkflowService:
|
||||
)
|
||||
# Execute workflow blocks
|
||||
blocks = workflow.workflow_definition.blocks
|
||||
try:
|
||||
for block_idx, block in enumerate(blocks):
|
||||
parameters = block.get_all_parameters()
|
||||
block_result = None
|
||||
for block_idx, block in enumerate(blocks):
|
||||
try:
|
||||
parameters = block.get_all_parameters(workflow_run_id)
|
||||
await app.WORKFLOW_CONTEXT_MANAGER.register_block_parameters_for_workflow_run(
|
||||
workflow_run_id, parameters
|
||||
)
|
||||
@@ -162,40 +165,36 @@ class WorkflowService:
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
block_idx=block_idx,
|
||||
)
|
||||
await block.execute(workflow_run_id=workflow_run_id)
|
||||
except Exception:
|
||||
LOG.exception(
|
||||
f"Error while executing workflow run {workflow_run.workflow_run_id}",
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
block_result = await block.execute_safe(workflow_run_id=workflow_run_id)
|
||||
if not block_result.success:
|
||||
LOG.error(
|
||||
f"Block with type {block.block_type} at index {block_idx} failed for workflow run {workflow_run_id}",
|
||||
block_type=block.block_type,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
block_idx=block_idx,
|
||||
block_result=block_result,
|
||||
)
|
||||
await self.mark_workflow_run_as_failed(workflow_run_id=workflow_run.workflow_run_id)
|
||||
break
|
||||
|
||||
tasks = await self.get_tasks_by_workflow_run_id(workflow_run.workflow_run_id)
|
||||
|
||||
if tasks:
|
||||
workflow_run = await self.handle_workflow_status(workflow_run=workflow_run, tasks=tasks)
|
||||
else:
|
||||
# Check if the workflow run has any workflow run output parameters
|
||||
# if it does, mark the workflow run as completed, else mark it as failed
|
||||
workflow_run_output_parameters = await self.get_workflow_run_output_parameters(
|
||||
workflow_run_id=workflow_run.workflow_run_id
|
||||
)
|
||||
if workflow_run_output_parameters:
|
||||
LOG.info(
|
||||
f"Workflow run {workflow_run.workflow_run_id} has output parameters, marking as completed",
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
)
|
||||
await self.mark_workflow_run_as_completed(workflow_run_id=workflow_run.workflow_run_id)
|
||||
else:
|
||||
LOG.error(
|
||||
f"Workflow run {workflow_run.workflow_run_id} has no tasks or output parameters, marking as failed",
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
f"Error while executing workflow run {workflow_run.workflow_run_id}",
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
await self.mark_workflow_run_as_failed(workflow_run_id=workflow_run.workflow_run_id)
|
||||
raise e
|
||||
|
||||
tasks = await self.get_tasks_by_workflow_run_id(workflow_run.workflow_run_id)
|
||||
await self.mark_workflow_run_as_completed(workflow_run_id=workflow_run.workflow_run_id)
|
||||
|
||||
await self.send_workflow_response(
|
||||
workflow=workflow,
|
||||
workflow_run=workflow_run,
|
||||
tasks=tasks,
|
||||
# TODO: We don't persist the block result for now, but we should in the case the users want to get it later
|
||||
last_block_result=block_result,
|
||||
api_key=api_key,
|
||||
)
|
||||
return workflow_run
|
||||
@@ -430,7 +429,7 @@ class WorkflowService:
|
||||
return await app.DATABASE.get_tasks_by_workflow_run_id(workflow_run_id=workflow_run_id)
|
||||
|
||||
async def build_workflow_run_status_response(
|
||||
self, workflow_id: str, workflow_run_id: str, organization_id: str
|
||||
self, workflow_id: str, workflow_run_id: str, last_block_result: BlockResult | None, organization_id: str
|
||||
) -> WorkflowRunStatusResponse:
|
||||
workflow = await self.get_workflow(workflow_id=workflow_id)
|
||||
if workflow is None:
|
||||
@@ -499,6 +498,7 @@ class WorkflowService:
|
||||
screenshot_urls=screenshot_urls,
|
||||
recording_url=recording_url,
|
||||
payload=payload,
|
||||
output=last_block_result,
|
||||
)
|
||||
|
||||
async def send_workflow_response(
|
||||
@@ -506,6 +506,7 @@ class WorkflowService:
|
||||
workflow: Workflow,
|
||||
workflow_run: WorkflowRun,
|
||||
tasks: list[Task],
|
||||
last_block_result: BlockResult | None,
|
||||
api_key: str | None = None,
|
||||
close_browser_on_completion: bool = True,
|
||||
) -> None:
|
||||
@@ -523,6 +524,7 @@ class WorkflowService:
|
||||
workflow_run_status_response = await self.build_workflow_run_status_response(
|
||||
workflow_id=workflow.workflow_id,
|
||||
workflow_run_id=workflow_run.workflow_run_id,
|
||||
last_block_result=last_block_result,
|
||||
organization_id=workflow.organization_id,
|
||||
)
|
||||
LOG.info("Built workflow run status response", workflow_run_status_response=workflow_run_status_response)
|
||||
@@ -772,6 +774,12 @@ class WorkflowService:
|
||||
output_parameter=output_parameter,
|
||||
url=block_yaml.url,
|
||||
)
|
||||
elif block_yaml.block_type == BlockType.UPLOAD_TO_S3:
|
||||
return UploadToS3Block(
|
||||
label=block_yaml.label,
|
||||
output_parameter=output_parameter,
|
||||
path=block_yaml.path,
|
||||
)
|
||||
elif block_yaml.block_type == BlockType.SEND_EMAIL:
|
||||
return SendEmailBlock(
|
||||
label=block_yaml.label,
|
||||
|
||||
@@ -14,6 +14,7 @@ class ActionType(StrEnum):
|
||||
CLICK = "click"
|
||||
INPUT_TEXT = "input_text"
|
||||
UPLOAD_FILE = "upload_file"
|
||||
DOWNLOAD_FILE = "download_file"
|
||||
SELECT_OPTION = "select_option"
|
||||
CHECKBOX = "checkbox"
|
||||
WAIT = "wait"
|
||||
@@ -69,6 +70,14 @@ class UploadFileAction(WebAction):
|
||||
return f"UploadFileAction(element_id={self.element_id}, file={self.file_url}, is_upload_file_tag={self.is_upload_file_tag})"
|
||||
|
||||
|
||||
class DownloadFileAction(WebAction):
|
||||
action_type: ActionType = ActionType.DOWNLOAD_FILE
|
||||
file_name: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"DownloadFileAction(element_id={self.element_id}, file_name={self.file_name})"
|
||||
|
||||
|
||||
class NullAction(Action):
|
||||
action_type: ActionType = ActionType.NULL_ACTION
|
||||
|
||||
@@ -149,6 +158,10 @@ def parse_actions(task: Task, json_response: List[Dict[str, Any]]) -> List[Actio
|
||||
# TODO: see if the element is a file input element. if it's not, convert this action into a click action
|
||||
|
||||
actions.append(UploadFileAction(element_id=element_id, file_url=action["file_url"], reasoning=reasoning))
|
||||
elif action_type == ActionType.DOWNLOAD_FILE:
|
||||
actions.append(
|
||||
DownloadFileAction(element_id=element_id, file_name=action["file_name"], reasoning=reasoning)
|
||||
)
|
||||
elif action_type == ActionType.SELECT_OPTION:
|
||||
actions.append(
|
||||
SelectOptionAction(
|
||||
@@ -166,22 +179,13 @@ def parse_actions(task: Task, json_response: List[Dict[str, Any]]) -> List[Actio
|
||||
elif action_type == ActionType.WAIT:
|
||||
actions.append(WaitAction(reasoning=reasoning))
|
||||
elif action_type == ActionType.COMPLETE:
|
||||
if actions:
|
||||
LOG.info(
|
||||
"Navigation goal achieved, creating complete action and discarding all other actions except "
|
||||
"complete action",
|
||||
task_id=task.task_id,
|
||||
nav_goal=task.navigation_goal,
|
||||
actions=actions,
|
||||
llm_response=json_response,
|
||||
)
|
||||
return [
|
||||
actions.append(
|
||||
CompleteAction(
|
||||
reasoning=reasoning,
|
||||
data_extraction_goal=task.data_extraction_goal,
|
||||
errors=action["errors"] if "errors" in action else [],
|
||||
)
|
||||
]
|
||||
)
|
||||
elif action_type == "null":
|
||||
actions.append(NullAction(reasoning=reasoning))
|
||||
elif action_type == ActionType.SOLVE_CAPTCHA:
|
||||
@@ -210,6 +214,7 @@ ActionTypeUnion = (
|
||||
ClickAction
|
||||
| InputTextAction
|
||||
| UploadFileAction
|
||||
| DownloadFileAction
|
||||
| SelectOptionAction
|
||||
| CheckboxAction
|
||||
| WaitAction
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable, List
|
||||
|
||||
import structlog
|
||||
from playwright.async_api import Locator, Page
|
||||
|
||||
from skyvern.constants import SKYVERN_DIR
|
||||
from skyvern.exceptions import ImaginaryFileUrl, MissingElement, MissingFileUrl, MultipleElementsFound
|
||||
from skyvern.forge import app
|
||||
from skyvern.forge.prompts import prompt_engine
|
||||
@@ -154,6 +157,34 @@ async def handle_upload_file_action(
|
||||
)
|
||||
|
||||
|
||||
async def handle_download_file_action(
|
||||
action: actions.DownloadFileAction, page: Page, scraped_page: ScrapedPage, task: Task, step: Step
|
||||
) -> list[ActionResult]:
|
||||
xpath = await validate_actions_in_dom(action, page, scraped_page)
|
||||
file_name = f"{action.file_name or uuid.uuid4()}"
|
||||
full_file_path = f"{SKYVERN_DIR}/downloads/{task.workflow_run_id or task.task_id}/{file_name}"
|
||||
try:
|
||||
# Start waiting for the download
|
||||
async with page.expect_download() as download_info:
|
||||
await asyncio.sleep(0.3)
|
||||
await page.click(f"xpath={xpath}", timeout=SettingsManager.get_settings().BROWSER_ACTION_TIMEOUT_MS)
|
||||
|
||||
download = await download_info.value
|
||||
|
||||
# Create download folders if they don't exist
|
||||
download_folder = f"{SKYVERN_DIR}/downloads/{task.workflow_run_id or task.task_id}"
|
||||
os.makedirs(download_folder, exist_ok=True)
|
||||
# Wait for the download process to complete and save the downloaded file
|
||||
await download.save_as(full_file_path)
|
||||
except Exception as e:
|
||||
LOG.exception(
|
||||
"DownloadFileAction: Failed to download file", action=action, full_file_path=full_file_path, exc_info=True
|
||||
)
|
||||
return [ActionFailure(e)]
|
||||
|
||||
return [ActionSuccess(data={"file_path": full_file_path})]
|
||||
|
||||
|
||||
async def handle_null_action(
|
||||
action: actions.NullAction, page: Page, scraped_page: ScrapedPage, task: Task, step: Step
|
||||
) -> list[ActionResult]:
|
||||
@@ -348,6 +379,7 @@ ActionHandler.register_action_type(ActionType.SOLVE_CAPTCHA, handle_solve_captch
|
||||
ActionHandler.register_action_type(ActionType.CLICK, handle_click_action)
|
||||
ActionHandler.register_action_type(ActionType.INPUT_TEXT, handle_input_text_action)
|
||||
ActionHandler.register_action_type(ActionType.UPLOAD_FILE, handle_upload_file_action)
|
||||
ActionHandler.register_action_type(ActionType.DOWNLOAD_FILE, handle_download_file_action)
|
||||
ActionHandler.register_action_type(ActionType.NULL_ACTION, handle_null_action)
|
||||
ActionHandler.register_action_type(ActionType.SELECT_OPTION, handle_select_option_action)
|
||||
ActionHandler.register_action_type(ActionType.WAIT, handle_wait_action)
|
||||
|
||||
@@ -169,7 +169,12 @@ async def scrape_web_unsafe(
|
||||
scroll_y_px_old = -1.0
|
||||
scroll_y_px = await scroll_to_top(page, drow_boxes=True)
|
||||
# Checking max number of screenshots to prevent infinite loop
|
||||
while scroll_y_px_old != scroll_y_px and len(screenshots) < SettingsManager.get_settings().MAX_NUM_SCREENSHOTS:
|
||||
# We are checking the difference between the old and new scroll_y_px to determine if we have reached the end of the
|
||||
# page. If the difference is less than 25, we assume we have reached the end of the page.
|
||||
while (
|
||||
abs(scroll_y_px_old - scroll_y_px) > 25
|
||||
and len(screenshots) < SettingsManager.get_settings().MAX_NUM_SCREENSHOTS
|
||||
):
|
||||
screenshot = await browser_state.take_screenshot(full_page=False)
|
||||
screenshots.append(screenshot)
|
||||
scroll_y_px_old = scroll_y_px
|
||||
|
||||
Reference in New Issue
Block a user