@mailwoman/corpus 9.3.0 → 9.4.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/lib/recipes/anchor-absorption.ts +2 -2
- package/lib/recipes/bare-country.ts +2 -2
- package/lib/recipes/boundary-stress.ts +3 -1
- package/lib/recipes/country-balanced.ts +2 -1
- package/lib/recipes/cz-pcfirst-preposition.ts +2 -1
- package/lib/recipes/fr-admin-split.ts +2 -1
- package/lib/recipes/fr-bare-street.ts +2 -1
- package/lib/recipes/fr-fragment.ts +2 -1
- package/lib/recipes/fr-lieudit.ts +2 -2
- package/lib/recipes/fr-order.ts +2 -1
- package/lib/recipes/german.ts +2 -1
- package/lib/recipes/house-venue.ts +2 -1
- package/lib/recipes/intersection.ts +2 -1
- package/lib/recipes/locale.ts +2 -1
- package/lib/recipes/nl-postcode.ts +3 -1
- package/lib/recipes/no-fragment.ts +2 -8
- package/lib/recipes/no-street-led.ts +2 -8
- package/lib/recipes/no-street.ts +2 -1
- package/lib/recipes/po-box-cedex.ts +1 -1
- package/lib/recipes/po-box.ts +2 -1
- package/lib/recipes/scaffold.ts +0 -1
- package/lib/recipes/si-bare-village.ts +3 -1
- package/lib/recipes/street-affix.ts +3 -2
- package/lib/recipes/street-bare.ts +3 -1
- package/lib/recipes/street.ts +3 -1
- package/lib/recipes/sub-venue-sources.ts +2 -1
- package/lib/recipes/sub-venue.ts +2 -1
- package/lib/recipes/trailing-region.ts +3 -8
- package/lib/recipes/unit.ts +2 -2
- package/lib/tools/fetch/acra-sg.ts +174 -0
- package/lib/tools/fetch/download.ts +217 -2
- package/lib/tools/fetch/gcis-tw.ts +175 -0
- package/lib/tools/fetch/houjin-jp.ts +135 -0
- package/lib/tools/fetch/index.ts +27 -0
- package/lib/tools/fetch/juso-kr.ts +165 -0
- package/lib/tools/fetch/localdata-kr.ts +194 -0
- package/lib/tools/golden-relabel-street.ts +6 -3
- package/out/recipes/anchor-absorption.d.ts.map +1 -1
- package/out/recipes/anchor-absorption.js +2 -1
- package/out/recipes/anchor-absorption.js.map +1 -1
- package/out/recipes/bare-country.d.ts.map +1 -1
- package/out/recipes/bare-country.js +2 -1
- package/out/recipes/bare-country.js.map +1 -1
- package/out/recipes/boundary-stress.d.ts.map +1 -1
- package/out/recipes/boundary-stress.js +2 -1
- package/out/recipes/boundary-stress.js.map +1 -1
- package/out/recipes/country-balanced.d.ts.map +1 -1
- package/out/recipes/country-balanced.js +2 -1
- package/out/recipes/country-balanced.js.map +1 -1
- package/out/recipes/cz-pcfirst-preposition.d.ts.map +1 -1
- package/out/recipes/cz-pcfirst-preposition.js +2 -1
- package/out/recipes/cz-pcfirst-preposition.js.map +1 -1
- package/out/recipes/fr-admin-split.d.ts +1 -1
- package/out/recipes/fr-admin-split.d.ts.map +1 -1
- package/out/recipes/fr-admin-split.js +1 -1
- package/out/recipes/fr-admin-split.js.map +1 -1
- package/out/recipes/fr-bare-street.d.ts.map +1 -1
- package/out/recipes/fr-bare-street.js +2 -1
- package/out/recipes/fr-bare-street.js.map +1 -1
- package/out/recipes/fr-fragment.d.ts.map +1 -1
- package/out/recipes/fr-fragment.js +2 -1
- package/out/recipes/fr-fragment.js.map +1 -1
- package/out/recipes/fr-lieudit.d.ts +1 -1
- package/out/recipes/fr-lieudit.d.ts.map +1 -1
- package/out/recipes/fr-lieudit.js +1 -1
- package/out/recipes/fr-lieudit.js.map +1 -1
- package/out/recipes/fr-order.d.ts.map +1 -1
- package/out/recipes/fr-order.js +2 -1
- package/out/recipes/fr-order.js.map +1 -1
- package/out/recipes/german.d.ts.map +1 -1
- package/out/recipes/german.js +2 -1
- package/out/recipes/german.js.map +1 -1
- package/out/recipes/house-venue.d.ts.map +1 -1
- package/out/recipes/house-venue.js +2 -1
- package/out/recipes/house-venue.js.map +1 -1
- package/out/recipes/intersection.d.ts.map +1 -1
- package/out/recipes/intersection.js +2 -1
- package/out/recipes/intersection.js.map +1 -1
- package/out/recipes/locale.d.ts +1 -1
- package/out/recipes/locale.d.ts.map +1 -1
- package/out/recipes/locale.js +1 -1
- package/out/recipes/locale.js.map +1 -1
- package/out/recipes/nl-postcode.d.ts.map +1 -1
- package/out/recipes/nl-postcode.js +2 -1
- package/out/recipes/nl-postcode.js.map +1 -1
- package/out/recipes/no-fragment.d.ts.map +1 -1
- package/out/recipes/no-fragment.js +2 -1
- package/out/recipes/no-fragment.js.map +1 -1
- package/out/recipes/no-street-led.d.ts.map +1 -1
- package/out/recipes/no-street-led.js +2 -1
- package/out/recipes/no-street-led.js.map +1 -1
- package/out/recipes/no-street.d.ts.map +1 -1
- package/out/recipes/no-street.js +2 -1
- package/out/recipes/no-street.js.map +1 -1
- package/out/recipes/po-box-cedex.d.ts.map +1 -1
- package/out/recipes/po-box-cedex.js +2 -1
- package/out/recipes/po-box-cedex.js.map +1 -1
- package/out/recipes/po-box.d.ts.map +1 -1
- package/out/recipes/po-box.js +2 -1
- package/out/recipes/po-box.js.map +1 -1
- package/out/recipes/scaffold.d.ts +0 -1
- package/out/recipes/scaffold.d.ts.map +1 -1
- package/out/recipes/scaffold.js +0 -12
- package/out/recipes/scaffold.js.map +1 -1
- package/out/recipes/si-bare-village.d.ts.map +1 -1
- package/out/recipes/si-bare-village.js +2 -1
- package/out/recipes/si-bare-village.js.map +1 -1
- package/out/recipes/street-affix.d.ts.map +1 -1
- package/out/recipes/street-affix.js +3 -3
- package/out/recipes/street-affix.js.map +1 -1
- package/out/recipes/street-bare.d.ts.map +1 -1
- package/out/recipes/street-bare.js +2 -1
- package/out/recipes/street-bare.js.map +1 -1
- package/out/recipes/street.d.ts.map +1 -1
- package/out/recipes/street.js +2 -1
- package/out/recipes/street.js.map +1 -1
- package/out/recipes/sub-venue-sources.d.ts.map +1 -1
- package/out/recipes/sub-venue-sources.js +2 -1
- package/out/recipes/sub-venue-sources.js.map +1 -1
- package/out/recipes/sub-venue.d.ts.map +1 -1
- package/out/recipes/sub-venue.js +2 -1
- package/out/recipes/sub-venue.js.map +1 -1
- package/out/recipes/trailing-region.d.ts.map +1 -1
- package/out/recipes/trailing-region.js +2 -1
- package/out/recipes/trailing-region.js.map +1 -1
- package/out/recipes/unit.d.ts.map +1 -1
- package/out/recipes/unit.js +2 -1
- package/out/recipes/unit.js.map +1 -1
- package/out/tools/fetch/acra-sg.d.ts +23 -0
- package/out/tools/fetch/acra-sg.d.ts.map +1 -0
- package/out/tools/fetch/acra-sg.js +126 -0
- package/out/tools/fetch/acra-sg.js.map +1 -0
- package/out/tools/fetch/download.d.ts +65 -0
- package/out/tools/fetch/download.d.ts.map +1 -1
- package/out/tools/fetch/download.js +160 -2
- package/out/tools/fetch/download.js.map +1 -1
- package/out/tools/fetch/gcis-tw.d.ts +23 -0
- package/out/tools/fetch/gcis-tw.d.ts.map +1 -0
- package/out/tools/fetch/gcis-tw.js +135 -0
- package/out/tools/fetch/gcis-tw.js.map +1 -0
- package/out/tools/fetch/houjin-jp.d.ts +24 -0
- package/out/tools/fetch/houjin-jp.d.ts.map +1 -0
- package/out/tools/fetch/houjin-jp.js +102 -0
- package/out/tools/fetch/houjin-jp.js.map +1 -0
- package/out/tools/fetch/index.d.ts +27 -0
- package/out/tools/fetch/index.d.ts.map +1 -1
- package/out/tools/fetch/index.js +27 -0
- package/out/tools/fetch/index.js.map +1 -1
- package/out/tools/fetch/juso-kr.d.ts +30 -0
- package/out/tools/fetch/juso-kr.d.ts.map +1 -0
- package/out/tools/fetch/juso-kr.js +120 -0
- package/out/tools/fetch/juso-kr.js.map +1 -0
- package/out/tools/fetch/localdata-kr.d.ts +30 -0
- package/out/tools/fetch/localdata-kr.d.ts.map +1 -0
- package/out/tools/fetch/localdata-kr.js +139 -0
- package/out/tools/fetch/localdata-kr.js.map +1 -0
- package/out/tools/golden-relabel-street.d.ts.map +1 -1
- package/out/tools/golden-relabel-street.js +1 -2
- package/out/tools/golden-relabel-street.js.map +1 -1
- package/package.json +10 -10
- package/lib/name-prone-us-suffixes.ts +0 -12
- package/out/name-prone-us-suffixes.d.ts +0 -12
- package/out/name-prone-us-suffixes.d.ts.map +0 -1
- package/out/name-prone-us-suffixes.js +0 -12
- package/out/name-prone-us-suffixes.js.map +0 -1
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* Download and manifest utilities for `mailwoman corpus fetch <source>`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { pathExists, readLocalTextFile } from "@mailwoman/core/fs/readers"
|
|
9
|
+
import { pathExists, readLocalTextFile, tryStat } from "@mailwoman/core/fs/readers"
|
|
10
10
|
import { openWriteStream, pipeline, Readable } from "@mailwoman/core/fs/streams"
|
|
11
|
-
import { writeLocalFile, writeLocalTextFile } from "@mailwoman/core/fs/writers"
|
|
11
|
+
import { movePath, writeLocalFile, writeLocalTextFile } from "@mailwoman/core/fs/writers"
|
|
12
12
|
import { tryParsingJSON } from "@mailwoman/core/json"
|
|
13
13
|
import { sleep } from "@mailwoman/core/utils/sleep"
|
|
14
14
|
import type { PathBuilderLike } from "path-ts"
|
|
@@ -18,6 +18,17 @@ import type { PathBuilderLike } from "path-ts"
|
|
|
18
18
|
*/
|
|
19
19
|
const HTTP_TOO_MANY_REQUESTS = 429
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The answer to a satisfiable `Range` request; anything else means the host ignored the range and would send the whole
|
|
23
|
+
* body again.
|
|
24
|
+
*/
|
|
25
|
+
const HTTP_PARTIAL_CONTENT = 206
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The answer to a range that starts at or past the end of the body: the resume point is already the whole file.
|
|
29
|
+
*/
|
|
30
|
+
const HTTP_RANGE_NOT_SATISFIABLE = 416
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* Lowest 5xx status. Server-side failures are retryable; 4xx are not.
|
|
23
34
|
*/
|
|
@@ -260,3 +271,207 @@ export async function loadManifestEntries<T>(path: string, key: (entry: T) => st
|
|
|
260
271
|
export async function writeManifest(path: string, manifest: unknown): Promise<void> {
|
|
261
272
|
await writeLocalTextFile(JSON.stringify(manifest, null, 2) + "\n", path)
|
|
262
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The sibling `MANIFEST.json` shape for a source that is a COLLECTION of files behind one portal (a monthly register
|
|
277
|
+
* published per region, per industry, or per first letter). It carries what a trained artifact has to be able to cite
|
|
278
|
+
* later: the license the portal labels the data with, the attribution wording it requires, and one
|
|
279
|
+
* {@link SourceManifest} per file.
|
|
280
|
+
*/
|
|
281
|
+
export interface SourceCollectionManifest {
|
|
282
|
+
source: string
|
|
283
|
+
source_url: string
|
|
284
|
+
license: string
|
|
285
|
+
attribution: string
|
|
286
|
+
downloaded_at: string
|
|
287
|
+
files: SourceManifest[]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Pipe a response body to `dest` and answer the byte count. The one primitive the portal fetchers share when the
|
|
292
|
+
* request is not a bare GET — a session cookie, a CSRF header, or a form POST stands between the listing and the file,
|
|
293
|
+
* so {@link streamDownload}'s URL-only contract does not fit and each module builds its own `Response` first. Writes a
|
|
294
|
+
* `.tmp` sibling and renames, so an interrupted transfer never lands at the final path looking complete.
|
|
295
|
+
*/
|
|
296
|
+
export async function streamBodyToFile(res: Response, dest: string): Promise<number> {
|
|
297
|
+
if (!res.body) throw new HTTPStatusError(res.status, `HTTP ${res.status} with no body — ${res.url}`)
|
|
298
|
+
let bytes = 0
|
|
299
|
+
|
|
300
|
+
const counter = new TransformStream<Uint8Array, Uint8Array>({
|
|
301
|
+
transform(chunk, controller) {
|
|
302
|
+
bytes += chunk.byteLength
|
|
303
|
+
controller.enqueue(chunk)
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const tmp = dest + ".tmp"
|
|
308
|
+
await pipeline(Readable.fromWeb(res.body.pipeThrough(counter)), openWriteStream(tmp))
|
|
309
|
+
await movePath(tmp, dest)
|
|
310
|
+
|
|
311
|
+
return bytes
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The per-file entries of a {@link SourceCollectionManifest} keyed by file name, or an empty map when the manifest is
|
|
316
|
+
* missing or not in the collection shape, so a re-run after an interruption fetches only what is missing. The
|
|
317
|
+
* single-file modules' `loadManifestEntries` reads a bare array and is not this.
|
|
318
|
+
*/
|
|
319
|
+
export async function loadCollectionFiles(path: string): Promise<Map<string, SourceManifest>> {
|
|
320
|
+
const parsed = await readManifest<SourceCollectionManifest>(path)
|
|
321
|
+
const entries = new Map<string, SourceManifest>()
|
|
322
|
+
|
|
323
|
+
for (const entry of Array.isArray(parsed?.files) ? parsed.files : []) {
|
|
324
|
+
entries.set(entry.filename, entry)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return entries
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Run one transfer up to `1 + retries` times, pausing `retryDelayMs` between attempts. A government portal drops a
|
|
332
|
+
* multi-hundred-megabyte connection often enough that a collection fetch which lets the first `TypeError: terminated`
|
|
333
|
+
* propagate loses the whole run to one file; the per-file loop calls the transfer through this and records the failure
|
|
334
|
+
* only once the attempts are spent. The last error is rethrown so the caller can name the file it lost.
|
|
335
|
+
*/
|
|
336
|
+
export async function withRetries<T>(
|
|
337
|
+
transfer: () => Promise<T>,
|
|
338
|
+
options: { retries?: number; retryDelayMs?: number; report?: (line: string) => void; label?: string } = {}
|
|
339
|
+
): Promise<T> {
|
|
340
|
+
const { retries = 3, retryDelayMs = DEFAULT_RETRY_DELAY_MS, report, label = "transfer" } = options
|
|
341
|
+
let lastError: unknown
|
|
342
|
+
|
|
343
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
344
|
+
if (attempt > 0) {
|
|
345
|
+
report?.(` retry ${attempt}/${retries} after ${retryDelayMs}ms — ${label}`)
|
|
346
|
+
await sleep(retryDelayMs)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
return await transfer()
|
|
351
|
+
} catch (error) {
|
|
352
|
+
lastError = error
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The byte count already at `path`, or 0 when nothing is there — the resume point of an interrupted range download.
|
|
361
|
+
*/
|
|
362
|
+
async function bytesOnDisk(path: string): Promise<number> {
|
|
363
|
+
const stat = await tryStat(path)
|
|
364
|
+
|
|
365
|
+
return stat?.size ?? 0
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Download `url` to `dest` in ranges, resuming from whatever the `.tmp` sibling already holds. For a host that drops a
|
|
370
|
+
* long connection every few megabytes but answers `Range` with 206 (the Korean address portal does both, measured at
|
|
371
|
+
* 0.9–12 MB per connection against a 181 MB file), a whole-body transfer never finishes and a plain retry starts over;
|
|
372
|
+
* this one asks for the remainder each time and keeps what landed. The total comes from the first `Content-Range`, and
|
|
373
|
+
* the loop gives up after `maxConnections` connections so a host that keeps answering 206 with no bytes cannot spin.
|
|
374
|
+
* Answers the byte count written; throws when the host answers anything but 206 for a range.
|
|
375
|
+
*/
|
|
376
|
+
export async function resumableDownload(options: {
|
|
377
|
+
url: string
|
|
378
|
+
dest: string
|
|
379
|
+
headers?: Record<string, string>
|
|
380
|
+
maxConnections?: number
|
|
381
|
+
retryDelayMs?: number
|
|
382
|
+
report?: (line: string) => void
|
|
383
|
+
}): Promise<number> {
|
|
384
|
+
const { url, dest, headers = {}, maxConnections = 400, retryDelayMs = DEFAULT_RETRY_DELAY_MS, report } = options
|
|
385
|
+
const tmp = dest + ".tmp"
|
|
386
|
+
let have = await bytesOnDisk(tmp)
|
|
387
|
+
let total: number | undefined
|
|
388
|
+
|
|
389
|
+
for (let connection = 0; connection < maxConnections; connection++) {
|
|
390
|
+
if (total !== undefined && have >= total) break
|
|
391
|
+
|
|
392
|
+
let res: Response
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
res = await fetch(url, { headers: { ...headers, range: `bytes=${have}-` }, signal: AbortSignal.timeout(600_000) })
|
|
396
|
+
} catch {
|
|
397
|
+
await sleep(retryDelayMs)
|
|
398
|
+
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (res.status === HTTP_RANGE_NOT_SATISFIABLE && have > 0) {
|
|
403
|
+
// Nothing past `have`: the file on disk is already the whole body (a parallel filler or an earlier run
|
|
404
|
+
// landed it), and `Content-Range: bytes */<total>` says how long it is.
|
|
405
|
+
const whole = /\*\/(\d+)/.exec(res.headers.get("content-range") ?? "")?.[1]
|
|
406
|
+
total = whole ? Number(whole) : have
|
|
407
|
+
await res.body?.cancel()
|
|
408
|
+
|
|
409
|
+
break
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (res.status !== HTTP_PARTIAL_CONTENT) {
|
|
413
|
+
throw new HTTPStatusError(res.status, `HTTP ${res.status} for a range request — ${url}`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const range = /bytes \d+-\d+\/(\d+)/.exec(res.headers.get("content-range") ?? "")?.[1]
|
|
417
|
+
|
|
418
|
+
if (range) {
|
|
419
|
+
total = Number(range)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const before = have
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
// Append: the stream opens the file for appending so a partial body extends what earlier connections left.
|
|
426
|
+
await pipeline(
|
|
427
|
+
Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]),
|
|
428
|
+
openWriteStream(tmp, { flags: "a" })
|
|
429
|
+
)
|
|
430
|
+
} catch {
|
|
431
|
+
// The connection dropped mid-body; the bytes that landed are on disk, and the next range starts after them.
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
have = await bytesOnDisk(tmp)
|
|
435
|
+
|
|
436
|
+
if (have === before) {
|
|
437
|
+
await sleep(retryDelayMs)
|
|
438
|
+
} else {
|
|
439
|
+
report?.(` ${(have / 1024 / 1024).toFixed(1)} MB${total ? ` of ${(total / 1024 / 1024).toFixed(1)} MB` : ""}`)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (total === undefined || have < total) {
|
|
444
|
+
throw new Error(`resumable download stalled at ${have} of ${total ?? "?"} bytes — ${url}`)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await movePath(tmp, dest)
|
|
448
|
+
|
|
449
|
+
return have
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The `Set-Cookie` values of a response folded into one `Cookie` header value, so a second request to the same portal
|
|
454
|
+
* carries the session the first one opened.
|
|
455
|
+
*/
|
|
456
|
+
export function cookieHeader(res: Response): string {
|
|
457
|
+
return res.headers
|
|
458
|
+
.getSetCookie()
|
|
459
|
+
.map((cookie) => cookie.split(";")[0])
|
|
460
|
+
.join("; ")
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The file name a `Content-Disposition: attachment` header names, RFC 5987 form (`filename*=utf-8''…`) first, plain
|
|
465
|
+
* `filename="…"` second, or `fallback` when the header carries neither.
|
|
466
|
+
*/
|
|
467
|
+
export function attachmentFilename(res: Response, fallback: string): string {
|
|
468
|
+
const disposition = res.headers.get("content-disposition") ?? ""
|
|
469
|
+
const extended = /filename\*=(?:utf-8|UTF-8)'[^']*'([^;]+)/.exec(disposition)?.[1]
|
|
470
|
+
|
|
471
|
+
if (extended) return decodeURIComponent(extended.trim())
|
|
472
|
+
const plain = /filename="?([^";]+)"?/.exec(disposition)?.[1]
|
|
473
|
+
|
|
474
|
+
if (plain) return decodeURIComponent(plain.trim())
|
|
475
|
+
|
|
476
|
+
return fallback
|
|
477
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Fetch Taiwan's company and business registers (公司登記資料, 商業登記資料) from the Ministry of
|
|
7
|
+
* Economic Affairs' commerce open-data platform, data.gcis.nat.gov.tw. The platform publishes the
|
|
8
|
+
* full registers as CSV files split by region (the six special municipalities each on their own,
|
|
9
|
+
* the rest of the island in four bands) and by industry letter, 148 files in all, each row carrying
|
|
10
|
+
* the registered address (公司地址) and, for companies, the tax office's business address
|
|
11
|
+
* (營業地址) as free text. This is the NOISY source for Taiwanese addresses: a person typed these.
|
|
12
|
+
*
|
|
13
|
+
* License: 政府資料開放授權條款-第1版 (Open Government Data License, Taiwan, v1.0). The platform's
|
|
14
|
+
* own dialog states the condition that binds: attribution in the form it prescribes, or the grant is
|
|
15
|
+
* void from the start ("未盡顯名標示義務者,視為自始未取得開放資料之授權"). The manifest records the
|
|
16
|
+
* prescribed wording per file so the corpus build and the model card can carry it.
|
|
17
|
+
*
|
|
18
|
+
* Invoke via `mailwoman corpus fetch gcis-tw --out-root <path>`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { BYTES_PER_KIB } from "@mailwoman/core/fs/formatters"
|
|
22
|
+
import { pathExists } from "@mailwoman/core/fs/readers"
|
|
23
|
+
import { makeDirectories } from "@mailwoman/core/fs/writers"
|
|
24
|
+
import { sha256File } from "@mailwoman/core/hash"
|
|
25
|
+
import { sleep } from "@mailwoman/core/utils/sleep"
|
|
26
|
+
import { join } from "path-ts"
|
|
27
|
+
|
|
28
|
+
import type { BaseFetchOptions, FetchSummary, SourceCollectionManifest, SourceManifest } from "#tools/fetch/download"
|
|
29
|
+
import { loadCollectionFiles, streamBodyToFile, withRetries, writeManifest } from "#tools/fetch/download"
|
|
30
|
+
|
|
31
|
+
const SLUG = "gcis-tw"
|
|
32
|
+
const PORTAL = "https://data.gcis.nat.gov.tw"
|
|
33
|
+
const CATALOG_URL = `${PORTAL}/od/datacategory`
|
|
34
|
+
const LICENSE = "政府資料開放授權條款-第1版 (Open Government Data License, Taiwan, v1.0) — http://data.gov.tw/license"
|
|
35
|
+
const ATTRIBUTION = "提供機關/經濟部商業發展署 [<dataset title>] — the 顯名聲明 each file's dialog prescribes"
|
|
36
|
+
const PACE_MS = 300
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The bulk register files: `<region><register>資料-<industry letter><industry>`. The per-industry API datasets and the
|
|
40
|
+
* monthly new-registration lists share the catalog and are not these.
|
|
41
|
+
*/
|
|
42
|
+
const BULK_TITLE = /(公司登記資料|商業登記資料)-[A-Z]/
|
|
43
|
+
|
|
44
|
+
export type FetchGCISTWOptions = BaseFetchOptions
|
|
45
|
+
|
|
46
|
+
interface Dataset {
|
|
47
|
+
title: string
|
|
48
|
+
detailPath: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A file name a shell and a manifest can carry: the title with its punctuation folded to underscores.
|
|
53
|
+
*/
|
|
54
|
+
function filenameFor(title: string): string {
|
|
55
|
+
return `${title
|
|
56
|
+
.replaceAll(/[()()、,/\\\s]+/g, "_")
|
|
57
|
+
.replaceAll(/_+/g, "_")
|
|
58
|
+
.replaceAll(/^_|_$/g, "")}.csv`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listBulkDatasets(): Promise<Dataset[]> {
|
|
62
|
+
const res = await fetch(CATALOG_URL, { headers: { accept: "text/html" } })
|
|
63
|
+
|
|
64
|
+
if (!res.ok) throw new Error(`gcis-tw: the catalog answered HTTP ${res.status}`)
|
|
65
|
+
const html = await res.text()
|
|
66
|
+
const datasets: Dataset[] = []
|
|
67
|
+
|
|
68
|
+
for (const [, path, rawTitle] of html.matchAll(/<a[^>]*href="(\/od\/detail[^"]*)"[^>]*>([^<]+)<\/a>/g)) {
|
|
69
|
+
const title = rawTitle?.trim()
|
|
70
|
+
|
|
71
|
+
if (!path || !title || !BULK_TITLE.test(title)) continue
|
|
72
|
+
datasets.push({ title, detailPath: path.replace(/;jsessionid=[^?]*/, "") })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return datasets
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The `/od/file?oid=…` link a dataset's detail page hands its download dialog.
|
|
80
|
+
*/
|
|
81
|
+
async function fileURLFor(dataset: Dataset): Promise<string | undefined> {
|
|
82
|
+
const res = await fetch(`${PORTAL}${dataset.detailPath}`, { headers: { accept: "text/html" } })
|
|
83
|
+
|
|
84
|
+
if (!res.ok) return undefined
|
|
85
|
+
const html = await res.text()
|
|
86
|
+
const path = /showDialog\('(\/od\/file\?oid=[^']+)'\)/.exec(html)?.[1]
|
|
87
|
+
|
|
88
|
+
return path ? `${PORTAL}${path}` : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function fetchGCISTW(options: FetchGCISTWOptions, report?: (line: string) => void): Promise<FetchSummary> {
|
|
92
|
+
const destDir = join(options.outRoot, SLUG)
|
|
93
|
+
await makeDirectories(destDir)
|
|
94
|
+
const manifestPath = join(destDir, "MANIFEST.json")
|
|
95
|
+
|
|
96
|
+
const datasets = await listBulkDatasets()
|
|
97
|
+
report?.(`=== ${SLUG}: ${datasets.length} bulk register files in the catalog`)
|
|
98
|
+
|
|
99
|
+
const previous = await loadCollectionFiles(manifestPath)
|
|
100
|
+
const files = new Map<string, SourceManifest>()
|
|
101
|
+
let fetched = 0
|
|
102
|
+
let skipped = 0
|
|
103
|
+
const failedCodes: string[] = []
|
|
104
|
+
|
|
105
|
+
for (const dataset of datasets) {
|
|
106
|
+
const filename = filenameFor(dataset.title)
|
|
107
|
+
const dest = join(destDir, filename)
|
|
108
|
+
const before = previous.get(filename)
|
|
109
|
+
|
|
110
|
+
if (before && (await pathExists(dest))) {
|
|
111
|
+
files.set(filename, before)
|
|
112
|
+
|
|
113
|
+
skipped++
|
|
114
|
+
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await sleep(PACE_MS)
|
|
119
|
+
const url = await fileURLFor(dataset)
|
|
120
|
+
|
|
121
|
+
if (!url) {
|
|
122
|
+
report?.(` ✗ no file link on the detail page for ${dataset.title}`)
|
|
123
|
+
failedCodes.push(dataset.title)
|
|
124
|
+
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
report?.(`--- ${dataset.title}`)
|
|
129
|
+
let bytes: number
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
bytes = await withRetries(
|
|
133
|
+
async () => {
|
|
134
|
+
const res = await fetch(url, { headers: { accept: "*/*" }, signal: AbortSignal.timeout(1_800_000) })
|
|
135
|
+
|
|
136
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} for ${dataset.title}`)
|
|
137
|
+
|
|
138
|
+
return streamBodyToFile(res, dest)
|
|
139
|
+
},
|
|
140
|
+
{ report, label: dataset.title }
|
|
141
|
+
)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
report?.(` ✗ ${(error as Error).message}`)
|
|
144
|
+
failedCodes.push(dataset.title)
|
|
145
|
+
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (bytes < BYTES_PER_KIB) {
|
|
150
|
+
report?.(` ✗ ${bytes} bytes — an error page, not the register`)
|
|
151
|
+
failedCodes.push(dataset.title)
|
|
152
|
+
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sha = await sha256File(dest)
|
|
157
|
+
files.set(filename, { source_url: url, downloaded_at: new Date().toISOString(), filename, sha256: sha, bytes })
|
|
158
|
+
|
|
159
|
+
fetched++
|
|
160
|
+
report?.(` ✓ ${(bytes / 1024 / 1024).toFixed(1)} MB sha256=${sha}`)
|
|
161
|
+
|
|
162
|
+
const manifest: SourceCollectionManifest = {
|
|
163
|
+
source: SLUG,
|
|
164
|
+
source_url: CATALOG_URL,
|
|
165
|
+
license: LICENSE,
|
|
166
|
+
attribution: ATTRIBUTION,
|
|
167
|
+
downloaded_at: new Date().toISOString(),
|
|
168
|
+
files: [...files.values()],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await writeManifest(manifestPath, manifest)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { fetched, skipped, failed: failedCodes.length, failedCodes }
|
|
175
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Fetch the National Tax Agency's corporate-number register (法人番号公表サイト 全件データ): every
|
|
7
|
+
* corporation assigned a number, about 5,000,000 rows, with the head-office address as one string
|
|
8
|
+
* (国内所在地) plus the prefecture and municipality split out and the postcode. The nationwide CSV
|
|
9
|
+
* in Unicode is one zip of about 255 MB. This is the NOISY source for Japanese addresses; the
|
|
10
|
+
* Overture rows the JP corpus is built from are the LABEL half.
|
|
11
|
+
*
|
|
12
|
+
* The agency states the three published fields may be used freely by anyone ("どなたでも自由にご利用
|
|
13
|
+
* いただくことができます"), with no attribution condition on the download page.
|
|
14
|
+
*
|
|
15
|
+
* The file sits behind a form: the page hands out a per-session token, and a POST with that token,
|
|
16
|
+
* `event=download` and the file number of the nationwide Unicode CSV answers the zip. The file
|
|
17
|
+
* number is read off the page rather than pinned, because the agency re-issues the files monthly.
|
|
18
|
+
*
|
|
19
|
+
* Invoke via `mailwoman corpus fetch houjin-jp --out-root <path>`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { makeDirectories } from "@mailwoman/core/fs/writers"
|
|
23
|
+
import { sha256File } from "@mailwoman/core/hash"
|
|
24
|
+
import { join } from "path-ts"
|
|
25
|
+
|
|
26
|
+
import type { BaseFetchOptions, FetchSummary, SourceCollectionManifest } from "#tools/fetch/download"
|
|
27
|
+
import { attachmentFilename, cookieHeader, streamBodyToFile, withRetries, writeManifest } from "#tools/fetch/download"
|
|
28
|
+
|
|
29
|
+
const SLUG = "houjin-jp"
|
|
30
|
+
const PAGE_URL = "https://www.houjin-bangou.nta.go.jp/download/zenken/"
|
|
31
|
+
const FORM_URL = `${PAGE_URL}index.html`
|
|
32
|
+
const TOKEN_FIELD = "jp.go.nta.houjin_bangou.framework.web.common.CNSFWTokenProcessor.request.token"
|
|
33
|
+
const LICENSE = "国税庁法人番号公表サイト — the three published fields are free for anyone to use (利用規約)"
|
|
34
|
+
const ATTRIBUTION = "国税庁 法人番号公表サイト (National Tax Agency, Corporate Number Publication Site)"
|
|
35
|
+
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) mailwoman-corpus-fetch"
|
|
36
|
+
|
|
37
|
+
export type FetchHoujinJPOptions = BaseFetchOptions
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The nationwide row of the "CSV形式・Unicode" table carries the file number in its `doDownload(N)` handler.
|
|
41
|
+
*/
|
|
42
|
+
function nationwideUnicodeFileNumber(html: string): string | undefined {
|
|
43
|
+
const start = html.indexOf('id="csv-unicode"')
|
|
44
|
+
|
|
45
|
+
if (start === -1) return undefined
|
|
46
|
+
const section = html.slice(start, html.indexOf('id="xml-unicode"', start))
|
|
47
|
+
const row = /全国[\s\S]{0,600}?doDownload\((\d+)\)/.exec(section)
|
|
48
|
+
|
|
49
|
+
return row?.[1]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchHoujinJP(
|
|
53
|
+
options: FetchHoujinJPOptions,
|
|
54
|
+
report?: (line: string) => void
|
|
55
|
+
): Promise<FetchSummary> {
|
|
56
|
+
const destDir = join(options.outRoot, SLUG)
|
|
57
|
+
await makeDirectories(destDir)
|
|
58
|
+
|
|
59
|
+
const page = await fetch(PAGE_URL, { headers: { "user-agent": USER_AGENT, accept: "text/html" } })
|
|
60
|
+
|
|
61
|
+
if (!page.ok) throw new Error(`houjin-jp: the download page answered HTTP ${page.status}`)
|
|
62
|
+
const html = await page.text()
|
|
63
|
+
const token = new RegExp(`${TOKEN_FIELD.replaceAll(".", "\\.")}" value="([^"]+)"`).exec(html)?.[1]
|
|
64
|
+
const fileNumber = nationwideUnicodeFileNumber(html)
|
|
65
|
+
|
|
66
|
+
if (!token || !fileNumber) {
|
|
67
|
+
report?.(
|
|
68
|
+
` ✗ the page carried ${token ? "a token" : "no token"} and ${fileNumber ? `file ${fileNumber}` : "no nationwide Unicode CSV row"}`
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return { fetched: 0, skipped: 0, failed: 1, failedCodes: ["zenkoku-csv-unicode"] }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
report?.(`=== ${SLUG}: nationwide CSV (Unicode), file ${fileNumber}`)
|
|
75
|
+
|
|
76
|
+
let filename = `zenkoku_all_${fileNumber}.zip`
|
|
77
|
+
let bytes: number
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
bytes = await withRetries(
|
|
81
|
+
async () => {
|
|
82
|
+
// A fresh page per attempt: the token is bound to the session cookie, and both may have lapsed.
|
|
83
|
+
const attemptPage = await fetch(PAGE_URL, { headers: { "user-agent": USER_AGENT, accept: "text/html" } })
|
|
84
|
+
|
|
85
|
+
const attemptToken = new RegExp(`${TOKEN_FIELD.replaceAll(".", "\\.")}" value="([^"]+)"`).exec(
|
|
86
|
+
await attemptPage.text()
|
|
87
|
+
)?.[1]
|
|
88
|
+
|
|
89
|
+
if (!attemptToken) throw new Error("the download page carried no token")
|
|
90
|
+
|
|
91
|
+
const res = await fetch(FORM_URL, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"user-agent": USER_AGENT,
|
|
95
|
+
cookie: cookieHeader(attemptPage),
|
|
96
|
+
referer: PAGE_URL,
|
|
97
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
98
|
+
},
|
|
99
|
+
body: new URLSearchParams({ [TOKEN_FIELD]: attemptToken, event: "download", selDlFileNo: fileNumber }),
|
|
100
|
+
signal: AbortSignal.timeout(1_800_000),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (!res.ok || !(res.headers.get("content-type") ?? "").includes("octet-stream")) {
|
|
104
|
+
throw new Error(`HTTP ${res.status} ${res.headers.get("content-type") ?? ""}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
filename = attachmentFilename(res, filename)
|
|
108
|
+
|
|
109
|
+
return streamBodyToFile(res, join(destDir, filename))
|
|
110
|
+
},
|
|
111
|
+
{ report, label: filename }
|
|
112
|
+
)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
report?.(` ✗ ${(error as Error).message}`)
|
|
115
|
+
|
|
116
|
+
return { fetched: 0, skipped: 0, failed: 1, failedCodes: ["zenkoku-csv-unicode"] }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const dest = join(destDir, filename)
|
|
120
|
+
const sha = await sha256File(dest)
|
|
121
|
+
report?.(` ✓ ${filename} ${(bytes / 1024 / 1024).toFixed(1)} MB sha256=${sha}`)
|
|
122
|
+
|
|
123
|
+
const manifest: SourceCollectionManifest = {
|
|
124
|
+
source: SLUG,
|
|
125
|
+
source_url: PAGE_URL,
|
|
126
|
+
license: LICENSE,
|
|
127
|
+
attribution: ATTRIBUTION,
|
|
128
|
+
downloaded_at: new Date().toISOString(),
|
|
129
|
+
files: [{ source_url: FORM_URL, downloaded_at: new Date().toISOString(), filename, sha256: sha, bytes }],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await writeManifest(join(destDir, "MANIFEST.json"), manifest)
|
|
133
|
+
|
|
134
|
+
return { fetched: 1, skipped: 0, failed: 0, failedCodes: [] }
|
|
135
|
+
}
|
package/lib/tools/fetch/index.ts
CHANGED
|
@@ -57,6 +57,18 @@
|
|
|
57
57
|
* - `state-hi-schools` — Hawaii DOE school directory (original XLSX; HIDOE + PCS sheets). Tier A
|
|
58
58
|
* (state PD-equivalent).
|
|
59
59
|
* - `tiger-full` — US Census TIGER 2024 ADDRFEAT, all US counties. Tier A (US PD).
|
|
60
|
+
* - `juso-kr` — the Korean road-name address register (주소DB, plus the English road-name DB) from
|
|
61
|
+
* business.juso.go.kr, the LABEL source for Korean. Tier B ("이용허락범위 제한 없음", attribution).
|
|
62
|
+
* - `localdata-kr` — Korea's local-government permit registry, one CSV per category, each row carrying
|
|
63
|
+
* the lot-number AND road-name address of one premises plus a coordinate. The NOISY source for
|
|
64
|
+
* Korean. Tier B (same label as `juso-kr`).
|
|
65
|
+
* - `gcis-tw` — Taiwan's company and business registers from the economic ministry's open-data
|
|
66
|
+
* platform, 148 files by region and industry. The NOISY source for Taiwanese. Tier B (Open
|
|
67
|
+
* Government Data License v1.0 — the prescribed attribution is a condition of the grant).
|
|
68
|
+
* - `acra-sg` — Singapore's ACRA corporate-entity register, 27 CSVs with the address FIELDED. The
|
|
69
|
+
* NOISY source for Singapore. Tier B (Singapore Open Data Licence 1.0).
|
|
70
|
+
* - `houjin-jp` — the National Tax Agency's corporate-number register, the nationwide Unicode CSV.
|
|
71
|
+
* The NOISY source for Japanese. Tier A-equivalent (free use, no attribution condition stated).
|
|
60
72
|
*
|
|
61
73
|
* License tiers per `docs/licensing-strategy.md` (or the playpen knowledge base mirror at
|
|
62
74
|
* `docs/docs/projects/mailwoman/licensing-strategy.md`). `openaddresses` is a **Tier-mixed**
|
|
@@ -97,11 +109,16 @@
|
|
|
97
109
|
* 5. Add the source's adapter (or extend an existing one) under `corpus/src/adapters/`.
|
|
98
110
|
*/
|
|
99
111
|
|
|
112
|
+
import { fetchACRASG } from "#tools/fetch/acra-sg"
|
|
100
113
|
import { fetchBan } from "#tools/fetch/ban"
|
|
114
|
+
import { fetchGCISTW } from "#tools/fetch/gcis-tw"
|
|
101
115
|
import { fetchGeonamesDumps } from "#tools/fetch/geonames-dump"
|
|
102
116
|
import { fetchGeonamesPostal } from "#tools/fetch/geonames-postal"
|
|
117
|
+
import { fetchHoujinJP } from "#tools/fetch/houjin-jp"
|
|
103
118
|
import { fetchHRSA } from "#tools/fetch/hrsa"
|
|
104
119
|
import { fetchIMLSPLS } from "#tools/fetch/imls-pls"
|
|
120
|
+
import { fetchJusoKR } from "#tools/fetch/juso-kr"
|
|
121
|
+
import { fetchLocaldataKR } from "#tools/fetch/localdata-kr"
|
|
105
122
|
import { fetchNAD } from "#tools/fetch/nad"
|
|
106
123
|
import { fetchNPPES } from "#tools/fetch/nppes"
|
|
107
124
|
import { fetchOpenAddresses } from "#tools/fetch/openaddresses"
|
|
@@ -111,11 +128,16 @@ import { fetchStateSources } from "#tools/fetch/state-sources"
|
|
|
111
128
|
import { fetchTigerFull } from "#tools/fetch/tiger-full"
|
|
112
129
|
import { fetchWikidataSubVenue } from "#tools/fetch/wikidata-subvenue"
|
|
113
130
|
|
|
131
|
+
export * from "#tools/fetch/acra-sg"
|
|
114
132
|
export * from "#tools/fetch/ban"
|
|
133
|
+
export * from "#tools/fetch/gcis-tw"
|
|
115
134
|
export * from "#tools/fetch/geonames-dump"
|
|
116
135
|
export * from "#tools/fetch/geonames-postal"
|
|
136
|
+
export * from "#tools/fetch/houjin-jp"
|
|
117
137
|
export * from "#tools/fetch/hrsa"
|
|
118
138
|
export * from "#tools/fetch/imls-pls"
|
|
139
|
+
export * from "#tools/fetch/juso-kr"
|
|
140
|
+
export * from "#tools/fetch/localdata-kr"
|
|
119
141
|
export * from "#tools/fetch/nad"
|
|
120
142
|
export * from "#tools/fetch/nppes"
|
|
121
143
|
export * from "#tools/fetch/openaddresses"
|
|
@@ -129,7 +151,12 @@ export * from "#tools/fetch/wikidata-subvenue"
|
|
|
129
151
|
* The fetch-source registry: id → module entry point. Each entry point takes its own options interface.
|
|
130
152
|
*/
|
|
131
153
|
export const FETCH_SOURCES = {
|
|
154
|
+
"acra-sg": fetchACRASG,
|
|
132
155
|
ban: fetchBan,
|
|
156
|
+
"gcis-tw": fetchGCISTW,
|
|
157
|
+
"houjin-jp": fetchHoujinJP,
|
|
158
|
+
"juso-kr": fetchJusoKR,
|
|
159
|
+
"localdata-kr": fetchLocaldataKR,
|
|
133
160
|
nad: fetchNAD,
|
|
134
161
|
"geonames-dump": fetchGeonamesDumps,
|
|
135
162
|
"geonames-postal": fetchGeonamesPostal,
|