@push.rocks/smartnetwork 4.8.0 → 4.10.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/dist_rust/rustnetwork_linux_amd64 +0 -0
- package/dist_rust/rustnetwork_linux_arm64 +0 -0
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/helpers/ip.d.ts +21 -0
- package/dist_ts/helpers/ip.js +80 -1
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/index.js +2 -2
- package/dist_ts/smartnetwork.classes.domainintelligence.d.ts +13 -58
- package/dist_ts/smartnetwork.classes.domainintelligence.js +45 -319
- package/dist_ts/smartnetwork.classes.ipintelligence.d.ts +3 -34
- package/dist_ts/smartnetwork.classes.ipintelligence.js +18 -281
- package/dist_ts/smartnetwork.plugins.d.ts +2 -1
- package/dist_ts/smartnetwork.plugins.js +3 -2
- package/package.json +5 -4
- package/readme.md +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers/ip.ts +79 -0
- package/ts/index.ts +1 -1
- package/ts/smartnetwork.classes.domainintelligence.ts +48 -341
- package/ts/smartnetwork.classes.ipintelligence.ts +19 -310
- package/ts/smartnetwork.plugins.ts +2 -1
|
@@ -70,8 +70,6 @@ export interface IDomainIntelligenceOptions {
|
|
|
70
70
|
dnsClient?: TSmartdnsClient;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// IANA bootstrap for domain RDAP
|
|
74
|
-
const IANA_BOOTSTRAP_DNS_URL = 'https://data.iana.org/rdap/dns.json';
|
|
75
73
|
const DEFAULT_TIMEOUT = 5000;
|
|
76
74
|
|
|
77
75
|
/**
|
|
@@ -103,16 +101,16 @@ function emptyResult(domain: string | null = null): IDomainIntelligenceResult {
|
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
/**
|
|
106
|
-
* DomainIntelligence performs RDAP lookups for domain names
|
|
107
|
-
*
|
|
104
|
+
* DomainIntelligence performs RDAP lookups for domain names via
|
|
105
|
+
* @push.rocks/smartwhois, which resolves the correct registry RDAP endpoint
|
|
106
|
+
* per TLD through the IANA DNS bootstrap.
|
|
108
107
|
*/
|
|
109
108
|
export class DomainIntelligence {
|
|
110
109
|
private readonly logger = getLogger();
|
|
111
110
|
private readonly timeout: number;
|
|
112
111
|
|
|
113
|
-
//
|
|
114
|
-
private
|
|
115
|
-
private bootstrapPromise: Promise<void> | null = null;
|
|
112
|
+
// RDAP client from smartwhois (owns bootstrap caching and registry failover)
|
|
113
|
+
private readonly rdapClient: plugins.smartwhois.RdapClient;
|
|
116
114
|
|
|
117
115
|
// Optional injected smartdns client (shared by SmartNetwork)
|
|
118
116
|
private readonly sharedDnsClient: TSmartdnsClient | null;
|
|
@@ -120,6 +118,11 @@ export class DomainIntelligence {
|
|
|
120
118
|
constructor(options?: IDomainIntelligenceOptions) {
|
|
121
119
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
122
120
|
this.sharedDnsClient = options?.dnsClient ?? null;
|
|
121
|
+
this.rdapClient = new plugins.smartwhois.RdapClient({
|
|
122
|
+
requestTimeoutMs: this.timeout,
|
|
123
|
+
totalTimeoutMs: this.timeout,
|
|
124
|
+
userAgent: '@push.rocks/smartnetwork',
|
|
125
|
+
});
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
/**
|
|
@@ -169,26 +172,49 @@ export class DomainIntelligence {
|
|
|
169
172
|
return result;
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
// ─── RDAP Layer (
|
|
175
|
+
// ─── RDAP Layer (delegated to @push.rocks/smartwhois) ───────────────
|
|
173
176
|
|
|
174
177
|
/**
|
|
175
|
-
* Run the full RDAP lookup flow for a pre-normalized domain
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
178
|
+
* Run the full RDAP lookup flow for a pre-normalized domain via the
|
|
179
|
+
* smartwhois RdapClient. Returns the parsed RDAP fields (as a full
|
|
180
|
+
* IDomainIntelligenceResult) or null if any step fails, the TLD has no
|
|
181
|
+
* RDAP support, or the registry reports the domain as not registered.
|
|
179
182
|
*/
|
|
180
183
|
private async queryRdapLayer(domain: string): Promise<IDomainIntelligenceResult | null> {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
try {
|
|
185
|
+
const rdapRecord = await this.rdapClient.lookupDomain(domain);
|
|
186
|
+
if (!rdapRecord || !rdapRecord.isRegistered) return null;
|
|
187
|
+
return this.mapRdapRecord(domain, rdapRecord);
|
|
188
|
+
} catch (err: any) {
|
|
189
|
+
this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
190
193
|
|
|
191
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Map a smartwhois RDAP domain record onto the IDomainIntelligenceResult
|
|
196
|
+
* shape (RDAP fields only; DNS enrichment fields stay null here).
|
|
197
|
+
*/
|
|
198
|
+
private mapRdapRecord(
|
|
199
|
+
domain: string,
|
|
200
|
+
record: plugins.smartwhois.IRdapDomainRecord,
|
|
201
|
+
): IDomainIntelligenceResult {
|
|
202
|
+
const result = emptyResult(domain);
|
|
203
|
+
result.handle = record.handle ?? null;
|
|
204
|
+
result.status = record.statuses && record.statuses.length > 0 ? record.statuses : null;
|
|
205
|
+
result.registrationDate = record.createdDate ?? null;
|
|
206
|
+
result.expirationDate = record.expiryDate ?? null;
|
|
207
|
+
result.lastChangedDate = record.updatedDate ?? null;
|
|
208
|
+
result.registrarName = record.registrar ?? null;
|
|
209
|
+
result.registrarIanaId = record.registrarIanaId ?? null;
|
|
210
|
+
result.registrantOrg = record.registrantOrg ?? null;
|
|
211
|
+
result.registrantCountry = record.registrantCountry ?? null;
|
|
212
|
+
result.abuseEmail = record.abuseEmail ?? null;
|
|
213
|
+
result.abusePhone = record.abusePhone ?? null;
|
|
214
|
+
result.nameservers =
|
|
215
|
+
record.nameservers && record.nameservers.length > 0 ? record.nameservers : null;
|
|
216
|
+
result.dnssec = record.dnssec ?? null;
|
|
217
|
+
return result;
|
|
192
218
|
}
|
|
193
219
|
|
|
194
220
|
// ─── Normalization & TLD extraction ─────────────────────────────────
|
|
@@ -217,244 +243,6 @@ export class DomainIntelligence {
|
|
|
217
243
|
return ascii;
|
|
218
244
|
}
|
|
219
245
|
|
|
220
|
-
/**
|
|
221
|
-
* Extract the TLD as the last dot-separated label.
|
|
222
|
-
*/
|
|
223
|
-
private extractTld(domain: string): string | null {
|
|
224
|
-
const idx = domain.lastIndexOf('.');
|
|
225
|
-
if (idx < 0 || idx === domain.length - 1) return null;
|
|
226
|
-
return domain.slice(idx + 1);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── Bootstrap Subsystem ────────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Load and cache the IANA DNS bootstrap file.
|
|
233
|
-
*/
|
|
234
|
-
private async ensureBootstrap(): Promise<void> {
|
|
235
|
-
if (this.bootstrapEntries) return;
|
|
236
|
-
if (this.bootstrapPromise) {
|
|
237
|
-
await this.bootstrapPromise;
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
this.bootstrapPromise = (async () => {
|
|
242
|
-
try {
|
|
243
|
-
const controller = new AbortController();
|
|
244
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
245
|
-
try {
|
|
246
|
-
const response = await fetch(IANA_BOOTSTRAP_DNS_URL, {
|
|
247
|
-
signal: controller.signal,
|
|
248
|
-
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
|
|
249
|
-
});
|
|
250
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
251
|
-
const data = (await response.json()) as { services: [string[], string[]][] };
|
|
252
|
-
|
|
253
|
-
const entries = new Map<string, string>();
|
|
254
|
-
for (const [tlds, urls] of data.services) {
|
|
255
|
-
const baseUrl = urls[0]; // first URL is preferred
|
|
256
|
-
if (!baseUrl) continue;
|
|
257
|
-
const cleanBase = baseUrl.replace(/\/$/, ''); // strip trailing slash
|
|
258
|
-
for (const tld of tlds) {
|
|
259
|
-
entries.set(tld.toLowerCase(), cleanBase);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
this.bootstrapEntries = entries;
|
|
263
|
-
} finally {
|
|
264
|
-
clearTimeout(timeoutId);
|
|
265
|
-
}
|
|
266
|
-
} catch (err: any) {
|
|
267
|
-
this.logger.debug?.(`Failed to load DNS RDAP bootstrap: ${err.message}`);
|
|
268
|
-
this.bootstrapEntries = new Map(); // empty = all RDAP lookups will skip
|
|
269
|
-
}
|
|
270
|
-
})();
|
|
271
|
-
|
|
272
|
-
await this.bootstrapPromise;
|
|
273
|
-
this.bootstrapPromise = null;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Find the RDAP base URL for a given TLD via direct lookup.
|
|
278
|
-
*/
|
|
279
|
-
private matchTld(tld: string): string | null {
|
|
280
|
-
if (!this.bootstrapEntries || this.bootstrapEntries.size === 0) return null;
|
|
281
|
-
return this.bootstrapEntries.get(tld.toLowerCase()) ?? null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ─── RDAP Query ─────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Perform the RDAP HTTP query for a domain.
|
|
288
|
-
*/
|
|
289
|
-
private async queryRdap(domain: string, baseUrl: string): Promise<any | null> {
|
|
290
|
-
const controller = new AbortController();
|
|
291
|
-
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
292
|
-
try {
|
|
293
|
-
const response = await fetch(`${baseUrl}/domain/${encodeURIComponent(domain)}`, {
|
|
294
|
-
signal: controller.signal,
|
|
295
|
-
headers: {
|
|
296
|
-
'Accept': 'application/rdap+json',
|
|
297
|
-
'User-Agent': '@push.rocks/smartnetwork',
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
if (!response.ok) return null;
|
|
301
|
-
return await response.json();
|
|
302
|
-
} catch (err: any) {
|
|
303
|
-
this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`);
|
|
304
|
-
return null;
|
|
305
|
-
} finally {
|
|
306
|
-
clearTimeout(timeoutId);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ─── RDAP Response Parsing ──────────────────────────────────────────
|
|
311
|
-
|
|
312
|
-
private parseRdapResponse(domain: string, data: any): IDomainIntelligenceResult {
|
|
313
|
-
const result = emptyResult(domain);
|
|
314
|
-
|
|
315
|
-
if (typeof data.handle === 'string') result.handle = data.handle;
|
|
316
|
-
|
|
317
|
-
if (Array.isArray(data.status) && data.status.length > 0) {
|
|
318
|
-
result.status = data.status.filter((s: any): s is string => typeof s === 'string');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Events: registration / expiration / last changed
|
|
322
|
-
if (Array.isArray(data.events)) {
|
|
323
|
-
const events = this.extractEvents(data.events);
|
|
324
|
-
result.registrationDate = events.registration;
|
|
325
|
-
result.expirationDate = events.expiration;
|
|
326
|
-
result.lastChangedDate = events.lastChanged;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Registrar (sponsor) and registrant from entities
|
|
330
|
-
if (Array.isArray(data.entities)) {
|
|
331
|
-
const registrar = this.extractRegistrar(data.entities);
|
|
332
|
-
result.registrarName = registrar.name;
|
|
333
|
-
result.registrarIanaId = registrar.ianaId;
|
|
334
|
-
result.abuseEmail = registrar.abuseEmail;
|
|
335
|
-
result.abusePhone = registrar.abusePhone;
|
|
336
|
-
|
|
337
|
-
const registrant = this.extractRegistrant(data.entities);
|
|
338
|
-
result.registrantOrg = registrant.org;
|
|
339
|
-
result.registrantCountry = registrant.country;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Nameservers
|
|
343
|
-
if (Array.isArray(data.nameservers)) {
|
|
344
|
-
result.nameservers = this.extractNameservers(data.nameservers);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// DNSSEC: secureDNS.delegationSigned
|
|
348
|
-
if (data.secureDNS && typeof data.secureDNS.delegationSigned === 'boolean') {
|
|
349
|
-
result.dnssec = data.secureDNS.delegationSigned;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
return result;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Pull registration / expiration / last changed timestamps from an
|
|
357
|
-
* RDAP `events` array.
|
|
358
|
-
*/
|
|
359
|
-
private extractEvents(events: any[]): {
|
|
360
|
-
registration: string | null;
|
|
361
|
-
expiration: string | null;
|
|
362
|
-
lastChanged: string | null;
|
|
363
|
-
} {
|
|
364
|
-
let registration: string | null = null;
|
|
365
|
-
let expiration: string | null = null;
|
|
366
|
-
let lastChanged: string | null = null;
|
|
367
|
-
|
|
368
|
-
for (const ev of events) {
|
|
369
|
-
const action = typeof ev?.eventAction === 'string' ? ev.eventAction.toLowerCase() : '';
|
|
370
|
-
const date = typeof ev?.eventDate === 'string' ? ev.eventDate : null;
|
|
371
|
-
if (!date) continue;
|
|
372
|
-
if (action === 'registration') registration = date;
|
|
373
|
-
else if (action === 'expiration') expiration = date;
|
|
374
|
-
else if (action === 'last changed') lastChanged = date;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
return { registration, expiration, lastChanged };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Extract registrar identity (name, IANA ID) and a nested abuse contact.
|
|
382
|
-
*/
|
|
383
|
-
private extractRegistrar(entities: any[]): {
|
|
384
|
-
name: string | null;
|
|
385
|
-
ianaId: number | null;
|
|
386
|
-
abuseEmail: string | null;
|
|
387
|
-
abusePhone: string | null;
|
|
388
|
-
} {
|
|
389
|
-
let name: string | null = null;
|
|
390
|
-
let ianaId: number | null = null;
|
|
391
|
-
let abuseEmail: string | null = null;
|
|
392
|
-
let abusePhone: string | null = null;
|
|
393
|
-
|
|
394
|
-
for (const entity of entities) {
|
|
395
|
-
const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
|
|
396
|
-
if (!roles.includes('registrar')) continue;
|
|
397
|
-
|
|
398
|
-
name = this.extractVcardFn(entity);
|
|
399
|
-
|
|
400
|
-
// IANA Registrar ID lives in publicIds[]
|
|
401
|
-
if (Array.isArray(entity.publicIds)) {
|
|
402
|
-
for (const pid of entity.publicIds) {
|
|
403
|
-
if (pid && typeof pid === 'object' && pid.type === 'IANA Registrar ID') {
|
|
404
|
-
const parsed = parseInt(String(pid.identifier), 10);
|
|
405
|
-
if (!isNaN(parsed)) ianaId = parsed;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Abuse contact: nested entity with role "abuse"
|
|
411
|
-
if (Array.isArray(entity.entities)) {
|
|
412
|
-
for (const sub of entity.entities) {
|
|
413
|
-
const subRoles: string[] = Array.isArray(sub?.roles) ? sub.roles : [];
|
|
414
|
-
if (subRoles.includes('abuse')) {
|
|
415
|
-
if (!abuseEmail) abuseEmail = this.extractVcardEmail(sub);
|
|
416
|
-
if (!abusePhone) abusePhone = this.extractVcardTel(sub);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
break; // first registrar wins
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return { name, ianaId, abuseEmail, abusePhone };
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Extract registrant org/country from entities array.
|
|
429
|
-
*/
|
|
430
|
-
private extractRegistrant(entities: any[]): {
|
|
431
|
-
org: string | null;
|
|
432
|
-
country: string | null;
|
|
433
|
-
} {
|
|
434
|
-
for (const entity of entities) {
|
|
435
|
-
const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : [];
|
|
436
|
-
if (!roles.includes('registrant')) continue;
|
|
437
|
-
const org = this.extractVcardFn(entity);
|
|
438
|
-
const country = this.extractVcardCountry(entity);
|
|
439
|
-
return { org, country };
|
|
440
|
-
}
|
|
441
|
-
return { org: null, country: null };
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Map an RDAP nameservers[] array to a list of lowercased ldhName strings.
|
|
446
|
-
*/
|
|
447
|
-
private extractNameservers(nameservers: any[]): string[] | null {
|
|
448
|
-
const out: string[] = [];
|
|
449
|
-
for (const ns of nameservers) {
|
|
450
|
-
const ldh = ns?.ldhName;
|
|
451
|
-
if (typeof ldh === 'string' && ldh.length > 0) {
|
|
452
|
-
out.push(ldh.toLowerCase());
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return out.length > 0 ? out : null;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
246
|
// ─── DNS Layer ──────────────────────────────────────────────────────
|
|
459
247
|
|
|
460
248
|
/**
|
|
@@ -575,85 +363,4 @@ export class DomainIntelligence {
|
|
|
575
363
|
}
|
|
576
364
|
return out.length > 0 ? out : null;
|
|
577
365
|
}
|
|
578
|
-
|
|
579
|
-
// ─── vCard helpers (duplicated from IpIntelligence) ─────────────────
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Extract the 'fn' (formatted name) from an entity's vcardArray
|
|
583
|
-
*/
|
|
584
|
-
private extractVcardFn(entity: any): string | null {
|
|
585
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
586
|
-
const properties = entity.vcardArray[1];
|
|
587
|
-
if (!Array.isArray(properties)) return null;
|
|
588
|
-
|
|
589
|
-
for (const prop of properties) {
|
|
590
|
-
if (Array.isArray(prop) && prop[0] === 'fn') {
|
|
591
|
-
return prop[3] || null;
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Extract email from an entity's vcardArray
|
|
599
|
-
*/
|
|
600
|
-
private extractVcardEmail(entity: any): string | null {
|
|
601
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
602
|
-
const properties = entity.vcardArray[1];
|
|
603
|
-
if (!Array.isArray(properties)) return null;
|
|
604
|
-
|
|
605
|
-
for (const prop of properties) {
|
|
606
|
-
if (Array.isArray(prop) && prop[0] === 'email') {
|
|
607
|
-
return prop[3] || null;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return null;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Extract telephone number from an entity's vcardArray
|
|
615
|
-
*/
|
|
616
|
-
private extractVcardTel(entity: any): string | null {
|
|
617
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
618
|
-
const properties = entity.vcardArray[1];
|
|
619
|
-
if (!Array.isArray(properties)) return null;
|
|
620
|
-
|
|
621
|
-
for (const prop of properties) {
|
|
622
|
-
if (Array.isArray(prop) && prop[0] === 'tel') {
|
|
623
|
-
// tel value can be a string or a uri like "tel:+1.5555555555"
|
|
624
|
-
const value = prop[3];
|
|
625
|
-
if (typeof value === 'string') {
|
|
626
|
-
return value.startsWith('tel:') ? value.slice(4) : value;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Extract country from an entity's vcardArray address field
|
|
635
|
-
*/
|
|
636
|
-
private extractVcardCountry(entity: any): string | null {
|
|
637
|
-
if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null;
|
|
638
|
-
const properties = entity.vcardArray[1];
|
|
639
|
-
if (!Array.isArray(properties)) return null;
|
|
640
|
-
|
|
641
|
-
for (const prop of properties) {
|
|
642
|
-
if (Array.isArray(prop) && prop[0] === 'adr') {
|
|
643
|
-
// The label parameter often contains the full address with country at the end
|
|
644
|
-
const label = prop[1]?.label;
|
|
645
|
-
if (typeof label === 'string') {
|
|
646
|
-
const lines = label.split('\n');
|
|
647
|
-
const lastLine = lines[lines.length - 1]?.trim();
|
|
648
|
-
if (lastLine && lastLine.length > 1) return lastLine;
|
|
649
|
-
}
|
|
650
|
-
// Also check the structured value (7-element array, last element is country)
|
|
651
|
-
const value = prop[3];
|
|
652
|
-
if (Array.isArray(value) && value.length >= 7 && value[6]) {
|
|
653
|
-
return value[6];
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
366
|
}
|