diff --git a/components/airtop/actions/create-session/create-session.mjs b/components/airtop/actions/create-session/create-session.mjs new file mode 100644 index 0000000000000..405f4bd7e3545 --- /dev/null +++ b/components/airtop/actions/create-session/create-session.mjs @@ -0,0 +1,97 @@ +import { parseObjectEntries } from "../../common/utils.mjs"; +import app from "../../airtop.app.mjs"; +import { ConfigurationError } from "@pipedream/platform"; + +export default { + key: "airtop-create-session", + name: "Create Session", + description: "Create a new cloud browser session. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/sessions/create)", + version: "0.0.1", + type: "action", + props: { + app, + profileName: { + type: "string", + label: "Profile Name", + description: "Name of a profile to load into the session. Only letters, numbers and hyphens are allowed. [See the documentation](https://docs.airtop.ai/guides/how-to/saving-a-profile) for more information", + optional: true, + }, + saveProfileOnTermination: { + type: "boolean", + label: "Save Profile on Termination", + description: "If enabled, [the profile will be saved when the session terminates](https://docs.airtop.ai/api-reference/airtop-api/sessions/save-profile-on-termination). Only relevant if `Profile Name` is provided", + optional: true, + }, + timeoutMinutes: { + type: "integer", + label: "Timeout (Minutes)", + description: "Number of minutes of inactivity (idle timeout) after which the session will terminate", + optional: true, + }, + record: { + type: "boolean", + label: "Record", + description: "Whether to enable session recording", + optional: true, + }, + solveCaptcha: { + type: "boolean", + label: "Solve Captcha", + description: "Whether to automatically solve captcha challenges", + optional: true, + }, + additionalOptions: { + type: "object", + label: "Additional Options", + description: "Additional configuration parameters to send in the request. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/sessions/create) for available parameters (e.g., `proxy`). Values will be parsed as JSON where applicable.", + optional: true, + }, + }, + async run({ $ }) { + const { + profileName, + saveProfileOnTermination, + timeoutMinutes, + record, + solveCaptcha, + additionalOptions, + } = this; + + if (profileName && !/^[a-zA-Z0-9-]+$/.test(profileName)) { + throw new ConfigurationError(`Profile name \`${profileName}\` must contain only letters, numbers and hyphens`); + } + + const data = { + configuration: { + profileName, + timeoutMinutes, + record, + solveCaptcha, + ...parseObjectEntries(additionalOptions), + }, + }; + + const response = await this.app.createSession({ + $, + data, + }); + + const sessionId = response.id; + + let saveProfileOnTerminationResponse; + if (saveProfileOnTermination && profileName) { + saveProfileOnTerminationResponse = await this.app.saveProfileOnTermination({ + $, + sessionId, + profileName, + }); + } + + $.export("$summary", `Successfully created session \`${sessionId}\` with status: ${response.status}`); + return { + response, + saveProfileOnTerminationResponse, + }; + }, +}; + diff --git a/components/airtop/actions/create-window/create-window.mjs b/components/airtop/actions/create-window/create-window.mjs new file mode 100644 index 0000000000000..ca7c2fd086cbe --- /dev/null +++ b/components/airtop/actions/create-window/create-window.mjs @@ -0,0 +1,70 @@ +import app from "../../airtop.app.mjs"; + +export default { + key: "airtop-create-window", + name: "Create Window", + description: "Create a new browser window in an active session. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/windows/create)", + version: "0.0.1", + type: "action", + props: { + app, + sessionId: { + propDefinition: [ + app, + "sessionId", + ], + }, + url: { + type: "string", + label: "Initial URL", + description: "Optional URL to navigate to immediately after creating the window.", + optional: true, + default: "https://www.pipedream.com", + }, + screenResolution: { + type: "string", + label: "Screen Resolution", + description: "Affects the live view configuration. By default, a live view will fill the parent frame when initially loaded. This parameter can be used to configure fixed dimensions (e.g. `1280x720`).", + optional: true, + default: "1280x720", + }, + waitUntil: { + propDefinition: [ + app, + "waitUntil", + ], + }, + waitUntilTimeoutSeconds: { + propDefinition: [ + app, + "waitUntilTimeoutSeconds", + ], + }, + }, + async run({ $ }) { + const { + sessionId, + url, + screenResolution, + waitUntil, + waitUntilTimeoutSeconds, + } = this; + + const response = await this.app.createWindow({ + $, + sessionId, + data: { + url, + screenResolution, + waitUntil, + waitUntilTimeoutSeconds, + }, + }); + + const windowId = response.id; + + $.export("$summary", `Successfully created window ${windowId}`); + return response; + }, +}; + diff --git a/components/airtop/actions/end-session/end-session.mjs b/components/airtop/actions/end-session/end-session.mjs new file mode 100644 index 0000000000000..a08d90a0b63a3 --- /dev/null +++ b/components/airtop/actions/end-session/end-session.mjs @@ -0,0 +1,30 @@ +import app from "../../airtop.app.mjs"; + +export default { + key: "airtop-end-session", + name: "End Session", + description: "End a browser session. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/sessions/terminate)", + version: "0.0.1", + type: "action", + props: { + app, + sessionId: { + propDefinition: [ + app, + "sessionId", + ], + }, + }, + async run({ $ }) { + const { sessionId } = this; + + const response = await this.app.endSession({ + $, + sessionId, + }); + + $.export("$summary", `Successfully terminated session ${sessionId}`); + return response; + }, +}; + diff --git a/components/airtop/actions/load-url/load-url.mjs b/components/airtop/actions/load-url/load-url.mjs new file mode 100644 index 0000000000000..26a03d30df843 --- /dev/null +++ b/components/airtop/actions/load-url/load-url.mjs @@ -0,0 +1,68 @@ +import app from "../../airtop.app.mjs"; + +export default { + key: "airtop-load-url", + name: "Load URL", + description: "Navigate a browser window to a specific URL. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/windows/load-url)", + version: "0.0.1", + type: "action", + props: { + app, + sessionId: { + propDefinition: [ + app, + "sessionId", + ], + }, + windowId: { + propDefinition: [ + app, + "windowId", + ({ sessionId }) => ({ + sessionId, + }), + ], + }, + url: { + type: "string", + label: "URL", + description: "The URL to navigate to (e.g. `https://www.pipedream.com`)", + }, + waitUntil: { + propDefinition: [ + app, + "waitUntil", + ], + }, + waitUntilTimeoutSeconds: { + propDefinition: [ + app, + "waitUntilTimeoutSeconds", + ], + }, + }, + async run({ $ }) { + const { + sessionId, + windowId, + url, + waitUntil, + waitUntilTimeoutSeconds, + } = this; + + const response = await this.app.loadUrl({ + $, + sessionId, + windowId, + data: { + url, + waitUntil, + waitUntilTimeoutSeconds, + }, + }); + + $.export("$summary", `Successfully navigated to ${url}`); + return response; + }, +}; + diff --git a/components/airtop/actions/query-page/query-page.mjs b/components/airtop/actions/query-page/query-page.mjs new file mode 100644 index 0000000000000..2824c4c36bc30 --- /dev/null +++ b/components/airtop/actions/query-page/query-page.mjs @@ -0,0 +1,78 @@ +import app from "../../airtop.app.mjs"; + +export default { + key: "airtop-query-page", + name: "Query Page", + description: "Extract data or ask questions about page content using AI. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/windows/page-query)", + version: "0.0.1", + type: "action", + props: { + app, + sessionId: { + propDefinition: [ + app, + "sessionId", + ], + }, + windowId: { + propDefinition: [ + app, + "windowId", + ({ sessionId }) => ({ + sessionId, + }), + ], + }, + prompt: { + type: "string", + label: "Prompt", + description: "The prompt to submit about the content in the browser window.", + }, + followPaginationLinks: { + propDefinition: [ + app, + "followPaginationLinks", + ], + }, + costThresholdCredits: { + propDefinition: [ + app, + "costThresholdCredits", + ], + }, + timeThresholdSeconds: { + propDefinition: [ + app, + "timeThresholdSeconds", + ], + }, + }, + async run({ $ }) { + const { + sessionId, + windowId, + prompt, + followPaginationLinks, + costThresholdCredits, + timeThresholdSeconds, + } = this; + + const response = await this.app.queryPage({ + $, + sessionId, + windowId, + data: { + prompt, + configuration: { + followPaginationLinks, + costThresholdCredits, + timeThresholdSeconds, + }, + }, + }); + + $.export("$summary", "Successfully queried page"); + return response; + }, +}; + diff --git a/components/airtop/actions/scrape-content/scrape-content.mjs b/components/airtop/actions/scrape-content/scrape-content.mjs new file mode 100644 index 0000000000000..663d6a2eca484 --- /dev/null +++ b/components/airtop/actions/scrape-content/scrape-content.mjs @@ -0,0 +1,69 @@ +import app from "../../airtop.app.mjs"; + +export default { + key: "airtop-scrape-content", + name: "Scrape Content", + description: "Scrape structured content from a web page using AI. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/windows/scrape-content)", + version: "0.0.1", + type: "action", + props: { + app, + sessionId: { + propDefinition: [ + app, + "sessionId", + ], + }, + windowId: { + propDefinition: [ + app, + "windowId", + ({ sessionId }) => ({ + sessionId, + }), + ], + }, + clientRequestId: { + type: "string", + label: "Client Request ID", + description: "Optional client-provided request identifier", + optional: true, + }, + costThresholdCredits: { + propDefinition: [ + app, + "costThresholdCredits", + ], + }, + timeThresholdSeconds: { + propDefinition: [ + app, + "timeThresholdSeconds", + ], + }, + }, + async run({ $ }) { + const { + sessionId, + windowId, + clientRequestId, + costThresholdCredits, + timeThresholdSeconds, + } = this; + + const response = await this.app.scrapeContent({ + $, + sessionId, + windowId, + data: { + clientRequestId, + costThresholdCredits, + timeThresholdSeconds, + }, + }); + + $.export("$summary", "Successfully scraped content from the page"); + return response; + }, +}; + diff --git a/components/airtop/airtop.app.mjs b/components/airtop/airtop.app.mjs index 2b7c770aaa445..abaacfb6252f2 100644 --- a/components/airtop/airtop.app.mjs +++ b/components/airtop/airtop.app.mjs @@ -1,11 +1,213 @@ +import { axios } from "@pipedream/platform"; +import { formatTimeAgo } from "./common/utils.mjs"; + export default { type: "app", app: "airtop", - propDefinitions: {}, + propDefinitions: { + sessionId: { + type: "string", + label: "Session ID", + description: "Select a session or provide a session ID. Note that only actively running sessions are listed in the options", + async options({ page = 0 }) { + const limit = 10; + const offset = page * limit; + + const data = await this.listSessions({ + params: { + status: "running", + limit, + offset, + }, + }); + + return data.sessions.map(({ + id, dateCreated, lastActivity, + }) => ({ + label: `${id.slice(0, 8)} last active ${formatTimeAgo(lastActivity)} (created ${formatTimeAgo(dateCreated)})`, + value: id, + })); + }, + }, + windowId: { + type: "string", + label: "Window ID", + description: "Select a browser window or provide a custom window ID", + async options({ sessionId }) { + if (!sessionId) { + return []; + } + const data = await this.listWindows({ + sessionId, + }); + return data.windows.map((window) => ({ + label: `Window ${window.windowId.substring(0, 8)}...`, + value: window.windowId, + })); + }, + }, + waitUntil: { + type: "string", + label: "Wait Until", + description: "Wait until the specified loading event occurs. Defaults to `load`.", + options: [ + { + label: "Load - Wait until the page DOM and its assets have loaded", + value: "load", + }, + { + label: "DOM Content Loaded - Wait until the DOM has loaded", + value: "domContentLoaded", + }, + { + label: "Complete - Wait until the page and all its iframes have loaded", + value: "complete", + }, + { + label: "No Wait - Return immediately without waiting", + value: "noWait", + }, + ], + optional: true, + default: "load", + }, + waitUntilTimeoutSeconds: { + type: "integer", + label: "\"Wait Until\" Timeout (seconds)", + description: "Maximum time in seconds to wait for the specified loading event to occur. If the timeout is reached, the operation will still succeed but return a warning.", + optional: true, + default: 30, + }, + followPaginationLinks: { + type: "boolean", + label: "Follow Pagination Links", + description: "If true, a best effort attempt to load more content items than are originally displayed on the page will be made. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/windows/page-query) for more information", + optional: true, + default: false, + }, + costThresholdCredits: { + type: "integer", + label: "Cost Threshold (Credits)", + description: "A credit threshold that, once exceeded, will cause the operation to be cancelled. [See the documentation](https://docs.airtop.ai/guides/misc/faq#how-does-the-credit-system-work) for more information.", + optional: true, + }, + timeThresholdSeconds: { + type: "integer", + label: "Time Threshold (Seconds)", + description: "A time threshold that, once exceeded, will cause the operation to be cancelled. This is checked periodically. Set to 0 to disable (not recommended).", + optional: true, + }, + }, methods: { - // this.$auth contains connected account data - authKeys() { - console.log(Object.keys(this.$auth)); + _headers() { + return { + "Authorization": `Bearer ${this.$auth.api_key}`, + "Content-Type": "application/json", + }; + }, + async _makeRequest({ + $ = this, headers, ...args + }) { + const response = await axios($, { + baseURL: "https://api.airtop.ai/api/v1", + headers: { + ...this._headers(), + ...headers, + }, + ...args, + }); + return response.data; + }, + async createSession(args = {}) { + return this._makeRequest({ + method: "POST", + url: "/sessions", + ...args, + }); + }, + async listSessions(args = {}) { + return this._makeRequest({ + url: "/sessions", + ...args, + }); + }, + async getSession({ + sessionId, ...args + }) { + return this._makeRequest({ + url: `/sessions/${sessionId}`, + ...args, + }); + }, + async endSession({ + sessionId, ...args + }) { + return this._makeRequest({ + method: "DELETE", + url: `/sessions/${sessionId}`, + ...args, + }); + }, + async createWindow({ + sessionId, ...args + }) { + return this._makeRequest({ + method: "POST", + url: `/sessions/${sessionId}/windows`, + ...args, + }); + }, + async listWindows({ + sessionId, ...args + }) { + return this._makeRequest({ + url: `/sessions/${sessionId}/windows`, + ...args, + }); + }, + async getWindow({ + sessionId, windowId, ...args + }) { + return this._makeRequest({ + url: `/sessions/${sessionId}/windows/${windowId}`, + ...args, + }); + }, + async loadUrl({ + sessionId, windowId, ...args + }) { + return this._makeRequest({ + method: "POST", + url: `/sessions/${sessionId}/windows/${windowId}`, + ...args, + }); + }, + async queryPage({ + sessionId, windowId, ...args + }) { + return this._makeRequest({ + method: "POST", + url: `/sessions/${sessionId}/windows/${windowId}/page-query`, + ...args, + }); + }, + async scrapeContent({ + sessionId, windowId, ...args + }) { + return this._makeRequest({ + method: "POST", + url: `/sessions/${sessionId}/windows/${windowId}/scrape-content`, + ...args, + }); + }, + async saveProfileOnTermination({ + sessionId, profileName, ...args + }) { + return this._makeRequest({ + method: "PUT", + url: `/sessions/${sessionId}/save-profile-on-termination/${encodeURIComponent(profileName)}`, + ...args, + }); }, }, -}; \ No newline at end of file +}; diff --git a/components/airtop/common/utils.mjs b/components/airtop/common/utils.mjs new file mode 100644 index 0000000000000..f53750e91bd6e --- /dev/null +++ b/components/airtop/common/utils.mjs @@ -0,0 +1,52 @@ +function optionalParseAsJSON(value) { + try { + return JSON.parse(value); + } catch (e) { + return value; + } +} + +export function parseObjectEntries(value = {}) { + const obj = typeof value === "string" + ? JSON.parse(value) + : value; + return Object.fromEntries( + Object.entries(obj).map(([ + key, + value, + ]) => [ + key, + optionalParseAsJSON(value), + ]), + ); +} + +export function formatTimeAgo(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now - date) / 1000); + + if (diffInSeconds < 60) { + return "just now"; + } + + const diffInMinutes = Math.floor(diffInSeconds / 60); + if (diffInMinutes < 60) { + return `${diffInMinutes} minute${diffInMinutes !== 1 + ? "s" + : ""} ago`; + } + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) { + return `${diffInHours} hour${diffInHours !== 1 + ? "s" + : ""} ago`; + } + + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays} day${diffInDays !== 1 + ? "s" + : ""} ago`; +} + diff --git a/components/airtop/package.json b/components/airtop/package.json index 844d7e23d77da..a5c0920e5a800 100644 --- a/components/airtop/package.json +++ b/components/airtop/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/airtop", - "version": "0.0.1", + "version": "0.1.0", "description": "Pipedream Airtop Components", "main": "airtop.app.mjs", "keywords": [ @@ -11,5 +11,8 @@ "author": "Pipedream (https://pipedream.com/)", "publishConfig": { "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.1.0" } -} \ No newline at end of file +} diff --git a/components/airtop/sources/common/polling.mjs b/components/airtop/sources/common/polling.mjs new file mode 100644 index 0000000000000..7ebf776a81c7f --- /dev/null +++ b/components/airtop/sources/common/polling.mjs @@ -0,0 +1,53 @@ +import { DEFAULT_POLLING_SOURCE_TIMER_INTERVAL } from "@pipedream/platform"; +import airtop from "../../airtop.app.mjs"; + +export default { + props: { + airtop, + db: "$.service.db", + timer: { + type: "$.interface.timer", + default: { + intervalSeconds: DEFAULT_POLLING_SOURCE_TIMER_INTERVAL, + }, + }, + }, + methods: { + _getLastTs() { + return this.db.get("lastTs"); + }, + _setLastTs(lastTs) { + this.db.set("lastTs", lastTs); + }, + isNew(resource, lastTs) { + if (!resource.dateCreated || !lastTs) { + return true; + } + return new Date(resource.dateCreated).getTime() > lastTs; + }, + getResources() { + throw new Error("getResources is not implemented"); + }, + generateMeta() { + throw new Error("generateMeta is not implemented"); + }, + }, + async run() { + let lastTs = this._getLastTs(); + + const resources = await this.getResources(lastTs); + for (const resource of resources) { + const { dateCreated } = resource; + if (!lastTs || (dateCreated && new Date(dateCreated).getTime() > lastTs)) { + lastTs = new Date(dateCreated).getTime(); + } + const meta = this.generateMeta(resource); + this.$emit(resource, meta); + } + + if (lastTs) { + this._setLastTs(lastTs); + } + }, +}; + diff --git a/components/airtop/sources/new-session-created/new-session-created.mjs b/components/airtop/sources/new-session-created/new-session-created.mjs new file mode 100644 index 0000000000000..5271fde001a20 --- /dev/null +++ b/components/airtop/sources/new-session-created/new-session-created.mjs @@ -0,0 +1,59 @@ +import common from "../common/polling.mjs"; + +export default { + ...common, + key: "airtop-new-session-created", + name: "New Session Created", + description: "Emit new event when a new session is created in Airtop. [See the documentation](https://docs.airtop.ai/api-reference/airtop-api/sessions/list)", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + async getResources(lastTs) { + const resources = []; + let hasMore = true; + let offset = 0; + const limit = 25; + const isFirstRun = !lastTs; + + while (hasMore) { + const data = await this.airtop.listSessions({ + params: { + limit, + offset, + }, + }); + + if (!data?.sessions?.length) { + break; + } + + for (const resource of data.sessions) { + const isNewResource = this.isNew(resource, lastTs); + if (isNewResource) { + resources.push(resource); + } + } + + hasMore = data.pagination?.hasMore; + offset = data.pagination?.nextOffset; + + // Stop on first run or on last page + if (isFirstRun || !hasMore) { + break; + } + } + + return resources; + }, + generateMeta(session) { + return { + id: session.id, + summary: `New Session: ${session.id.slice(0, 8)}`, + ts: new Date(session.dateCreated).getTime(), + }; + }, + }, +}; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f686711b10b3e..06f950c80da9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -623,7 +623,11 @@ importers: specifier: ^2.30.1 version: 2.30.1 - components/airtop: {} + components/airtop: + dependencies: + '@pipedream/platform': + specifier: ^3.1.0 + version: 3.1.0 components/aitable_ai: dependencies: @@ -31365,22 +31369,22 @@ packages: superagent@3.8.1: resolution: {integrity: sha512-VMBFLYgFuRdfeNQSMLbxGSLfmXL/xc+OO+BZp41Za/NRDBet/BNbkRJrYzCUu0u4GU0i/ml2dtT8b9qgkw9z6Q==} engines: {node: '>= 4.0'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@4.1.0: resolution: {integrity: sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag==} engines: {node: '>= 6.0'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@5.3.1: resolution: {integrity: sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==} engines: {node: '>= 7.0.0'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@7.1.6: resolution: {integrity: sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net supports-color@10.0.0: resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}