@apitap/core 1.0.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 +60 -0
- package/README.md +362 -0
- package/SKILL.md +270 -0
- package/dist/auth/crypto.d.ts +31 -0
- package/dist/auth/crypto.js +66 -0
- package/dist/auth/crypto.js.map +1 -0
- package/dist/auth/handoff.d.ts +29 -0
- package/dist/auth/handoff.js +180 -0
- package/dist/auth/handoff.js.map +1 -0
- package/dist/auth/manager.d.ts +46 -0
- package/dist/auth/manager.js +127 -0
- package/dist/auth/manager.js.map +1 -0
- package/dist/auth/oauth-refresh.d.ts +16 -0
- package/dist/auth/oauth-refresh.js +91 -0
- package/dist/auth/oauth-refresh.js.map +1 -0
- package/dist/auth/refresh.d.ts +43 -0
- package/dist/auth/refresh.js +217 -0
- package/dist/auth/refresh.js.map +1 -0
- package/dist/capture/anti-bot.d.ts +15 -0
- package/dist/capture/anti-bot.js +43 -0
- package/dist/capture/anti-bot.js.map +1 -0
- package/dist/capture/blocklist.d.ts +6 -0
- package/dist/capture/blocklist.js +70 -0
- package/dist/capture/blocklist.js.map +1 -0
- package/dist/capture/body-diff.d.ts +8 -0
- package/dist/capture/body-diff.js +102 -0
- package/dist/capture/body-diff.js.map +1 -0
- package/dist/capture/body-variables.d.ts +13 -0
- package/dist/capture/body-variables.js +142 -0
- package/dist/capture/body-variables.js.map +1 -0
- package/dist/capture/domain.d.ts +8 -0
- package/dist/capture/domain.js +34 -0
- package/dist/capture/domain.js.map +1 -0
- package/dist/capture/entropy.d.ts +33 -0
- package/dist/capture/entropy.js +100 -0
- package/dist/capture/entropy.js.map +1 -0
- package/dist/capture/filter.d.ts +11 -0
- package/dist/capture/filter.js +49 -0
- package/dist/capture/filter.js.map +1 -0
- package/dist/capture/graphql.d.ts +21 -0
- package/dist/capture/graphql.js +99 -0
- package/dist/capture/graphql.js.map +1 -0
- package/dist/capture/idle.d.ts +23 -0
- package/dist/capture/idle.js +44 -0
- package/dist/capture/idle.js.map +1 -0
- package/dist/capture/monitor.d.ts +26 -0
- package/dist/capture/monitor.js +183 -0
- package/dist/capture/monitor.js.map +1 -0
- package/dist/capture/oauth-detector.d.ts +18 -0
- package/dist/capture/oauth-detector.js +96 -0
- package/dist/capture/oauth-detector.js.map +1 -0
- package/dist/capture/pagination.d.ts +9 -0
- package/dist/capture/pagination.js +40 -0
- package/dist/capture/pagination.js.map +1 -0
- package/dist/capture/parameterize.d.ts +17 -0
- package/dist/capture/parameterize.js +63 -0
- package/dist/capture/parameterize.js.map +1 -0
- package/dist/capture/scrubber.d.ts +5 -0
- package/dist/capture/scrubber.js +38 -0
- package/dist/capture/scrubber.js.map +1 -0
- package/dist/capture/session.d.ts +46 -0
- package/dist/capture/session.js +445 -0
- package/dist/capture/session.js.map +1 -0
- package/dist/capture/token-detector.d.ts +16 -0
- package/dist/capture/token-detector.js +62 -0
- package/dist/capture/token-detector.js.map +1 -0
- package/dist/capture/verifier.d.ts +17 -0
- package/dist/capture/verifier.js +147 -0
- package/dist/capture/verifier.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +930 -0
- package/dist/cli.js.map +1 -0
- package/dist/discovery/auth.d.ts +17 -0
- package/dist/discovery/auth.js +81 -0
- package/dist/discovery/auth.js.map +1 -0
- package/dist/discovery/fetch.d.ts +17 -0
- package/dist/discovery/fetch.js +59 -0
- package/dist/discovery/fetch.js.map +1 -0
- package/dist/discovery/frameworks.d.ts +11 -0
- package/dist/discovery/frameworks.js +249 -0
- package/dist/discovery/frameworks.js.map +1 -0
- package/dist/discovery/index.d.ts +21 -0
- package/dist/discovery/index.js +219 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/openapi.d.ts +13 -0
- package/dist/discovery/openapi.js +175 -0
- package/dist/discovery/openapi.js.map +1 -0
- package/dist/discovery/probes.d.ts +9 -0
- package/dist/discovery/probes.js +70 -0
- package/dist/discovery/probes.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/report.d.ts +52 -0
- package/dist/inspect/report.js +191 -0
- package/dist/inspect/report.js.map +1 -0
- package/dist/mcp.d.ts +8 -0
- package/dist/mcp.js +526 -0
- package/dist/mcp.js.map +1 -0
- package/dist/orchestration/browse.d.ts +38 -0
- package/dist/orchestration/browse.js +198 -0
- package/dist/orchestration/browse.js.map +1 -0
- package/dist/orchestration/cache.d.ts +15 -0
- package/dist/orchestration/cache.js +24 -0
- package/dist/orchestration/cache.js.map +1 -0
- package/dist/plugin.d.ts +17 -0
- package/dist/plugin.js +158 -0
- package/dist/plugin.js.map +1 -0
- package/dist/read/decoders/deepwiki.d.ts +2 -0
- package/dist/read/decoders/deepwiki.js +148 -0
- package/dist/read/decoders/deepwiki.js.map +1 -0
- package/dist/read/decoders/grokipedia.d.ts +2 -0
- package/dist/read/decoders/grokipedia.js +210 -0
- package/dist/read/decoders/grokipedia.js.map +1 -0
- package/dist/read/decoders/hackernews.d.ts +2 -0
- package/dist/read/decoders/hackernews.js +168 -0
- package/dist/read/decoders/hackernews.js.map +1 -0
- package/dist/read/decoders/index.d.ts +2 -0
- package/dist/read/decoders/index.js +12 -0
- package/dist/read/decoders/index.js.map +1 -0
- package/dist/read/decoders/reddit.d.ts +2 -0
- package/dist/read/decoders/reddit.js +142 -0
- package/dist/read/decoders/reddit.js.map +1 -0
- package/dist/read/decoders/twitter.d.ts +12 -0
- package/dist/read/decoders/twitter.js +187 -0
- package/dist/read/decoders/twitter.js.map +1 -0
- package/dist/read/decoders/wikipedia.d.ts +2 -0
- package/dist/read/decoders/wikipedia.js +66 -0
- package/dist/read/decoders/wikipedia.js.map +1 -0
- package/dist/read/decoders/youtube.d.ts +2 -0
- package/dist/read/decoders/youtube.js +69 -0
- package/dist/read/decoders/youtube.js.map +1 -0
- package/dist/read/extract.d.ts +25 -0
- package/dist/read/extract.js +320 -0
- package/dist/read/extract.js.map +1 -0
- package/dist/read/index.d.ts +14 -0
- package/dist/read/index.js +66 -0
- package/dist/read/index.js.map +1 -0
- package/dist/read/peek.d.ts +9 -0
- package/dist/read/peek.js +137 -0
- package/dist/read/peek.js.map +1 -0
- package/dist/read/types.d.ts +44 -0
- package/dist/read/types.js +3 -0
- package/dist/read/types.js.map +1 -0
- package/dist/replay/engine.d.ts +53 -0
- package/dist/replay/engine.js +441 -0
- package/dist/replay/engine.js.map +1 -0
- package/dist/replay/truncate.d.ts +16 -0
- package/dist/replay/truncate.js +92 -0
- package/dist/replay/truncate.js.map +1 -0
- package/dist/serve.d.ts +31 -0
- package/dist/serve.js +149 -0
- package/dist/serve.js.map +1 -0
- package/dist/skill/generator.d.ts +44 -0
- package/dist/skill/generator.js +419 -0
- package/dist/skill/generator.js.map +1 -0
- package/dist/skill/importer.d.ts +26 -0
- package/dist/skill/importer.js +80 -0
- package/dist/skill/importer.js.map +1 -0
- package/dist/skill/search.d.ts +19 -0
- package/dist/skill/search.js +51 -0
- package/dist/skill/search.js.map +1 -0
- package/dist/skill/signing.d.ts +16 -0
- package/dist/skill/signing.js +34 -0
- package/dist/skill/signing.js.map +1 -0
- package/dist/skill/ssrf.d.ts +27 -0
- package/dist/skill/ssrf.js +210 -0
- package/dist/skill/ssrf.js.map +1 -0
- package/dist/skill/store.d.ts +7 -0
- package/dist/skill/store.js +93 -0
- package/dist/skill/store.js.map +1 -0
- package/dist/stats/report.d.ts +26 -0
- package/dist/stats/report.js +157 -0
- package/dist/stats/report.js.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
- package/src/auth/crypto.ts +92 -0
- package/src/auth/handoff.ts +229 -0
- package/src/auth/manager.ts +140 -0
- package/src/auth/oauth-refresh.ts +120 -0
- package/src/auth/refresh.ts +300 -0
- package/src/capture/anti-bot.ts +63 -0
- package/src/capture/blocklist.ts +75 -0
- package/src/capture/body-diff.ts +109 -0
- package/src/capture/body-variables.ts +156 -0
- package/src/capture/domain.ts +34 -0
- package/src/capture/entropy.ts +121 -0
- package/src/capture/filter.ts +56 -0
- package/src/capture/graphql.ts +124 -0
- package/src/capture/idle.ts +45 -0
- package/src/capture/monitor.ts +224 -0
- package/src/capture/oauth-detector.ts +106 -0
- package/src/capture/pagination.ts +49 -0
- package/src/capture/parameterize.ts +68 -0
- package/src/capture/scrubber.ts +49 -0
- package/src/capture/session.ts +502 -0
- package/src/capture/token-detector.ts +76 -0
- package/src/capture/verifier.ts +171 -0
- package/src/cli.ts +1031 -0
- package/src/discovery/auth.ts +99 -0
- package/src/discovery/fetch.ts +85 -0
- package/src/discovery/frameworks.ts +231 -0
- package/src/discovery/index.ts +256 -0
- package/src/discovery/openapi.ts +230 -0
- package/src/discovery/probes.ts +76 -0
- package/src/index.ts +26 -0
- package/src/inspect/report.ts +247 -0
- package/src/mcp.ts +618 -0
- package/src/orchestration/browse.ts +250 -0
- package/src/orchestration/cache.ts +37 -0
- package/src/plugin.ts +188 -0
- package/src/read/decoders/deepwiki.ts +180 -0
- package/src/read/decoders/grokipedia.ts +246 -0
- package/src/read/decoders/hackernews.ts +198 -0
- package/src/read/decoders/index.ts +15 -0
- package/src/read/decoders/reddit.ts +158 -0
- package/src/read/decoders/twitter.ts +211 -0
- package/src/read/decoders/wikipedia.ts +75 -0
- package/src/read/decoders/youtube.ts +75 -0
- package/src/read/extract.ts +396 -0
- package/src/read/index.ts +78 -0
- package/src/read/peek.ts +175 -0
- package/src/read/types.ts +37 -0
- package/src/replay/engine.ts +559 -0
- package/src/replay/truncate.ts +116 -0
- package/src/serve.ts +189 -0
- package/src/skill/generator.ts +473 -0
- package/src/skill/importer.ts +107 -0
- package/src/skill/search.ts +76 -0
- package/src/skill/signing.ts +36 -0
- package/src/skill/ssrf.ts +238 -0
- package/src/skill/store.ts +107 -0
- package/src/stats/report.ts +208 -0
- package/src/types.ts +233 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// src/auth/refresh.ts
|
|
2
|
+
import type { SkillFile, StoredToken, StoredSession } from '../types.js';
|
|
3
|
+
import type { AuthManager } from './manager.js';
|
|
4
|
+
import { refreshOAuth, type OAuthRefreshResult } from './oauth-refresh.js';
|
|
5
|
+
|
|
6
|
+
export interface RefreshOptions {
|
|
7
|
+
domain: string;
|
|
8
|
+
refreshUrl?: string;
|
|
9
|
+
browserMode?: 'headless' | 'visible';
|
|
10
|
+
timeout?: number; // ms, default 30000, extended to 300000 for captcha
|
|
11
|
+
/** @internal Skip SSRF check — for testing only */
|
|
12
|
+
_skipSsrfCheck?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RefreshResult {
|
|
16
|
+
success: boolean;
|
|
17
|
+
tokens: Record<string, string>;
|
|
18
|
+
captchaDetected?: 'cloudflare' | 'recaptcha' | 'hcaptcha';
|
|
19
|
+
oauthRefreshed?: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Mutex to prevent concurrent refreshes for the same domain
|
|
24
|
+
const refreshLocks = new Map<string, Promise<RefreshResult>>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract token values from a request body string.
|
|
28
|
+
*
|
|
29
|
+
* @param body - Raw request body (string)
|
|
30
|
+
* @param tokenNames - JSON paths of tokens to extract (e.g., ["csrf_token", "data.nonce"])
|
|
31
|
+
* @returns Map of token name to value
|
|
32
|
+
*/
|
|
33
|
+
export function extractTokensFromRequest(
|
|
34
|
+
body: string,
|
|
35
|
+
tokenNames: string[]
|
|
36
|
+
): Record<string, string> {
|
|
37
|
+
const result: Record<string, string> = {};
|
|
38
|
+
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(body);
|
|
42
|
+
} catch {
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const path of tokenNames) {
|
|
51
|
+
const value = getNestedValue(parsed as Record<string, unknown>, path);
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
result[path] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
61
|
+
const parts = path.split('.');
|
|
62
|
+
let current: unknown = obj;
|
|
63
|
+
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (typeof current !== 'object' || current === null) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
current = (current as Record<string, unknown>)[part];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Detect captcha challenge in page content.
|
|
76
|
+
*/
|
|
77
|
+
export function detectCaptcha(
|
|
78
|
+
html: string
|
|
79
|
+
): 'cloudflare' | 'recaptcha' | 'hcaptcha' | null {
|
|
80
|
+
// Cloudflare challenge
|
|
81
|
+
if (
|
|
82
|
+
html.includes('Just a moment...') ||
|
|
83
|
+
html.includes('cdn-cgi/challenge-platform') ||
|
|
84
|
+
html.includes('cf-browser-verification')
|
|
85
|
+
) {
|
|
86
|
+
return 'cloudflare';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// reCAPTCHA
|
|
90
|
+
if (
|
|
91
|
+
html.includes('g-recaptcha') ||
|
|
92
|
+
html.includes('google.com/recaptcha')
|
|
93
|
+
) {
|
|
94
|
+
return 'recaptcha';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// hCaptcha
|
|
98
|
+
if (
|
|
99
|
+
html.includes('h-captcha') ||
|
|
100
|
+
html.includes('hcaptcha.com')
|
|
101
|
+
) {
|
|
102
|
+
return 'hcaptcha';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Refresh tokens by spawning a browser and intercepting requests.
|
|
110
|
+
*
|
|
111
|
+
* This is the main entry point for token refresh. It:
|
|
112
|
+
* 1. Launches a browser (visible if captchaRisk is true)
|
|
113
|
+
* 2. Navigates to the refresh URL
|
|
114
|
+
* 3. Intercepts outgoing requests to capture fresh token values
|
|
115
|
+
* 4. Returns the captured tokens
|
|
116
|
+
*
|
|
117
|
+
* @param skill - Skill file with auth config and endpoint info
|
|
118
|
+
* @param authManager - Auth manager for storing refreshed tokens
|
|
119
|
+
* @param options - Refresh options
|
|
120
|
+
*/
|
|
121
|
+
export async function refreshTokens(
|
|
122
|
+
skill: SkillFile,
|
|
123
|
+
authManager: AuthManager,
|
|
124
|
+
options: RefreshOptions
|
|
125
|
+
): Promise<RefreshResult> {
|
|
126
|
+
const { domain } = options;
|
|
127
|
+
|
|
128
|
+
// Check for existing refresh in progress (mutex)
|
|
129
|
+
const existingRefresh = refreshLocks.get(domain);
|
|
130
|
+
if (existingRefresh) {
|
|
131
|
+
return existingRefresh;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const refreshPromise = doRefresh(skill, authManager, options);
|
|
135
|
+
refreshLocks.set(domain, refreshPromise);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return await refreshPromise;
|
|
139
|
+
} finally {
|
|
140
|
+
refreshLocks.delete(domain);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function doRefresh(
|
|
145
|
+
skill: SkillFile,
|
|
146
|
+
authManager: AuthManager,
|
|
147
|
+
options: RefreshOptions
|
|
148
|
+
): Promise<RefreshResult> {
|
|
149
|
+
let oauthRefreshed = false;
|
|
150
|
+
|
|
151
|
+
// Step 1: OAuth path — if oauthConfig + stored credentials available
|
|
152
|
+
const oauthConfig = skill.auth?.oauthConfig;
|
|
153
|
+
if (oauthConfig) {
|
|
154
|
+
const oauthCreds = await authManager.retrieveOAuthCredentials(options.domain);
|
|
155
|
+
const canOAuth =
|
|
156
|
+
(oauthConfig.grantType === 'refresh_token' && oauthCreds?.refreshToken) ||
|
|
157
|
+
(oauthConfig.grantType === 'client_credentials');
|
|
158
|
+
|
|
159
|
+
if (canOAuth) {
|
|
160
|
+
const oauthResult = await refreshOAuth(options.domain, oauthConfig, authManager, { _skipSsrfCheck: options._skipSsrfCheck });
|
|
161
|
+
if (oauthResult.success) {
|
|
162
|
+
oauthRefreshed = true;
|
|
163
|
+
} else {
|
|
164
|
+
// OAuth failed — fall through to browser path if available
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Step 2: Browser path — if refreshable tokens exist or refreshUrl is present
|
|
170
|
+
const tokenNames = new Set<string>();
|
|
171
|
+
for (const endpoint of skill.endpoints) {
|
|
172
|
+
if (endpoint.requestBody?.refreshableTokens) {
|
|
173
|
+
for (const name of endpoint.requestBody.refreshableTokens) {
|
|
174
|
+
tokenNames.add(name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const needsBrowser = tokenNames.size > 0 || (skill.auth?.refreshUrl && !oauthRefreshed);
|
|
180
|
+
|
|
181
|
+
if (!needsBrowser) {
|
|
182
|
+
// No browser refresh needed — return OAuth result
|
|
183
|
+
return { success: oauthRefreshed, tokens: {}, oauthRefreshed: oauthRefreshed || undefined };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return doBrowserRefresh(skill, authManager, options, tokenNames, oauthRefreshed);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function doBrowserRefresh(
|
|
190
|
+
skill: SkillFile,
|
|
191
|
+
authManager: AuthManager,
|
|
192
|
+
options: RefreshOptions,
|
|
193
|
+
tokenNames: Set<string>,
|
|
194
|
+
oauthRefreshed: boolean
|
|
195
|
+
): Promise<RefreshResult> {
|
|
196
|
+
if (tokenNames.size === 0 && !skill.auth?.refreshUrl) {
|
|
197
|
+
return { success: oauthRefreshed, tokens: {}, oauthRefreshed: oauthRefreshed || undefined };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { chromium } = await import('playwright');
|
|
201
|
+
|
|
202
|
+
const browserMode = options.browserMode || skill.auth?.browserMode || 'headless';
|
|
203
|
+
const refreshUrl = options.refreshUrl || skill.auth?.refreshUrl || skill.baseUrl;
|
|
204
|
+
const timeout = options.timeout || (skill.auth?.captchaRisk ? 300_000 : 30_000);
|
|
205
|
+
|
|
206
|
+
// Try to restore session from cache
|
|
207
|
+
const cachedSession = await authManager.retrieveSession(options.domain);
|
|
208
|
+
const sessionValid = cachedSession && isSessionValid(cachedSession);
|
|
209
|
+
|
|
210
|
+
const browser = await chromium.launch({
|
|
211
|
+
headless: browserMode === 'headless',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const context = await browser.newContext();
|
|
216
|
+
|
|
217
|
+
// Restore cookies if session is valid
|
|
218
|
+
if (sessionValid && cachedSession) {
|
|
219
|
+
await context.addCookies(cachedSession.cookies);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const page = await context.newPage();
|
|
223
|
+
const capturedTokens: Record<string, string> = {};
|
|
224
|
+
let captchaDetected: 'cloudflare' | 'recaptcha' | 'hcaptcha' | null = null;
|
|
225
|
+
|
|
226
|
+
// Intercept requests to capture token values
|
|
227
|
+
if (tokenNames.size > 0) {
|
|
228
|
+
page.on('request', (request) => {
|
|
229
|
+
const body = request.postData();
|
|
230
|
+
if (body) {
|
|
231
|
+
const extracted = extractTokensFromRequest(body, [...tokenNames]);
|
|
232
|
+
Object.assign(capturedTokens, extracted);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Navigate and wait for network idle
|
|
238
|
+
await page.goto(refreshUrl, { waitUntil: 'networkidle', timeout });
|
|
239
|
+
|
|
240
|
+
// Check for captcha
|
|
241
|
+
const content = await page.content();
|
|
242
|
+
captchaDetected = detectCaptcha(content);
|
|
243
|
+
|
|
244
|
+
if (captchaDetected) {
|
|
245
|
+
// Extended timeout for captcha solving
|
|
246
|
+
console.error(`\u26a0\ufe0f Captcha detected (${captchaDetected}). Please solve it in the browser window.`);
|
|
247
|
+
await page.waitForTimeout(timeout);
|
|
248
|
+
|
|
249
|
+
// Re-check for tokens after captcha
|
|
250
|
+
// User interaction will trigger requests containing tokens
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Wait a bit for any final requests
|
|
254
|
+
await page.waitForTimeout(2000);
|
|
255
|
+
|
|
256
|
+
// Save session for next time
|
|
257
|
+
const cookies = await context.cookies();
|
|
258
|
+
await authManager.storeSession(options.domain, {
|
|
259
|
+
cookies,
|
|
260
|
+
savedAt: new Date().toISOString(),
|
|
261
|
+
maxAgeMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Store captured tokens
|
|
265
|
+
if (Object.keys(capturedTokens).length > 0) {
|
|
266
|
+
const storedTokens: Record<string, StoredToken> = {};
|
|
267
|
+
for (const [name, value] of Object.entries(capturedTokens)) {
|
|
268
|
+
storedTokens[name] = {
|
|
269
|
+
value,
|
|
270
|
+
refreshedAt: new Date().toISOString(),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
await authManager.storeTokens(options.domain, storedTokens);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const browserSuccess = tokenNames.size === 0 || Object.keys(capturedTokens).length > 0;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
success: oauthRefreshed || browserSuccess,
|
|
280
|
+
tokens: capturedTokens,
|
|
281
|
+
captchaDetected: captchaDetected || undefined,
|
|
282
|
+
oauthRefreshed: oauthRefreshed || undefined,
|
|
283
|
+
};
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return {
|
|
286
|
+
success: oauthRefreshed, // OAuth may have succeeded even if browser failed
|
|
287
|
+
tokens: {},
|
|
288
|
+
error: error instanceof Error ? error.message : String(error),
|
|
289
|
+
oauthRefreshed: oauthRefreshed || undefined,
|
|
290
|
+
};
|
|
291
|
+
} finally {
|
|
292
|
+
await browser.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isSessionValid(session: StoredSession): boolean {
|
|
297
|
+
const maxAge = session.maxAgeMs || 24 * 60 * 60 * 1000;
|
|
298
|
+
const savedAt = new Date(session.savedAt).getTime();
|
|
299
|
+
return Date.now() - savedAt < maxAge;
|
|
300
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/capture/anti-bot.ts
|
|
2
|
+
|
|
3
|
+
export type AntiBotSignal = 'cloudflare' | 'akamai' | 'rate-limited' | 'captcha' | 'challenge';
|
|
4
|
+
|
|
5
|
+
export interface AntiBotResult {
|
|
6
|
+
detected: boolean;
|
|
7
|
+
signals: AntiBotSignal[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect anti-bot protection signals from response headers and body.
|
|
12
|
+
*/
|
|
13
|
+
export function detectAntiBot(options: {
|
|
14
|
+
headers: Record<string, string>;
|
|
15
|
+
cookies?: string;
|
|
16
|
+
body?: string;
|
|
17
|
+
status?: number;
|
|
18
|
+
contentType?: string;
|
|
19
|
+
}): AntiBotResult {
|
|
20
|
+
const signals: AntiBotSignal[] = [];
|
|
21
|
+
const { headers, cookies, body, status, contentType } = options;
|
|
22
|
+
const headerLower = lowerKeys(headers);
|
|
23
|
+
|
|
24
|
+
// Cloudflare: cf-ray header or __cf_bm cookie
|
|
25
|
+
if (headerLower['cf-ray'] || headerLower['cf-cache-status'] || cookies?.includes('__cf_bm')) {
|
|
26
|
+
signals.push('cloudflare');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Akamai: _abck cookie
|
|
30
|
+
if (cookies?.includes('_abck')) {
|
|
31
|
+
signals.push('akamai');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Rate limiting: X-RateLimit-* or Retry-After
|
|
35
|
+
if (headerLower['retry-after'] ||
|
|
36
|
+
Object.keys(headerLower).some(k => k.startsWith('x-ratelimit'))) {
|
|
37
|
+
signals.push('rate-limited');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// CAPTCHA in response body
|
|
41
|
+
if (body && /\b(captcha|hcaptcha|recaptcha|cf-turnstile)\b/i.test(body)) {
|
|
42
|
+
signals.push('captcha');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Challenge page: HTML response when JSON expected + 403
|
|
46
|
+
if (status === 403 && contentType?.includes('text/html') &&
|
|
47
|
+
!contentType.includes('json')) {
|
|
48
|
+
signals.push('challenge');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
detected: signals.length > 0,
|
|
53
|
+
signals: [...new Set(signals)],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lowerKeys(obj: Record<string, string>): Record<string, string> {
|
|
58
|
+
const result: Record<string, string> = {};
|
|
59
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
60
|
+
result[k.toLowerCase()] = v;
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/capture/blocklist.ts
|
|
2
|
+
|
|
3
|
+
const BLOCKLIST = new Set([
|
|
4
|
+
// Analytics
|
|
5
|
+
'google-analytics.com',
|
|
6
|
+
'analytics.google.com',
|
|
7
|
+
'googletagmanager.com',
|
|
8
|
+
'segment.io',
|
|
9
|
+
'cdn.segment.com',
|
|
10
|
+
'mixpanel.com',
|
|
11
|
+
'amplitude.com',
|
|
12
|
+
'hotjar.com',
|
|
13
|
+
'heapanalytics.com',
|
|
14
|
+
'plausible.io',
|
|
15
|
+
'posthog.com',
|
|
16
|
+
'clarity.ms',
|
|
17
|
+
'fullstory.com',
|
|
18
|
+
|
|
19
|
+
// Ads
|
|
20
|
+
'doubleclick.net',
|
|
21
|
+
'googlesyndication.com',
|
|
22
|
+
'googleadservices.com',
|
|
23
|
+
'facebook.net',
|
|
24
|
+
'connect.facebook.net',
|
|
25
|
+
'adsrvr.org',
|
|
26
|
+
'adnxs.com',
|
|
27
|
+
'criteo.com',
|
|
28
|
+
'outbrain.com',
|
|
29
|
+
'taboola.com',
|
|
30
|
+
|
|
31
|
+
// Error tracking / monitoring
|
|
32
|
+
'sentry.io',
|
|
33
|
+
'datadoghq.com',
|
|
34
|
+
'browser-intake-datadoghq.com',
|
|
35
|
+
'newrelic.com',
|
|
36
|
+
'bam.nr-data.net',
|
|
37
|
+
'logrocket.com',
|
|
38
|
+
'logr-ingest.com',
|
|
39
|
+
'bugsnag.com',
|
|
40
|
+
'rollbar.com',
|
|
41
|
+
|
|
42
|
+
// Social tracking
|
|
43
|
+
'bat.bing.com',
|
|
44
|
+
'ct.pinterest.com',
|
|
45
|
+
'snap.licdn.com',
|
|
46
|
+
'px.ads.linkedin.com',
|
|
47
|
+
'analytics.twitter.com',
|
|
48
|
+
'analytics.tiktok.com',
|
|
49
|
+
|
|
50
|
+
// Customer engagement
|
|
51
|
+
'intercom.io',
|
|
52
|
+
'widget.intercom.io',
|
|
53
|
+
'api-iam.intercom.io',
|
|
54
|
+
'zendesk.com',
|
|
55
|
+
'drift.com',
|
|
56
|
+
'crisp.chat',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a hostname is on the blocklist.
|
|
61
|
+
* Matches exact hostnames and subdomains of blocklisted domains.
|
|
62
|
+
* e.g. "sentry.io" blocks "o123.ingest.sentry.io"
|
|
63
|
+
*/
|
|
64
|
+
export function isBlocklisted(hostname: string): boolean {
|
|
65
|
+
if (BLOCKLIST.has(hostname)) return true;
|
|
66
|
+
|
|
67
|
+
// Check parent domains: "a.b.sentry.io" → "b.sentry.io" → "sentry.io"
|
|
68
|
+
const parts = hostname.split('.');
|
|
69
|
+
for (let i = 1; i < parts.length - 1; i++) {
|
|
70
|
+
const parent = parts.slice(i).join('.');
|
|
71
|
+
if (BLOCKLIST.has(parent)) return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/capture/body-diff.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-request body diffing (Strategy 1).
|
|
5
|
+
*
|
|
6
|
+
* Compare request bodies across multiple captures of the same endpoint.
|
|
7
|
+
* Any field whose value changed between requests is dynamic by definition.
|
|
8
|
+
* Returns JSON paths of changed fields.
|
|
9
|
+
*/
|
|
10
|
+
export function diffBodies(bodies: string[]): string[] {
|
|
11
|
+
if (bodies.length < 2) return [];
|
|
12
|
+
|
|
13
|
+
// Try JSON first
|
|
14
|
+
const parsed: unknown[] = [];
|
|
15
|
+
for (const body of bodies) {
|
|
16
|
+
try {
|
|
17
|
+
parsed.push(JSON.parse(body));
|
|
18
|
+
} catch {
|
|
19
|
+
// Fall back to form-encoded diffing
|
|
20
|
+
return diffFormEncoded(bodies);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// All parsed as JSON — diff objects
|
|
25
|
+
const changed = new Set<string>();
|
|
26
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
27
|
+
diffObjects(parsed[0], parsed[i], '', changed);
|
|
28
|
+
}
|
|
29
|
+
return [...changed].sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function diffObjects(a: unknown, b: unknown, prefix: string, changed: Set<string>): void {
|
|
33
|
+
// Different types → the whole path is dynamic
|
|
34
|
+
if (typeof a !== typeof b || Array.isArray(a) !== Array.isArray(b)) {
|
|
35
|
+
if (prefix) changed.add(prefix);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Both arrays
|
|
40
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
41
|
+
if (a.length !== b.length) {
|
|
42
|
+
// Different lengths → mark whole array as dynamic
|
|
43
|
+
if (prefix) changed.add(prefix);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (let i = 0; i < a.length; i++) {
|
|
47
|
+
diffObjects(a[i], b[i], prefix ? `${prefix}[${i}]` : `[${i}]`, changed);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Both objects
|
|
53
|
+
if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a)) {
|
|
54
|
+
const aObj = a as Record<string, unknown>;
|
|
55
|
+
const bObj = b as Record<string, unknown>;
|
|
56
|
+
const allKeys = new Set([...Object.keys(aObj), ...Object.keys(bObj)]);
|
|
57
|
+
|
|
58
|
+
for (const key of allKeys) {
|
|
59
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
60
|
+
|
|
61
|
+
if (!(key in aObj) || !(key in bObj)) {
|
|
62
|
+
// Key only exists in one → dynamic
|
|
63
|
+
changed.add(path);
|
|
64
|
+
} else {
|
|
65
|
+
diffObjects(aObj[key], bObj[key], path, changed);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Primitive comparison
|
|
72
|
+
if (a !== b && prefix) {
|
|
73
|
+
changed.add(prefix);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function diffFormEncoded(bodies: string[]): string[] {
|
|
78
|
+
const parsed = bodies.map(parseFormEncoded);
|
|
79
|
+
if (parsed.some(m => m.size === 0)) return [];
|
|
80
|
+
|
|
81
|
+
const changed = new Set<string>();
|
|
82
|
+
const firstMap = parsed[0];
|
|
83
|
+
|
|
84
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
85
|
+
const otherMap = parsed[i];
|
|
86
|
+
const allKeys = new Set([...firstMap.keys(), ...otherMap.keys()]);
|
|
87
|
+
|
|
88
|
+
for (const key of allKeys) {
|
|
89
|
+
if (firstMap.get(key) !== otherMap.get(key)) {
|
|
90
|
+
changed.add(key);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [...changed].sort();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseFormEncoded(body: string): Map<string, string> {
|
|
99
|
+
const map = new Map<string, string>();
|
|
100
|
+
const pairs = body.split('&');
|
|
101
|
+
for (const pair of pairs) {
|
|
102
|
+
const idx = pair.indexOf('=');
|
|
103
|
+
if (idx === -1) continue;
|
|
104
|
+
const key = decodeURIComponent(pair.slice(0, idx).replace(/\+/g, ' '));
|
|
105
|
+
const val = decodeURIComponent(pair.slice(idx + 1).replace(/\+/g, ' '));
|
|
106
|
+
map.set(key, val);
|
|
107
|
+
}
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// src/capture/body-variables.ts
|
|
2
|
+
|
|
3
|
+
// Strategy 2: Name-based heuristics — keys implying dynamic values
|
|
4
|
+
const DYNAMIC_KEY_PATTERNS = [
|
|
5
|
+
// Time
|
|
6
|
+
/timestamp/i, /\btime\b/i, /\bdate\b/i, /created[_-]?at/i, /updated[_-]?at/i,
|
|
7
|
+
/\bsince\b/i, /\buntil\b/i, /\bbefore\b/i, /\bafter\b/i, /\bexpires?\b/i,
|
|
8
|
+
// Pagination
|
|
9
|
+
/\bcursor\b/i, /\boffset\b/i, /\bpage\b/i, /page[_-]?number/i,
|
|
10
|
+
/next[_-]?token/i, /continuation/i,
|
|
11
|
+
// Identity
|
|
12
|
+
/request[_-]?id/i, /correlation[_-]?id/i, /trace[_-]?id/i,
|
|
13
|
+
/\bnonce\b/i, /idempotency[_-]?key/i,
|
|
14
|
+
// Session
|
|
15
|
+
/session[_-]?id/i, /\bcsrf\b/i, /\bxsrf\b/i,
|
|
16
|
+
// Geolocation
|
|
17
|
+
/\bgeo(code|loc(ation)?)\b/i, /\blat(itude)?\b/i, /\blo?ng(itude)?\b/i,
|
|
18
|
+
/\bcoord/i, /\bzip\b/i, /\bpostal/i,
|
|
19
|
+
// Search / user input
|
|
20
|
+
/\bquery\b/i, /\bsearch/i, /\bkeyword/i, /\bterm\b/i, /\bfilter\b/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function isDynamicKeyName(key: string): boolean {
|
|
24
|
+
return DYNAMIC_KEY_PATTERNS.some(p => p.test(key));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Strategy 3: Pattern-based detection — value patterns implying dynamic data
|
|
28
|
+
function isTimestampOrPattern(value: string | number): boolean {
|
|
29
|
+
if (typeof value === 'number') {
|
|
30
|
+
// Unix epoch seconds (roughly 2001–2603)
|
|
31
|
+
if (Number.isInteger(value) && value >= 1e9 && value < 2e10) return true;
|
|
32
|
+
// Unix epoch milliseconds
|
|
33
|
+
if (Number.isInteger(value) && value >= 1e12 && value < 2e13) return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ISO 8601 datetime
|
|
38
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) return true;
|
|
39
|
+
// ISO 8601 date only
|
|
40
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return true;
|
|
41
|
+
// Prefixed IDs (req_xxx, id_xxx, txn_xxx, msg_xxx, evt_xxx)
|
|
42
|
+
if (/^(req|id|txn|msg|evt)_[a-zA-Z0-9]+$/.test(value)) return true;
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Detect which fields in a JSON body are likely dynamic variables.
|
|
49
|
+
* Uses three strategies:
|
|
50
|
+
* Strategy 1 (cross-request diffing) is in body-diff.ts
|
|
51
|
+
* Strategy 2: Name-based key heuristics
|
|
52
|
+
* Strategy 3: Pattern-based value detection
|
|
53
|
+
* Plus existing: numeric values, UUIDs, base64 cursors, numeric strings
|
|
54
|
+
*/
|
|
55
|
+
export function detectBodyVariables(
|
|
56
|
+
body: unknown,
|
|
57
|
+
prefix = '',
|
|
58
|
+
): string[] {
|
|
59
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const detected: string[] = [];
|
|
64
|
+
const obj = body as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
67
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
68
|
+
|
|
69
|
+
// Strategy 2: key name implies dynamic value
|
|
70
|
+
if (isDynamicKeyName(key) && value != null) {
|
|
71
|
+
detected.push(path);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof value === 'number') {
|
|
76
|
+
// Strategy 3: epoch timestamp detection
|
|
77
|
+
if (isTimestampOrPattern(value)) {
|
|
78
|
+
detected.push(path);
|
|
79
|
+
} else {
|
|
80
|
+
// Original: numeric values are often IDs
|
|
81
|
+
detected.push(path);
|
|
82
|
+
}
|
|
83
|
+
} else if (typeof value === 'string') {
|
|
84
|
+
// Strategy 3: timestamp/pattern detection (checked first, catches more)
|
|
85
|
+
if (isTimestampOrPattern(value)) {
|
|
86
|
+
detected.push(path);
|
|
87
|
+
} else if (isLikelyDynamic(value)) {
|
|
88
|
+
detected.push(path);
|
|
89
|
+
}
|
|
90
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
91
|
+
// Recurse into nested objects
|
|
92
|
+
detected.push(...detectBodyVariables(value, path));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return detected;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isLikelyDynamic(value: string): boolean {
|
|
100
|
+
// UUID
|
|
101
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
// Base64-ish cursor (long alphanumeric with optional padding)
|
|
105
|
+
if (value.length > 15 && /^[a-zA-Z0-9+/=_-]+$/.test(value)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
// Numeric string (ID)
|
|
109
|
+
if (/^\d{4,}$/.test(value)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Substitute variables in a body template.
|
|
117
|
+
*/
|
|
118
|
+
export function substituteBodyVariables(
|
|
119
|
+
template: string | Record<string, unknown>,
|
|
120
|
+
values: Record<string, string>,
|
|
121
|
+
): string | Record<string, unknown> {
|
|
122
|
+
if (typeof template === 'string') {
|
|
123
|
+
// String template with :param placeholders
|
|
124
|
+
return template.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, name) => {
|
|
125
|
+
return values[name] ?? match;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Deep clone and substitute
|
|
130
|
+
const result = JSON.parse(JSON.stringify(template)) as Record<string, unknown>;
|
|
131
|
+
|
|
132
|
+
for (const [path, value] of Object.entries(values)) {
|
|
133
|
+
setNestedValue(result, path, value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setNestedValue(obj: Record<string, unknown>, path: string, value: string): void {
|
|
140
|
+
const parts = path.split('.');
|
|
141
|
+
let current = obj;
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
144
|
+
const part = parts[i];
|
|
145
|
+
if (current[part] && typeof current[part] === 'object') {
|
|
146
|
+
current = current[part] as Record<string, unknown>;
|
|
147
|
+
} else {
|
|
148
|
+
return; // Path doesn't exist
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const lastPart = parts[parts.length - 1];
|
|
153
|
+
if (lastPart in current) {
|
|
154
|
+
current[lastPart] = value;
|
|
155
|
+
}
|
|
156
|
+
}
|