@steipete/oracle 0.9.0 → 0.11.0
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.
- package/LICENSE +1 -1
- package/README.md +107 -49
- package/dist/bin/oracle-cli.js +551 -410
- package/dist/bin/oracle-mcp.js +2 -2
- package/dist/bin/oracle.js +165 -279
- package/dist/scripts/agent-send.js +31 -31
- package/dist/scripts/check.js +6 -6
- package/dist/scripts/debug/extract-chatgpt-response.js +10 -10
- package/dist/scripts/docs-list.js +30 -30
- package/dist/scripts/git-policy.js +25 -23
- package/dist/scripts/run-cli.js +8 -8
- package/dist/scripts/runner.js +203 -195
- package/dist/scripts/test-browser.js +21 -18
- package/dist/scripts/test-remote-chrome.js +20 -20
- package/dist/src/bridge/connection.js +18 -18
- package/dist/src/bridge/userConfigFile.js +7 -7
- package/dist/src/browser/actions/archiveConversation.js +224 -0
- package/dist/src/browser/actions/assistantResponse.js +175 -101
- package/dist/src/browser/actions/attachmentDataTransfer.js +49 -47
- package/dist/src/browser/actions/attachments.js +246 -150
- package/dist/src/browser/actions/deepResearch.js +662 -0
- package/dist/src/browser/actions/domEvents.js +2 -2
- package/dist/src/browser/actions/modelSelection.js +342 -119
- package/dist/src/browser/actions/navigation.js +183 -137
- package/dist/src/browser/actions/projectSources.js +491 -0
- package/dist/src/browser/actions/promptComposer.js +152 -91
- package/dist/src/browser/actions/remoteFileTransfer.js +10 -10
- package/dist/src/browser/actions/thinkingStatus.js +391 -0
- package/dist/src/browser/actions/thinkingTime.js +207 -110
- package/dist/src/browser/artifacts.js +150 -0
- package/dist/src/browser/attachRunning.js +31 -0
- package/dist/src/browser/chatgptImages.js +315 -0
- package/dist/src/browser/chromeLifecycle.js +276 -63
- package/dist/src/browser/config.js +59 -16
- package/dist/src/browser/constants.js +25 -12
- package/dist/src/browser/controlPlan.js +81 -0
- package/dist/src/browser/cookies.js +19 -19
- package/dist/src/browser/detect.js +250 -77
- package/dist/src/browser/domDebug.js +50 -1
- package/dist/src/browser/index.js +1559 -692
- package/dist/src/browser/liveTabs.js +434 -0
- package/dist/src/browser/modelStrategy.js +1 -1
- package/dist/src/browser/pageActions.js +5 -5
- package/dist/src/browser/policies.js +16 -13
- package/dist/src/browser/profileState.js +127 -42
- package/dist/src/browser/projectSourcesRunner.js +366 -0
- package/dist/src/browser/prompt.js +72 -42
- package/dist/src/browser/promptSummary.js +5 -5
- package/dist/src/browser/providerDomFlow.js +1 -1
- package/dist/src/browser/providers/chatgptDomProvider.js +9 -9
- package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +51 -42
- package/dist/src/browser/providers/index.js +2 -2
- package/dist/src/browser/reattach.js +178 -73
- package/dist/src/browser/reattachHelpers.js +32 -27
- package/dist/src/browser/sessionRunner.js +89 -25
- package/dist/src/browser/tabLeaseRegistry.js +182 -0
- package/dist/src/browser/utils.js +9 -9
- package/dist/src/browserMode.js +1 -1
- package/dist/src/cli/bridge/claudeConfig.js +24 -20
- package/dist/src/cli/bridge/client.js +28 -20
- package/dist/src/cli/bridge/codexConfig.js +16 -16
- package/dist/src/cli/bridge/doctor.js +47 -39
- package/dist/src/cli/bridge/host.js +58 -56
- package/dist/src/cli/browserConfig.js +102 -48
- package/dist/src/cli/browserDefaults.js +51 -26
- package/dist/src/cli/browserTabs.js +228 -0
- package/dist/src/cli/bundleWarnings.js +1 -1
- package/dist/src/cli/clipboard.js +11 -2
- package/dist/src/cli/detach.js +2 -2
- package/dist/src/cli/dryRun.js +62 -26
- package/dist/src/cli/duplicatePromptGuard.js +12 -4
- package/dist/src/cli/engine.js +9 -9
- package/dist/src/cli/errorUtils.js +1 -1
- package/dist/src/cli/fileSize.js +3 -3
- package/dist/src/cli/format.js +2 -2
- package/dist/src/cli/help.js +28 -28
- package/dist/src/cli/hiddenAliases.js +3 -3
- package/dist/src/cli/markdownBundle.js +7 -7
- package/dist/src/cli/markdownRenderer.js +15 -15
- package/dist/src/cli/notifier.js +77 -67
- package/dist/src/cli/options.js +131 -106
- package/dist/src/cli/oscUtils.js +1 -1
- package/dist/src/cli/projectSources.js +116 -0
- package/dist/src/cli/promptRequirement.js +2 -2
- package/dist/src/cli/renderOutput.js +1 -1
- package/dist/src/cli/rootAlias.js +1 -1
- package/dist/src/cli/runOptions.js +32 -28
- package/dist/src/cli/sessionCommand.js +82 -21
- package/dist/src/cli/sessionDisplay.js +213 -87
- package/dist/src/cli/sessionLineage.js +6 -2
- package/dist/src/cli/sessionRunner.js +149 -95
- package/dist/src/cli/sessionTable.js +26 -23
- package/dist/src/cli/stdin.js +22 -0
- package/dist/src/cli/tagline.js +121 -124
- package/dist/src/cli/tui/index.js +139 -128
- package/dist/src/cli/writeOutputPath.js +5 -5
- package/dist/src/config.js +7 -7
- package/dist/src/gemini-web/browserSessionManager.js +19 -15
- package/dist/src/gemini-web/client.js +76 -70
- package/dist/src/gemini-web/executionMode.js +6 -8
- package/dist/src/gemini-web/executor.js +98 -93
- package/dist/src/gemini-web/index.js +1 -1
- package/dist/src/mcp/consultPresets.js +19 -0
- package/dist/src/mcp/server.js +18 -12
- package/dist/src/mcp/tools/consult.js +246 -67
- package/dist/src/mcp/tools/projectSources.js +123 -0
- package/dist/src/mcp/tools/sessionResources.js +12 -12
- package/dist/src/mcp/tools/sessions.js +26 -17
- package/dist/src/mcp/types.js +12 -5
- package/dist/src/mcp/utils.js +21 -8
- package/dist/src/oracle/background.js +15 -15
- package/dist/src/oracle/claude.js +53 -25
- package/dist/src/oracle/client.js +50 -41
- package/dist/src/oracle/config.js +96 -66
- package/dist/src/oracle/errors.js +38 -38
- package/dist/src/oracle/files.js +55 -46
- package/dist/src/oracle/finishLine.js +10 -8
- package/dist/src/oracle/format.js +3 -3
- package/dist/src/oracle/gemini.js +37 -33
- package/dist/src/oracle/logging.js +7 -7
- package/dist/src/oracle/markdown.js +28 -28
- package/dist/src/oracle/modelResolver.js +16 -16
- package/dist/src/oracle/multiModelRunner.js +12 -12
- package/dist/src/oracle/oscProgress.js +8 -8
- package/dist/src/oracle/promptAssembly.js +6 -3
- package/dist/src/oracle/request.js +16 -13
- package/dist/src/oracle/run.js +160 -135
- package/dist/src/oracle/runUtils.js +8 -5
- package/dist/src/oracle/tokenEstimate.js +6 -6
- package/dist/src/oracle/tokenStats.js +5 -5
- package/dist/src/oracle/tokenStringifier.js +5 -5
- package/dist/src/oracle.js +12 -12
- package/dist/src/oracleHome.js +3 -3
- package/dist/src/projectSources/plan.js +27 -0
- package/dist/src/projectSources/url.js +23 -0
- package/dist/src/remote/client.js +25 -25
- package/dist/src/remote/health.js +20 -20
- package/dist/src/remote/remoteServiceConfig.js +9 -9
- package/dist/src/remote/server.js +129 -118
- package/dist/src/sessionManager.js +78 -75
- package/dist/src/sessionStore.js +3 -3
- package/dist/src/version.js +10 -10
- package/dist/vendor/oracle-notifier/README.md +2 -0
- package/package.json +67 -62
- package/vendor/oracle-notifier/README.md +2 -0
- package/dist/markdansi/types/index.js +0 -4
- package/dist/oracle/bin/oracle-cli.js +0 -472
- package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
- package/dist/oracle/src/browser/actions/attachments.js +0 -82
- package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
- package/dist/oracle/src/browser/actions/navigation.js +0 -75
- package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
- package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
- package/dist/oracle/src/browser/config.js +0 -33
- package/dist/oracle/src/browser/constants.js +0 -40
- package/dist/oracle/src/browser/cookies.js +0 -210
- package/dist/oracle/src/browser/domDebug.js +0 -36
- package/dist/oracle/src/browser/index.js +0 -331
- package/dist/oracle/src/browser/pageActions.js +0 -5
- package/dist/oracle/src/browser/prompt.js +0 -88
- package/dist/oracle/src/browser/promptSummary.js +0 -20
- package/dist/oracle/src/browser/sessionRunner.js +0 -80
- package/dist/oracle/src/browser/utils.js +0 -62
- package/dist/oracle/src/browserMode.js +0 -1
- package/dist/oracle/src/cli/browserConfig.js +0 -44
- package/dist/oracle/src/cli/dryRun.js +0 -59
- package/dist/oracle/src/cli/engine.js +0 -17
- package/dist/oracle/src/cli/errorUtils.js +0 -9
- package/dist/oracle/src/cli/help.js +0 -70
- package/dist/oracle/src/cli/markdownRenderer.js +0 -15
- package/dist/oracle/src/cli/options.js +0 -103
- package/dist/oracle/src/cli/promptRequirement.js +0 -14
- package/dist/oracle/src/cli/rootAlias.js +0 -30
- package/dist/oracle/src/cli/sessionCommand.js +0 -77
- package/dist/oracle/src/cli/sessionDisplay.js +0 -270
- package/dist/oracle/src/cli/sessionRunner.js +0 -94
- package/dist/oracle/src/heartbeat.js +0 -43
- package/dist/oracle/src/oracle/client.js +0 -48
- package/dist/oracle/src/oracle/config.js +0 -29
- package/dist/oracle/src/oracle/errors.js +0 -101
- package/dist/oracle/src/oracle/files.js +0 -220
- package/dist/oracle/src/oracle/format.js +0 -33
- package/dist/oracle/src/oracle/fsAdapter.js +0 -7
- package/dist/oracle/src/oracle/oscProgress.js +0 -60
- package/dist/oracle/src/oracle/request.js +0 -48
- package/dist/oracle/src/oracle/run.js +0 -444
- package/dist/oracle/src/oracle/tokenStats.js +0 -39
- package/dist/oracle/src/oracle/types.js +0 -1
- package/dist/oracle/src/oracle.js +0 -9
- package/dist/oracle/src/sessionManager.js +0 -205
- package/dist/oracle/src/version.js +0 -39
- package/dist/scripts/chrome/browser-tools.js +0 -295
- package/dist/src/browser/profileSync.js +0 -141
- /package/dist/{oracle/src/browser → src/projectSources}/types.js +0 -0
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
export const CHATGPT_URL =
|
|
2
|
-
export const DEFAULT_MODEL_TARGET =
|
|
3
|
-
export const DEFAULT_MODEL_STRATEGY =
|
|
4
|
-
export const COOKIE_URLS = [
|
|
1
|
+
export const CHATGPT_URL = "https://chatgpt.com/";
|
|
2
|
+
export const DEFAULT_MODEL_TARGET = "GPT-5.5 Pro";
|
|
3
|
+
export const DEFAULT_MODEL_STRATEGY = "select";
|
|
4
|
+
export const COOKIE_URLS = [
|
|
5
|
+
"https://chatgpt.com",
|
|
6
|
+
"https://chat.openai.com",
|
|
7
|
+
"https://atlas.openai.com",
|
|
8
|
+
];
|
|
5
9
|
export const INPUT_SELECTORS = [
|
|
6
10
|
'textarea[data-id="prompt-textarea"]',
|
|
7
11
|
'textarea[placeholder*="Send a message"]',
|
|
12
|
+
'textarea[aria-label="Chat with ChatGPT"]',
|
|
8
13
|
'textarea[aria-label="Message ChatGPT"]',
|
|
9
|
-
|
|
14
|
+
"textarea:not([disabled])",
|
|
10
15
|
'textarea[name="prompt-textarea"]',
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
"#prompt-textarea",
|
|
17
|
+
".ProseMirror",
|
|
18
|
+
'[contenteditable="true"][role="textbox"]',
|
|
13
19
|
'[contenteditable="true"][data-virtualkeyboard="true"]',
|
|
14
20
|
];
|
|
15
21
|
export const ANSWER_SELECTORS = [
|
|
@@ -24,12 +30,12 @@ export const ANSWER_SELECTORS = [
|
|
|
24
30
|
'[data-turn="assistant"]',
|
|
25
31
|
];
|
|
26
32
|
export const CONVERSATION_TURN_SELECTOR = 'article[data-testid^="conversation-turn"], div[data-testid^="conversation-turn"], section[data-testid^="conversation-turn"], ' +
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
"article[data-message-author-role], div[data-message-author-role], section[data-message-author-role], " +
|
|
34
|
+
"article[data-turn], div[data-turn], section[data-turn]";
|
|
29
35
|
export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"], [data-turn="assistant"]';
|
|
30
36
|
export const CLOUDFLARE_SCRIPT_SELECTOR = 'script[src*="/challenge-platform/"]';
|
|
31
|
-
export const CLOUDFLARE_TITLE =
|
|
32
|
-
export const PROMPT_PRIMARY_SELECTOR =
|
|
37
|
+
export const CLOUDFLARE_TITLE = "just a moment";
|
|
38
|
+
export const PROMPT_PRIMARY_SELECTOR = "#prompt-textarea";
|
|
33
39
|
export const PROMPT_FALLBACK_SELECTOR = 'textarea[name="prompt-textarea"]';
|
|
34
40
|
export const FILE_INPUT_SELECTORS = [
|
|
35
41
|
'form input[type="file"]:not([accept])',
|
|
@@ -65,7 +71,14 @@ export const SEND_BUTTON_SELECTORS = [
|
|
|
65
71
|
'button[aria-label*="Send"]',
|
|
66
72
|
];
|
|
67
73
|
export const SEND_BUTTON_SELECTOR = SEND_BUTTON_SELECTORS[0];
|
|
68
|
-
export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"]';
|
|
74
|
+
export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"], button.__composer-pill[aria-haspopup="menu"]';
|
|
75
|
+
export const COMPOSER_MODEL_SIGNAL_SELECTOR = '[data-testid="composer-footer-actions"]';
|
|
69
76
|
export const COPY_BUTTON_SELECTOR = 'button[data-testid="copy-turn-action-button"]';
|
|
70
77
|
// Action buttons that only appear once a turn has finished rendering.
|
|
78
|
+
export const DEEP_RESEARCH_PLUS_BUTTON = '[data-testid="composer-plus-btn"]';
|
|
79
|
+
export const DEEP_RESEARCH_DROPDOWN_ITEM_TEXT = "Deep research";
|
|
80
|
+
export const DEEP_RESEARCH_PILL_LABEL = "Deep research";
|
|
81
|
+
export const DEEP_RESEARCH_POLL_INTERVAL_MS = 5_000;
|
|
82
|
+
export const DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS = 70_000;
|
|
83
|
+
export const DEEP_RESEARCH_DEFAULT_TIMEOUT_MS = 2_400_000;
|
|
71
84
|
export const FINISHED_ACTIONS_SELECTOR = 'button[data-testid="copy-turn-action-button"], button[data-testid="good-response-turn-action-button"], button[data-testid="bad-response-turn-action-button"], button[aria-label="Share"]';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function describeBrowserControlPlan(config = {}) {
|
|
2
|
+
const guidance = [];
|
|
3
|
+
const tabRef = String(config.browserTabRef ?? "").trim();
|
|
4
|
+
const reusesExistingTab = tabRef.length > 0;
|
|
5
|
+
if (config.attachRunning) {
|
|
6
|
+
guidance.push(reusesExistingTab
|
|
7
|
+
? `Oracle reuses the matching ChatGPT tab (${tabRef}) and leaves the existing browser process alone.`
|
|
8
|
+
: "Oracle opens a dedicated tab and leaves the existing browser process alone.");
|
|
9
|
+
if (config.keepBrowser) {
|
|
10
|
+
guidance.push("The browser stays open because Oracle did not launch it.");
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
mode: "attach-running",
|
|
14
|
+
launchesChrome: false,
|
|
15
|
+
mayFocusWindow: true,
|
|
16
|
+
summary: reusesExistingTab
|
|
17
|
+
? "attach to an already-running local Chrome tab"
|
|
18
|
+
: "attach to an already-running local Chrome session",
|
|
19
|
+
guidance,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (config.remoteChrome) {
|
|
23
|
+
guidance.push(reusesExistingTab
|
|
24
|
+
? `Oracle reuses the matching ChatGPT tab (${tabRef}) in the configured remote Chrome session.`
|
|
25
|
+
: "Oracle opens a dedicated tab in the configured remote Chrome session.");
|
|
26
|
+
guidance.push("Local Chrome launch, cookie copy, and window hiding flags are skipped.");
|
|
27
|
+
return {
|
|
28
|
+
mode: "remote-chrome",
|
|
29
|
+
launchesChrome: false,
|
|
30
|
+
mayFocusWindow: false,
|
|
31
|
+
summary: reusesExistingTab
|
|
32
|
+
? "reuse an existing remote Chrome tab"
|
|
33
|
+
: "reuse an existing remote Chrome session",
|
|
34
|
+
guidance,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (config.headless) {
|
|
38
|
+
guidance.push("Headless mode avoids visible UI but may be blocked by ChatGPT or Cloudflare.");
|
|
39
|
+
return {
|
|
40
|
+
mode: "headless",
|
|
41
|
+
launchesChrome: true,
|
|
42
|
+
mayFocusWindow: false,
|
|
43
|
+
summary: "launch headless Chrome",
|
|
44
|
+
guidance,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (config.hideWindow) {
|
|
48
|
+
guidance.push("Chrome may briefly focus while launching before Oracle hides it.");
|
|
49
|
+
guidance.push("For the calmest shared-desktop flow, prefer --browser-attach-running or --remote-chrome.");
|
|
50
|
+
return {
|
|
51
|
+
mode: "hidden-window",
|
|
52
|
+
launchesChrome: true,
|
|
53
|
+
mayFocusWindow: true,
|
|
54
|
+
summary: "launch Chrome and hide the window after startup",
|
|
55
|
+
guidance,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
guidance.push(config.manualLogin
|
|
59
|
+
? "Manual-login mode may show the persistent Oracle Chrome profile for sign-in or automation."
|
|
60
|
+
: "A visible automation Chrome window may take focus while Oracle controls ChatGPT.");
|
|
61
|
+
guidance.push("Use --browser-hide-window, --browser-attach-running, or --remote-chrome to reduce desktop disruption.");
|
|
62
|
+
if (config.keepBrowser) {
|
|
63
|
+
guidance.push("Chrome will remain open after the run because --browser-keep-browser is enabled.");
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
mode: "visible-window",
|
|
67
|
+
launchesChrome: true,
|
|
68
|
+
mayFocusWindow: true,
|
|
69
|
+
summary: "launch visible Chrome",
|
|
70
|
+
guidance,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function formatBrowserControlPlan(plan, label = "browser") {
|
|
74
|
+
const risk = plan.mayFocusWindow
|
|
75
|
+
? "may focus/control the browser UI"
|
|
76
|
+
: "does not use a visible local browser window";
|
|
77
|
+
return [
|
|
78
|
+
`[${label}] Browser control: ${plan.summary}; ${risk}.`,
|
|
79
|
+
...plan.guidance.map((entry) => `[${label}] Browser guidance: ${entry}`),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { COOKIE_URLS } from
|
|
2
|
-
import { delay } from
|
|
3
|
-
import { getCookies } from
|
|
1
|
+
import { COOKIE_URLS } from "./constants.js";
|
|
2
|
+
import { delay } from "./utils.js";
|
|
3
|
+
import { getCookies } from "@steipete/sweet-cookie";
|
|
4
4
|
export class ChromeCookieSyncError extends Error {
|
|
5
5
|
}
|
|
6
6
|
export async function syncCookies(Network, url, profile, logger, options = {}) {
|
|
@@ -55,7 +55,7 @@ async function readChromeCookiesWithWait(url, profile, filterNames, cookiePath,
|
|
|
55
55
|
return cookies;
|
|
56
56
|
}
|
|
57
57
|
const waitLabel = waitMs >= 1000 ? `${Math.round(waitMs / 1000)}s` : `${waitMs}ms`;
|
|
58
|
-
const message = firstError instanceof Error ? firstError.message : String(firstError ??
|
|
58
|
+
const message = firstError instanceof Error ? firstError.message : String(firstError ?? "");
|
|
59
59
|
if (firstError) {
|
|
60
60
|
logger(`[cookies] Cookie read failed (${message}); waiting ${waitLabel} then retrying once.`);
|
|
61
61
|
}
|
|
@@ -68,27 +68,27 @@ async function readChromeCookiesWithWait(url, profile, filterNames, cookiePath,
|
|
|
68
68
|
async function readChromeCookies(url, profile, filterNames, cookiePath) {
|
|
69
69
|
const origins = Array.from(new Set([stripQuery(url), ...COOKIE_URLS]));
|
|
70
70
|
const chromeProfile = cookiePath ?? profile ?? undefined;
|
|
71
|
-
const timeoutMs = readDuration(
|
|
71
|
+
const timeoutMs = readDuration("ORACLE_COOKIE_LOAD_TIMEOUT_MS", 5_000);
|
|
72
72
|
// Learned: read from multiple origins to capture auth cookies that land on chat.openai.com + atlas.
|
|
73
73
|
const { cookies, warnings } = await getCookies({
|
|
74
74
|
url,
|
|
75
75
|
origins,
|
|
76
76
|
names: filterNames?.length ? filterNames : undefined,
|
|
77
|
-
browsers: [
|
|
78
|
-
mode:
|
|
77
|
+
browsers: ["chrome"],
|
|
78
|
+
mode: "merge",
|
|
79
79
|
chromeProfile,
|
|
80
80
|
timeoutMs,
|
|
81
81
|
});
|
|
82
|
-
if (process.env.ORACLE_DEBUG_COOKIES ===
|
|
82
|
+
if (process.env.ORACLE_DEBUG_COOKIES === "1" && warnings.length) {
|
|
83
83
|
// eslint-disable-next-line no-console
|
|
84
|
-
console.log(`[cookies] sweet-cookie warnings:\n- ${warnings.join(
|
|
84
|
+
console.log(`[cookies] sweet-cookie warnings:\n- ${warnings.join("\n- ")}`);
|
|
85
85
|
}
|
|
86
86
|
const merged = new Map();
|
|
87
87
|
for (const cookie of cookies) {
|
|
88
88
|
const normalized = toCdpCookie(cookie);
|
|
89
89
|
if (!normalized)
|
|
90
90
|
continue;
|
|
91
|
-
const key = `${normalized.domain ??
|
|
91
|
+
const key = `${normalized.domain ?? ""}:${normalized.name}`;
|
|
92
92
|
if (!merged.has(key))
|
|
93
93
|
merged.set(key, normalized);
|
|
94
94
|
}
|
|
@@ -102,10 +102,10 @@ function normalizeInlineCookies(rawCookies, fallbackHost) {
|
|
|
102
102
|
// Learned: inline cookies may omit url/domain; default to current host with a safe path.
|
|
103
103
|
const normalized = {
|
|
104
104
|
name: cookie.name,
|
|
105
|
-
value: cookie.value ??
|
|
105
|
+
value: cookie.value ?? "",
|
|
106
106
|
url: cookie.url,
|
|
107
107
|
domain: cookie.domain ?? fallbackHost,
|
|
108
|
-
path: cookie.path ??
|
|
108
|
+
path: cookie.path ?? "/",
|
|
109
109
|
expires: normalizeExpiration(cookie.expires),
|
|
110
110
|
secure: cookie.secure ?? true,
|
|
111
111
|
httpOnly: cookie.httpOnly ?? false,
|
|
@@ -125,13 +125,13 @@ function toCdpCookie(cookie) {
|
|
|
125
125
|
name: cookie.name,
|
|
126
126
|
value: cookie.value,
|
|
127
127
|
domain: cookie.domain,
|
|
128
|
-
path: cookie.path ??
|
|
128
|
+
path: cookie.path ?? "/",
|
|
129
129
|
secure: cookie.secure ?? true,
|
|
130
130
|
httpOnly: cookie.httpOnly ?? false,
|
|
131
131
|
};
|
|
132
|
-
if (typeof cookie.expires ===
|
|
132
|
+
if (typeof cookie.expires === "number")
|
|
133
133
|
out.expires = cookie.expires;
|
|
134
|
-
if (cookie.sameSite ===
|
|
134
|
+
if (cookie.sameSite === "Lax" || cookie.sameSite === "Strict" || cookie.sameSite === "None") {
|
|
135
135
|
out.sameSite = cookie.sameSite;
|
|
136
136
|
}
|
|
137
137
|
return out;
|
|
@@ -139,10 +139,10 @@ function toCdpCookie(cookie) {
|
|
|
139
139
|
function attachUrl(cookie, fallbackUrl) {
|
|
140
140
|
const cookieWithUrl = { ...cookie };
|
|
141
141
|
if (!cookieWithUrl.url) {
|
|
142
|
-
if (!cookieWithUrl.domain || cookieWithUrl.domain ===
|
|
142
|
+
if (!cookieWithUrl.domain || cookieWithUrl.domain === "localhost") {
|
|
143
143
|
cookieWithUrl.url = fallbackUrl;
|
|
144
144
|
}
|
|
145
|
-
else if (!cookieWithUrl.domain.startsWith(
|
|
145
|
+
else if (!cookieWithUrl.domain.startsWith(".")) {
|
|
146
146
|
cookieWithUrl.url = `https://${cookieWithUrl.domain}`;
|
|
147
147
|
}
|
|
148
148
|
}
|
|
@@ -156,8 +156,8 @@ function attachUrl(cookie, fallbackUrl) {
|
|
|
156
156
|
function stripQuery(url) {
|
|
157
157
|
try {
|
|
158
158
|
const parsed = new URL(url);
|
|
159
|
-
parsed.hash =
|
|
160
|
-
parsed.search =
|
|
159
|
+
parsed.hash = "";
|
|
160
|
+
parsed.search = "";
|
|
161
161
|
return parsed.toString();
|
|
162
162
|
}
|
|
163
163
|
catch {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import os from
|
|
3
|
-
import path from
|
|
4
|
-
import { Launcher } from
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Launcher } from "chrome-launcher";
|
|
5
5
|
export async function detectChromeBinary() {
|
|
6
|
-
const envPath = (process.env.CHROME_PATH ??
|
|
6
|
+
const envPath = (process.env.CHROME_PATH ?? "").trim();
|
|
7
7
|
if (envPath) {
|
|
8
8
|
const ok = await isExecutable(envPath);
|
|
9
9
|
if (ok) {
|
|
@@ -14,7 +14,7 @@ export async function detectChromeBinary() {
|
|
|
14
14
|
if (launcherDetected) {
|
|
15
15
|
return { path: launcherDetected };
|
|
16
16
|
}
|
|
17
|
-
const candidates = platformChromeCandidates();
|
|
17
|
+
const candidates = platformChromeCandidates(process.platform, os.homedir());
|
|
18
18
|
for (const candidate of candidates.absolutePaths) {
|
|
19
19
|
if (await isExecutable(candidate)) {
|
|
20
20
|
return { path: candidate };
|
|
@@ -26,112 +26,234 @@ export async function detectChromeBinary() {
|
|
|
26
26
|
}
|
|
27
27
|
return { path: null };
|
|
28
28
|
}
|
|
29
|
-
export async function detectChromeCookieDb({ profile }) {
|
|
30
|
-
const profileName = profile?.trim() ? profile.trim() :
|
|
31
|
-
if (process.platform ===
|
|
29
|
+
export async function detectChromeCookieDb({ profile, }) {
|
|
30
|
+
const profileName = profile?.trim() ? profile.trim() : "Default";
|
|
31
|
+
if (process.platform === "win32") {
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
|
-
const roots =
|
|
34
|
+
const roots = resolveAttachRunningProfileRoots();
|
|
35
35
|
for (const root of roots) {
|
|
36
|
-
const dir = path.join(root, profileName);
|
|
37
|
-
const direct = path.join(dir,
|
|
36
|
+
const dir = path.join(root.root, profileName);
|
|
37
|
+
const direct = path.join(dir, "Cookies");
|
|
38
38
|
if (await isFile(direct))
|
|
39
39
|
return direct;
|
|
40
|
-
const network = path.join(dir,
|
|
40
|
+
const network = path.join(dir, "Network", "Cookies");
|
|
41
41
|
if (await isFile(network))
|
|
42
42
|
return network;
|
|
43
43
|
}
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
|
-
function
|
|
47
|
-
if (
|
|
46
|
+
export function resolveAttachRunningProfileRoots(platform = process.platform, homeDir = os.homedir()) {
|
|
47
|
+
if (platform === "darwin") {
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
family: "chrome",
|
|
51
|
+
root: path.join(homeDir, "Library", "Application Support", "Google", "Chrome"),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
family: "chromium",
|
|
55
|
+
root: path.join(homeDir, "Library", "Application Support", "Chromium"),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
family: "edge",
|
|
59
|
+
root: path.join(homeDir, "Library", "Application Support", "Microsoft Edge"),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
family: "brave",
|
|
63
|
+
root: path.join(homeDir, "Library", "Application Support", "BraveSoftware", "Brave-Browser"),
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
if (platform === "linux") {
|
|
68
|
+
return [
|
|
69
|
+
{ family: "chrome", root: path.join(homeDir, ".config", "google-chrome") },
|
|
70
|
+
{ family: "chromium", root: path.join(homeDir, ".config", "chromium") },
|
|
71
|
+
{ family: "edge", root: path.join(homeDir, ".config", "microsoft-edge") },
|
|
72
|
+
{
|
|
73
|
+
family: "brave",
|
|
74
|
+
root: path.join(homeDir, ".config", "BraveSoftware", "Brave-Browser"),
|
|
75
|
+
},
|
|
76
|
+
{ family: "chromium", root: path.join(homeDir, "snap", "chromium", "common", "chromium") },
|
|
77
|
+
{ family: "chromium", root: path.join(homeDir, "snap", "chromium", "current", "chromium") },
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
if (platform === "win32") {
|
|
81
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(homeDir, "AppData", "Local");
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
family: "chrome",
|
|
85
|
+
root: path.join(localAppData, "Google", "Chrome", "User Data"),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
family: "chromium",
|
|
89
|
+
root: path.join(localAppData, "Chromium", "User Data"),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
family: "edge",
|
|
93
|
+
root: path.join(localAppData, "Microsoft", "Edge", "User Data"),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
family: "brave",
|
|
97
|
+
root: path.join(localAppData, "BraveSoftware", "Brave-Browser", "User Data"),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
export function resolveDevToolsActivePortDiscoveryRoots(platform = process.platform, homeDir = os.homedir()) {
|
|
104
|
+
if (platform === "darwin") {
|
|
105
|
+
return [path.join(homeDir, "Library", "Application Support")];
|
|
106
|
+
}
|
|
107
|
+
if (platform === "linux") {
|
|
108
|
+
return [path.join(homeDir, ".config"), path.join(homeDir, "snap")];
|
|
109
|
+
}
|
|
110
|
+
if (platform === "win32") {
|
|
111
|
+
return [process.env.LOCALAPPDATA ?? path.join(homeDir, "AppData", "Local")];
|
|
112
|
+
}
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
export function inferAttachRunningBrowserFamily(chromePath) {
|
|
116
|
+
const normalized = chromePath?.trim().toLowerCase();
|
|
117
|
+
if (!normalized) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (normalized.includes("microsoft edge") || normalized.includes("msedge")) {
|
|
121
|
+
return "edge";
|
|
122
|
+
}
|
|
123
|
+
if (normalized.includes("brave")) {
|
|
124
|
+
return "brave";
|
|
125
|
+
}
|
|
126
|
+
if (normalized.includes("chromium")) {
|
|
127
|
+
return "chromium";
|
|
128
|
+
}
|
|
129
|
+
if (normalized.includes("chrome")) {
|
|
130
|
+
return "chrome";
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
export function parseDevToolsActivePort(raw, options = {}) {
|
|
135
|
+
const host = formatWebSocketHost(options.host ?? "127.0.0.1");
|
|
136
|
+
const [rawPort, rawBrowserPath] = raw.split(/\r?\n/u);
|
|
137
|
+
const port = Number.parseInt(rawPort?.trim() ?? "", 10);
|
|
138
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
|
|
139
|
+
throw new Error("DevToolsActivePort did not contain a valid port.");
|
|
140
|
+
}
|
|
141
|
+
const browserPath = rawBrowserPath?.trim() || "/devtools/browser";
|
|
142
|
+
const normalizedPath = browserPath.startsWith("/") ? browserPath : `/${browserPath}`;
|
|
143
|
+
return {
|
|
144
|
+
port,
|
|
145
|
+
browserWSEndpoint: `ws://${host}:${port}${normalizedPath}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export async function readDevToolsActivePortInfo(profileRoot, options = {}) {
|
|
149
|
+
const candidates = [
|
|
150
|
+
path.join(profileRoot, "DevToolsActivePort"),
|
|
151
|
+
path.join(profileRoot, "Default", "DevToolsActivePort"),
|
|
152
|
+
];
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
try {
|
|
155
|
+
const raw = await fs.readFile(candidate, "utf8");
|
|
156
|
+
const parsed = parseDevToolsActivePort(raw, options);
|
|
157
|
+
return { ...parsed, path: candidate };
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// ignore missing/unreadable candidates
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
export async function discoverDevToolsActivePortCandidates(options = {}) {
|
|
166
|
+
const { host, platform = process.platform, homeDir = os.homedir(), maxDepth = 6 } = options;
|
|
167
|
+
const roots = resolveDevToolsActivePortDiscoveryRoots(platform, homeDir);
|
|
168
|
+
const candidates = [];
|
|
169
|
+
const seenPaths = new Set();
|
|
170
|
+
for (const root of roots) {
|
|
171
|
+
await walkForDevToolsActivePort(root, maxDepth, async (candidatePath, stat) => {
|
|
172
|
+
if (seenPaths.has(candidatePath)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
seenPaths.add(candidatePath);
|
|
176
|
+
try {
|
|
177
|
+
const raw = await fs.readFile(candidatePath, "utf8");
|
|
178
|
+
const parsed = parseDevToolsActivePort(raw, { host });
|
|
179
|
+
candidates.push({
|
|
180
|
+
...parsed,
|
|
181
|
+
path: candidatePath,
|
|
182
|
+
profileRoot: deriveDevToolsProfileRoot(candidatePath),
|
|
183
|
+
mtimeMs: Number(stat.mtimeMs),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// ignore unreadable or malformed DevToolsActivePort files
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return candidates;
|
|
192
|
+
}
|
|
193
|
+
function platformChromeCandidates(platform = process.platform, homeDir = os.homedir()) {
|
|
194
|
+
if (platform === "linux") {
|
|
48
195
|
return {
|
|
49
196
|
binaryNames: [
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
197
|
+
"google-chrome",
|
|
198
|
+
"google-chrome-stable",
|
|
199
|
+
"chromium",
|
|
200
|
+
"chromium-browser",
|
|
201
|
+
"brave-browser",
|
|
202
|
+
"microsoft-edge",
|
|
203
|
+
"microsoft-edge-stable",
|
|
57
204
|
],
|
|
58
205
|
absolutePaths: [
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
206
|
+
"/usr/bin/google-chrome",
|
|
207
|
+
"/usr/bin/google-chrome-stable",
|
|
208
|
+
"/usr/bin/google-chrome-beta",
|
|
209
|
+
"/usr/bin/google-chrome-unstable",
|
|
210
|
+
"/usr/bin/chromium",
|
|
211
|
+
"/usr/bin/chromium-browser",
|
|
212
|
+
"/usr/bin/brave-browser",
|
|
213
|
+
"/usr/bin/microsoft-edge",
|
|
214
|
+
"/usr/bin/microsoft-edge-stable",
|
|
215
|
+
"/snap/bin/chromium",
|
|
216
|
+
"/snap/bin/brave",
|
|
217
|
+
"/snap/bin/brave-browser",
|
|
218
|
+
"/snap/bin/microsoft-edge",
|
|
219
|
+
"/opt/google/chrome/chrome",
|
|
73
220
|
],
|
|
74
221
|
};
|
|
75
222
|
}
|
|
76
|
-
if (
|
|
223
|
+
if (platform === "darwin") {
|
|
77
224
|
return {
|
|
78
225
|
binaryNames: [],
|
|
79
226
|
absolutePaths: [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
227
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
228
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
229
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
230
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
84
231
|
],
|
|
85
232
|
};
|
|
86
233
|
}
|
|
87
|
-
if (
|
|
88
|
-
const programFiles = process.env.ProgramFiles ??
|
|
89
|
-
const programFilesX86 = process.env[
|
|
90
|
-
const localAppData = process.env.LOCALAPPDATA ?? path.join(
|
|
234
|
+
if (platform === "win32") {
|
|
235
|
+
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
|
236
|
+
const programFilesX86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
237
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(homeDir, "AppData", "Local");
|
|
91
238
|
return {
|
|
92
239
|
binaryNames: [],
|
|
93
240
|
absolutePaths: [
|
|
94
|
-
path.join(programFiles,
|
|
95
|
-
path.join(programFilesX86,
|
|
96
|
-
path.join(localAppData,
|
|
97
|
-
path.join(programFiles,
|
|
98
|
-
path.join(programFilesX86,
|
|
241
|
+
path.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
|
242
|
+
path.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
243
|
+
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
|
244
|
+
path.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
245
|
+
path.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
99
246
|
],
|
|
100
247
|
};
|
|
101
248
|
}
|
|
102
249
|
return { binaryNames: [], absolutePaths: [] };
|
|
103
250
|
}
|
|
104
|
-
function platformProfileRoots() {
|
|
105
|
-
const home = os.homedir();
|
|
106
|
-
if (process.platform === 'darwin') {
|
|
107
|
-
return [
|
|
108
|
-
path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
|
|
109
|
-
path.join(home, 'Library', 'Application Support', 'Chromium'),
|
|
110
|
-
path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
|
|
111
|
-
path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser'),
|
|
112
|
-
];
|
|
113
|
-
}
|
|
114
|
-
if (process.platform === 'linux') {
|
|
115
|
-
return [
|
|
116
|
-
path.join(home, '.config', 'google-chrome'),
|
|
117
|
-
path.join(home, '.config', 'google-chrome-beta'),
|
|
118
|
-
path.join(home, '.config', 'google-chrome-unstable'),
|
|
119
|
-
path.join(home, '.config', 'chromium'),
|
|
120
|
-
path.join(home, '.config', 'microsoft-edge'),
|
|
121
|
-
path.join(home, '.config', 'BraveSoftware', 'Brave-Browser'),
|
|
122
|
-
// Snap Chromium profiles
|
|
123
|
-
path.join(home, 'snap', 'chromium', 'common', 'chromium'),
|
|
124
|
-
path.join(home, 'snap', 'chromium', 'current', 'chromium'),
|
|
125
|
-
];
|
|
126
|
-
}
|
|
127
|
-
return [];
|
|
128
|
-
}
|
|
129
251
|
async function isExecutable(candidate) {
|
|
130
252
|
try {
|
|
131
253
|
const stat = await fs.stat(candidate);
|
|
132
254
|
if (!stat.isFile())
|
|
133
255
|
return false;
|
|
134
|
-
if (process.platform ===
|
|
256
|
+
if (process.platform === "win32")
|
|
135
257
|
return true;
|
|
136
258
|
// eslint-disable-next-line no-bitwise
|
|
137
259
|
return (stat.mode & 0o111) !== 0;
|
|
@@ -150,7 +272,7 @@ async function isFile(candidate) {
|
|
|
150
272
|
}
|
|
151
273
|
}
|
|
152
274
|
async function findOnPath(names) {
|
|
153
|
-
const rawPath = process.env.PATH ??
|
|
275
|
+
const rawPath = process.env.PATH ?? "";
|
|
154
276
|
const dirs = rawPath.split(path.delimiter).filter(Boolean);
|
|
155
277
|
for (const name of names) {
|
|
156
278
|
for (const dir of dirs) {
|
|
@@ -162,3 +284,54 @@ async function findOnPath(names) {
|
|
|
162
284
|
}
|
|
163
285
|
return null;
|
|
164
286
|
}
|
|
287
|
+
function deriveDevToolsProfileRoot(activePortPath) {
|
|
288
|
+
const parentDir = path.dirname(activePortPath);
|
|
289
|
+
if (path.basename(parentDir).toLowerCase() === "default") {
|
|
290
|
+
return path.dirname(parentDir);
|
|
291
|
+
}
|
|
292
|
+
return parentDir;
|
|
293
|
+
}
|
|
294
|
+
function formatWebSocketHost(host) {
|
|
295
|
+
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) {
|
|
296
|
+
return `[${host}]`;
|
|
297
|
+
}
|
|
298
|
+
return host;
|
|
299
|
+
}
|
|
300
|
+
async function walkForDevToolsActivePort(root, maxDepth, onFile) {
|
|
301
|
+
const stack = [{ dir: root, depth: 0 }];
|
|
302
|
+
while (stack.length > 0) {
|
|
303
|
+
const current = stack.pop();
|
|
304
|
+
if (!current) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
let entries;
|
|
308
|
+
try {
|
|
309
|
+
entries = await fs.readdir(current.dir, { withFileTypes: true });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
for (const entry of entries) {
|
|
315
|
+
const candidatePath = path.join(current.dir, entry.name);
|
|
316
|
+
if (entry.isSymbolicLink()) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (entry.isFile()) {
|
|
320
|
+
if (entry.name !== "DevToolsActivePort") {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const stat = await fs.stat(candidatePath);
|
|
325
|
+
await onFile(candidatePath, stat);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// ignore unreadable candidates
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (entry.isDirectory() && current.depth < maxDepth) {
|
|
333
|
+
stack.push({ dir: candidatePath, depth: current.depth + 1 });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|