@apifuse/connector-sdk 2.0.0-beta.1
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/README.md +44 -0
- package/bin/apifuse-check.ts +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/connectors-yaml.test.ts +135 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +665 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { infer as ZodInfer, ZodType } from "zod";
|
|
2
|
+
|
|
3
|
+
export type StealthPlatform = "macos" | "windows" | "linux" | "android" | "ios";
|
|
4
|
+
|
|
5
|
+
export type BrowserEngine = "playwright-stealth" | "nodriver" | "selenium-uc";
|
|
6
|
+
|
|
7
|
+
export interface BrowserOptions {
|
|
8
|
+
headless?: boolean;
|
|
9
|
+
stealth?: boolean;
|
|
10
|
+
proxy?: string;
|
|
11
|
+
engine?: BrowserEngine;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StealthProfile {
|
|
15
|
+
name: string;
|
|
16
|
+
platform: StealthPlatform;
|
|
17
|
+
version: string;
|
|
18
|
+
userAgent: string;
|
|
19
|
+
tlsClientIdentifier?: string;
|
|
20
|
+
ja3?: string;
|
|
21
|
+
ja4?: string;
|
|
22
|
+
h2Settings?: Record<string, unknown>;
|
|
23
|
+
headerOrder?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AuthMode = "none" | "credentials" | "oauth2" | "api-key";
|
|
27
|
+
|
|
28
|
+
export interface AuthField {
|
|
29
|
+
name: string;
|
|
30
|
+
label: string;
|
|
31
|
+
type: "text" | "password" | "otp";
|
|
32
|
+
required?: boolean;
|
|
33
|
+
deferred?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ConnectorMeta {
|
|
37
|
+
displayName: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
category: string;
|
|
40
|
+
tags?: string[];
|
|
41
|
+
icon?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RequestOptions {
|
|
45
|
+
headers?: Record<string, string>;
|
|
46
|
+
params?: Record<string, string>;
|
|
47
|
+
proxy?: string;
|
|
48
|
+
timeout?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TlsFetchOptions extends RequestOptions {
|
|
52
|
+
method?: string;
|
|
53
|
+
body?: string | Buffer;
|
|
54
|
+
profile?: string;
|
|
55
|
+
tls?: {
|
|
56
|
+
ja3?: string;
|
|
57
|
+
h2?: Record<string, unknown>;
|
|
58
|
+
};
|
|
59
|
+
headerOrder?: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CookieJar {
|
|
63
|
+
get(name: string): string | undefined;
|
|
64
|
+
getAll(): Record<string, string>;
|
|
65
|
+
toString(): string;
|
|
66
|
+
find?(predicate: (cookie: string) => boolean): string | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DeclarativeTlsResponse {
|
|
70
|
+
status: number;
|
|
71
|
+
ok: boolean;
|
|
72
|
+
headers: Record<string, string>;
|
|
73
|
+
rawHeaders: [string, string][];
|
|
74
|
+
body: string;
|
|
75
|
+
httpVersion?: string;
|
|
76
|
+
tlsInfo?: { protocol?: string; cipher?: string; [key: string]: unknown };
|
|
77
|
+
cookies: CookieJar;
|
|
78
|
+
json<T>(): Promise<T>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type TlsResponse = DeclarativeTlsResponse;
|
|
82
|
+
|
|
83
|
+
export type RequestWithMethodOptions = RequestOptions & {
|
|
84
|
+
method?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export interface TlsSession {
|
|
88
|
+
fetch(url: string, options?: TlsFetchOptions): Promise<TlsResponse>;
|
|
89
|
+
close(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ApiFuseResponse<T> {
|
|
93
|
+
data: T;
|
|
94
|
+
meta: {
|
|
95
|
+
requestId: string;
|
|
96
|
+
duration: number;
|
|
97
|
+
cached?: boolean;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface HttpResponse<T = unknown> {
|
|
102
|
+
status: number;
|
|
103
|
+
ok: boolean;
|
|
104
|
+
headers: Record<string, string>;
|
|
105
|
+
data: T;
|
|
106
|
+
json<U = T>(): Promise<U>;
|
|
107
|
+
text(): Promise<string>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface HttpClient {
|
|
111
|
+
request(url: string, opts?: RequestWithMethodOptions): Promise<HttpResponse>;
|
|
112
|
+
get(url: string, options?: RequestOptions): Promise<HttpResponse>;
|
|
113
|
+
post(
|
|
114
|
+
url: string,
|
|
115
|
+
body: unknown,
|
|
116
|
+
options?: RequestOptions,
|
|
117
|
+
): Promise<HttpResponse>;
|
|
118
|
+
put(
|
|
119
|
+
url: string,
|
|
120
|
+
body: unknown,
|
|
121
|
+
options?: RequestOptions,
|
|
122
|
+
): Promise<HttpResponse>;
|
|
123
|
+
delete(url: string, options?: RequestOptions): Promise<HttpResponse>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TlsClient {
|
|
127
|
+
fetch(url: string, options?: TlsFetchOptions): Promise<TlsResponse>;
|
|
128
|
+
createSession(opts?: { profile?: string }): TlsSession;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface BrowserClient {
|
|
132
|
+
readonly engine: BrowserEngine;
|
|
133
|
+
newPage(): Promise<unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface SessionStore {
|
|
137
|
+
get(key: string): Promise<string | null>;
|
|
138
|
+
set(key: string, value: string, ttl?: string): Promise<void>;
|
|
139
|
+
delete(key: string): Promise<void>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface StateContext {
|
|
143
|
+
seal(data: unknown, options?: { ttl?: string }): Promise<string>;
|
|
144
|
+
unseal<T = unknown>(token: string): Promise<T | null>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type TraceAttributeValue = string | number | boolean;
|
|
148
|
+
|
|
149
|
+
export interface TraceSpan {
|
|
150
|
+
id: string;
|
|
151
|
+
name: string;
|
|
152
|
+
startedAt: number;
|
|
153
|
+
endedAt: number;
|
|
154
|
+
duration_ms: number;
|
|
155
|
+
status: "ok" | "error";
|
|
156
|
+
error?: string;
|
|
157
|
+
attributes: Record<string, TraceAttributeValue>;
|
|
158
|
+
parentId?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface TraceConfig {
|
|
162
|
+
enabled?: boolean;
|
|
163
|
+
maxSpans?: number;
|
|
164
|
+
onSpan?: (span: TraceSpan) => void;
|
|
165
|
+
exporter?: "console" | "json" | "otlp" | "none";
|
|
166
|
+
endpoint?: string;
|
|
167
|
+
otlp?: {
|
|
168
|
+
endpoint: string;
|
|
169
|
+
headers?: Record<string, string>;
|
|
170
|
+
timeout?: number;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface TraceContext {
|
|
175
|
+
span<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface AuthContext {
|
|
179
|
+
requestField(
|
|
180
|
+
name: string,
|
|
181
|
+
options?: { type?: "otp" | "text" },
|
|
182
|
+
): Promise<string>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface ConnectorContext {
|
|
186
|
+
http: HttpClient;
|
|
187
|
+
tls: TlsClient;
|
|
188
|
+
browser: BrowserClient;
|
|
189
|
+
session: SessionStore;
|
|
190
|
+
state: StateContext;
|
|
191
|
+
trace: TraceContext;
|
|
192
|
+
auth: AuthContext;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface AuthConfig {
|
|
196
|
+
mode: AuthMode;
|
|
197
|
+
fields?: AuthField[];
|
|
198
|
+
exchange?: (
|
|
199
|
+
ctx: ConnectorContext,
|
|
200
|
+
credentials: Record<string, string>,
|
|
201
|
+
) => Promise<void>;
|
|
202
|
+
refresh?: (ctx: ConnectorContext) => Promise<void>;
|
|
203
|
+
refreshPolicy?: {
|
|
204
|
+
strategy: "reactive" | "proactive" | "both";
|
|
205
|
+
interval?: string;
|
|
206
|
+
};
|
|
207
|
+
disconnect?: (ctx: ConnectorContext) => Promise<void>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface OperationDefinition<
|
|
211
|
+
TInput extends ZodType = ZodType,
|
|
212
|
+
TOutput extends ZodType = ZodType,
|
|
213
|
+
> {
|
|
214
|
+
description?: string;
|
|
215
|
+
input: TInput;
|
|
216
|
+
output: TOutput;
|
|
217
|
+
handler: (
|
|
218
|
+
ctx: ConnectorContext,
|
|
219
|
+
input: ZodInfer<TInput>,
|
|
220
|
+
) => Promise<ZodInfer<TOutput>>;
|
|
221
|
+
fixtures?: {
|
|
222
|
+
request: ZodInfer<TInput>;
|
|
223
|
+
response: ZodInfer<TOutput>;
|
|
224
|
+
};
|
|
225
|
+
hints?: Record<string, string>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface ConnectorDefinition {
|
|
229
|
+
id: string;
|
|
230
|
+
version: string;
|
|
231
|
+
runtime: "standard" | "browser";
|
|
232
|
+
stealth?: {
|
|
233
|
+
profile: string;
|
|
234
|
+
platform: StealthPlatform;
|
|
235
|
+
};
|
|
236
|
+
proxy?: boolean;
|
|
237
|
+
browser?: {
|
|
238
|
+
engine: BrowserEngine;
|
|
239
|
+
};
|
|
240
|
+
auth?: AuthConfig;
|
|
241
|
+
meta: ConnectorMeta;
|
|
242
|
+
operations: Record<string, OperationDefinition<ZodType, ZodType>>;
|
|
243
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
function getDateParts(date: Date, timezone: string) {
|
|
2
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
3
|
+
timeZone: timezone,
|
|
4
|
+
hour12: false,
|
|
5
|
+
year: "numeric",
|
|
6
|
+
month: "2-digit",
|
|
7
|
+
day: "2-digit",
|
|
8
|
+
hour: "2-digit",
|
|
9
|
+
minute: "2-digit",
|
|
10
|
+
second: "2-digit",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const parts = Object.fromEntries(
|
|
14
|
+
formatter.formatToParts(date).map((part) => [part.type, part.value]),
|
|
15
|
+
) as Record<string, string>;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
year: parts.year,
|
|
19
|
+
month: parts.month,
|
|
20
|
+
day: parts.day,
|
|
21
|
+
hour: parts.hour,
|
|
22
|
+
minute: parts.minute,
|
|
23
|
+
second: parts.second,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTimezoneOffsetMinutes(date: Date, timezone: string): number {
|
|
28
|
+
const parts = getDateParts(date, timezone);
|
|
29
|
+
const asUTC = Date.UTC(
|
|
30
|
+
Number(parts.year),
|
|
31
|
+
Number(parts.month) - 1,
|
|
32
|
+
Number(parts.day),
|
|
33
|
+
Number(parts.hour),
|
|
34
|
+
Number(parts.minute),
|
|
35
|
+
Number(parts.second),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (asUTC - date.getTime()) / 60000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildUtcInstantFromLocalParts(
|
|
42
|
+
year: number,
|
|
43
|
+
month: number,
|
|
44
|
+
day: number,
|
|
45
|
+
hour: number,
|
|
46
|
+
minute: number,
|
|
47
|
+
second: number,
|
|
48
|
+
timezone: string,
|
|
49
|
+
): Date {
|
|
50
|
+
const guess = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
51
|
+
let utc = guess;
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < 2; i += 1) {
|
|
54
|
+
const offset = getTimezoneOffsetMinutes(new Date(utc), timezone);
|
|
55
|
+
const nextUtc = guess - offset * 60_000;
|
|
56
|
+
if (nextUtc === utc) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
utc = nextUtc;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Date(utc);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatOffset(offsetMinutes: number): string {
|
|
66
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
67
|
+
const abs = Math.abs(offsetMinutes);
|
|
68
|
+
const hours = String(Math.floor(abs / 60)).padStart(2, "0");
|
|
69
|
+
const minutes = String(abs % 60).padStart(2, "0");
|
|
70
|
+
return `${sign}${hours}:${minutes}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatIso(date: Date, timezone: string): string {
|
|
74
|
+
const parts = getDateParts(date, timezone);
|
|
75
|
+
const offset = getTimezoneOffsetMinutes(date, timezone);
|
|
76
|
+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${formatOffset(offset)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseDateValue(v: unknown, timezone: string): Date | null {
|
|
80
|
+
if (v instanceof Date) {
|
|
81
|
+
return Number.isNaN(v.getTime()) ? null : v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof v === "number") {
|
|
85
|
+
const date = new Date(v);
|
|
86
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof v !== "string") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const value = v.trim();
|
|
94
|
+
if (value === "") {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const compactDate = /^(\d{4})(\d{2})(\d{2})$/;
|
|
99
|
+
const compactDateTime = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?$/;
|
|
100
|
+
const dashedDate = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
101
|
+
|
|
102
|
+
let match = value.match(compactDateTime);
|
|
103
|
+
if (match) {
|
|
104
|
+
const [, year, month, day, hour, minute, second = "00"] = match;
|
|
105
|
+
return buildUtcInstantFromLocalParts(
|
|
106
|
+
Number(year),
|
|
107
|
+
Number(month),
|
|
108
|
+
Number(day),
|
|
109
|
+
Number(hour),
|
|
110
|
+
Number(minute),
|
|
111
|
+
Number(second),
|
|
112
|
+
timezone,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
match = value.match(compactDate);
|
|
117
|
+
if (match) {
|
|
118
|
+
const [, year, month, day] = match;
|
|
119
|
+
return buildUtcInstantFromLocalParts(
|
|
120
|
+
Number(year),
|
|
121
|
+
Number(month),
|
|
122
|
+
Number(day),
|
|
123
|
+
0,
|
|
124
|
+
0,
|
|
125
|
+
0,
|
|
126
|
+
timezone,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
match = value.match(dashedDate);
|
|
131
|
+
if (match) {
|
|
132
|
+
const [, year, month, day] = match;
|
|
133
|
+
return buildUtcInstantFromLocalParts(
|
|
134
|
+
Number(year),
|
|
135
|
+
Number(month),
|
|
136
|
+
Number(day),
|
|
137
|
+
0,
|
|
138
|
+
0,
|
|
139
|
+
0,
|
|
140
|
+
timezone,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const parsed = new Date(value);
|
|
145
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Convert various date formats to ISO 8601 string */
|
|
149
|
+
export function toISODate(v: unknown, timezone: string): string {
|
|
150
|
+
const date = parseDateValue(v, timezone);
|
|
151
|
+
return date ? formatIso(date, timezone) : "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Get today's date as ISO 8601 string in given timezone */
|
|
155
|
+
export function today(timezone: string): string {
|
|
156
|
+
const parts = getDateParts(new Date(), timezone);
|
|
157
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Get current hour as string in given timezone */
|
|
161
|
+
export function currentHour(timezone: string): string {
|
|
162
|
+
return getDateParts(new Date(), timezone).hour;
|
|
163
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unwrap nested envelope by dot-path
|
|
3
|
+
* e.g., unwrapEnvelope({response: {body: {items: [...]}}}, 'response.body.items')
|
|
4
|
+
*/
|
|
5
|
+
export function unwrapEnvelope(data: unknown, path?: string): unknown {
|
|
6
|
+
if (!path || path.trim() === "") {
|
|
7
|
+
return data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const segments = path.split(".").filter(Boolean);
|
|
11
|
+
let current: unknown = data;
|
|
12
|
+
|
|
13
|
+
for (const segment of segments) {
|
|
14
|
+
if (!current || typeof current !== "object") {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
current = (current as Record<string, unknown>)[segment];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pivot array by field — generalized version of pivotByCategory
|
|
26
|
+
* e.g., pivotByField([{type:'A', val:1},{type:'B', val:2}], 'type', 'val')
|
|
27
|
+
* → { A: 1, B: 2 }
|
|
28
|
+
*/
|
|
29
|
+
export function pivotByField(
|
|
30
|
+
items: unknown[],
|
|
31
|
+
keyField: string,
|
|
32
|
+
valueField: string,
|
|
33
|
+
): Record<string, unknown> {
|
|
34
|
+
const result: Record<string, unknown> = {};
|
|
35
|
+
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
if (!item || typeof item !== "object") {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const record = item as Record<string, unknown>;
|
|
42
|
+
const key = record[keyField];
|
|
43
|
+
const value = record[valueField];
|
|
44
|
+
|
|
45
|
+
if (key === undefined || key === null || value === undefined) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
result[String(key)] = value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse XML items from string — returns array
|
|
57
|
+
* Uses simple regex (no DOM), suitable for structured XML
|
|
58
|
+
*/
|
|
59
|
+
export function parseXmlItems(xml: string, tag: string): string[] {
|
|
60
|
+
const matches = [
|
|
61
|
+
...xml.matchAll(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "gi")),
|
|
62
|
+
];
|
|
63
|
+
return matches
|
|
64
|
+
.map((match) => match[1].replace(/<[^>]+>/g, "").trim())
|
|
65
|
+
.filter((item) => item.length > 0);
|
|
66
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Remove HTML tags from string */
|
|
2
|
+
export function stripHtml(html: string): string {
|
|
3
|
+
return html
|
|
4
|
+
.replace(/<[^>]*>/g, "")
|
|
5
|
+
.replace(/\s+/g, " ")
|
|
6
|
+
.trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Truncate string to maxLength with optional suffix */
|
|
10
|
+
export function truncate(
|
|
11
|
+
str: string,
|
|
12
|
+
maxLength: number,
|
|
13
|
+
suffix = "...",
|
|
14
|
+
): string {
|
|
15
|
+
if (str.length <= maxLength) {
|
|
16
|
+
return str;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return `${str.slice(0, maxLength)}${suffix}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Parse any value to number (returns 0 if not parseable) */
|
|
2
|
+
export function toNumber(v: unknown): number {
|
|
3
|
+
if (typeof v === "number") {
|
|
4
|
+
return Number.isFinite(v) ? v : 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
if (typeof v === "boolean") {
|
|
8
|
+
return v ? 1 : 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof v === "string") {
|
|
12
|
+
const cleaned = v.replaceAll(",", "").trim();
|
|
13
|
+
|
|
14
|
+
if (cleaned === "") {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parsed = Number(cleaned);
|
|
19
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse any value to float */
|
|
26
|
+
export function toFloat(v: unknown, decimals?: number): number {
|
|
27
|
+
const parsed = toNumber(v);
|
|
28
|
+
|
|
29
|
+
if (!Number.isFinite(parsed)) {
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (decimals === undefined) {
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const factor = 10 ** decimals;
|
|
38
|
+
return Math.round(parsed * factor) / factor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse any value to integer */
|
|
42
|
+
export function toInt(v: unknown): number {
|
|
43
|
+
return Math.round(toNumber(v));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse any value to boolean (handles "true", "1", "yes", true, 1) */
|
|
47
|
+
export function toBoolean(v: unknown): boolean {
|
|
48
|
+
if (typeof v === "boolean") {
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof v === "number") {
|
|
53
|
+
return v === 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof v === "string") {
|
|
57
|
+
const normalized = v.trim().toLowerCase();
|
|
58
|
+
return ["true", "1", "yes", "y"].includes(normalized);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false;
|
|
62
|
+
}
|