@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.
Files changed (165) hide show
  1. package/lib/recipes/anchor-absorption.ts +2 -2
  2. package/lib/recipes/bare-country.ts +2 -2
  3. package/lib/recipes/boundary-stress.ts +3 -1
  4. package/lib/recipes/country-balanced.ts +2 -1
  5. package/lib/recipes/cz-pcfirst-preposition.ts +2 -1
  6. package/lib/recipes/fr-admin-split.ts +2 -1
  7. package/lib/recipes/fr-bare-street.ts +2 -1
  8. package/lib/recipes/fr-fragment.ts +2 -1
  9. package/lib/recipes/fr-lieudit.ts +2 -2
  10. package/lib/recipes/fr-order.ts +2 -1
  11. package/lib/recipes/german.ts +2 -1
  12. package/lib/recipes/house-venue.ts +2 -1
  13. package/lib/recipes/intersection.ts +2 -1
  14. package/lib/recipes/locale.ts +2 -1
  15. package/lib/recipes/nl-postcode.ts +3 -1
  16. package/lib/recipes/no-fragment.ts +2 -8
  17. package/lib/recipes/no-street-led.ts +2 -8
  18. package/lib/recipes/no-street.ts +2 -1
  19. package/lib/recipes/po-box-cedex.ts +1 -1
  20. package/lib/recipes/po-box.ts +2 -1
  21. package/lib/recipes/scaffold.ts +0 -1
  22. package/lib/recipes/si-bare-village.ts +3 -1
  23. package/lib/recipes/street-affix.ts +3 -2
  24. package/lib/recipes/street-bare.ts +3 -1
  25. package/lib/recipes/street.ts +3 -1
  26. package/lib/recipes/sub-venue-sources.ts +2 -1
  27. package/lib/recipes/sub-venue.ts +2 -1
  28. package/lib/recipes/trailing-region.ts +3 -8
  29. package/lib/recipes/unit.ts +2 -2
  30. package/lib/tools/fetch/acra-sg.ts +174 -0
  31. package/lib/tools/fetch/download.ts +217 -2
  32. package/lib/tools/fetch/gcis-tw.ts +175 -0
  33. package/lib/tools/fetch/houjin-jp.ts +135 -0
  34. package/lib/tools/fetch/index.ts +27 -0
  35. package/lib/tools/fetch/juso-kr.ts +165 -0
  36. package/lib/tools/fetch/localdata-kr.ts +194 -0
  37. package/lib/tools/golden-relabel-street.ts +6 -3
  38. package/out/recipes/anchor-absorption.d.ts.map +1 -1
  39. package/out/recipes/anchor-absorption.js +2 -1
  40. package/out/recipes/anchor-absorption.js.map +1 -1
  41. package/out/recipes/bare-country.d.ts.map +1 -1
  42. package/out/recipes/bare-country.js +2 -1
  43. package/out/recipes/bare-country.js.map +1 -1
  44. package/out/recipes/boundary-stress.d.ts.map +1 -1
  45. package/out/recipes/boundary-stress.js +2 -1
  46. package/out/recipes/boundary-stress.js.map +1 -1
  47. package/out/recipes/country-balanced.d.ts.map +1 -1
  48. package/out/recipes/country-balanced.js +2 -1
  49. package/out/recipes/country-balanced.js.map +1 -1
  50. package/out/recipes/cz-pcfirst-preposition.d.ts.map +1 -1
  51. package/out/recipes/cz-pcfirst-preposition.js +2 -1
  52. package/out/recipes/cz-pcfirst-preposition.js.map +1 -1
  53. package/out/recipes/fr-admin-split.d.ts +1 -1
  54. package/out/recipes/fr-admin-split.d.ts.map +1 -1
  55. package/out/recipes/fr-admin-split.js +1 -1
  56. package/out/recipes/fr-admin-split.js.map +1 -1
  57. package/out/recipes/fr-bare-street.d.ts.map +1 -1
  58. package/out/recipes/fr-bare-street.js +2 -1
  59. package/out/recipes/fr-bare-street.js.map +1 -1
  60. package/out/recipes/fr-fragment.d.ts.map +1 -1
  61. package/out/recipes/fr-fragment.js +2 -1
  62. package/out/recipes/fr-fragment.js.map +1 -1
  63. package/out/recipes/fr-lieudit.d.ts +1 -1
  64. package/out/recipes/fr-lieudit.d.ts.map +1 -1
  65. package/out/recipes/fr-lieudit.js +1 -1
  66. package/out/recipes/fr-lieudit.js.map +1 -1
  67. package/out/recipes/fr-order.d.ts.map +1 -1
  68. package/out/recipes/fr-order.js +2 -1
  69. package/out/recipes/fr-order.js.map +1 -1
  70. package/out/recipes/german.d.ts.map +1 -1
  71. package/out/recipes/german.js +2 -1
  72. package/out/recipes/german.js.map +1 -1
  73. package/out/recipes/house-venue.d.ts.map +1 -1
  74. package/out/recipes/house-venue.js +2 -1
  75. package/out/recipes/house-venue.js.map +1 -1
  76. package/out/recipes/intersection.d.ts.map +1 -1
  77. package/out/recipes/intersection.js +2 -1
  78. package/out/recipes/intersection.js.map +1 -1
  79. package/out/recipes/locale.d.ts +1 -1
  80. package/out/recipes/locale.d.ts.map +1 -1
  81. package/out/recipes/locale.js +1 -1
  82. package/out/recipes/locale.js.map +1 -1
  83. package/out/recipes/nl-postcode.d.ts.map +1 -1
  84. package/out/recipes/nl-postcode.js +2 -1
  85. package/out/recipes/nl-postcode.js.map +1 -1
  86. package/out/recipes/no-fragment.d.ts.map +1 -1
  87. package/out/recipes/no-fragment.js +2 -1
  88. package/out/recipes/no-fragment.js.map +1 -1
  89. package/out/recipes/no-street-led.d.ts.map +1 -1
  90. package/out/recipes/no-street-led.js +2 -1
  91. package/out/recipes/no-street-led.js.map +1 -1
  92. package/out/recipes/no-street.d.ts.map +1 -1
  93. package/out/recipes/no-street.js +2 -1
  94. package/out/recipes/no-street.js.map +1 -1
  95. package/out/recipes/po-box-cedex.d.ts.map +1 -1
  96. package/out/recipes/po-box-cedex.js +2 -1
  97. package/out/recipes/po-box-cedex.js.map +1 -1
  98. package/out/recipes/po-box.d.ts.map +1 -1
  99. package/out/recipes/po-box.js +2 -1
  100. package/out/recipes/po-box.js.map +1 -1
  101. package/out/recipes/scaffold.d.ts +0 -1
  102. package/out/recipes/scaffold.d.ts.map +1 -1
  103. package/out/recipes/scaffold.js +0 -12
  104. package/out/recipes/scaffold.js.map +1 -1
  105. package/out/recipes/si-bare-village.d.ts.map +1 -1
  106. package/out/recipes/si-bare-village.js +2 -1
  107. package/out/recipes/si-bare-village.js.map +1 -1
  108. package/out/recipes/street-affix.d.ts.map +1 -1
  109. package/out/recipes/street-affix.js +3 -3
  110. package/out/recipes/street-affix.js.map +1 -1
  111. package/out/recipes/street-bare.d.ts.map +1 -1
  112. package/out/recipes/street-bare.js +2 -1
  113. package/out/recipes/street-bare.js.map +1 -1
  114. package/out/recipes/street.d.ts.map +1 -1
  115. package/out/recipes/street.js +2 -1
  116. package/out/recipes/street.js.map +1 -1
  117. package/out/recipes/sub-venue-sources.d.ts.map +1 -1
  118. package/out/recipes/sub-venue-sources.js +2 -1
  119. package/out/recipes/sub-venue-sources.js.map +1 -1
  120. package/out/recipes/sub-venue.d.ts.map +1 -1
  121. package/out/recipes/sub-venue.js +2 -1
  122. package/out/recipes/sub-venue.js.map +1 -1
  123. package/out/recipes/trailing-region.d.ts.map +1 -1
  124. package/out/recipes/trailing-region.js +2 -1
  125. package/out/recipes/trailing-region.js.map +1 -1
  126. package/out/recipes/unit.d.ts.map +1 -1
  127. package/out/recipes/unit.js +2 -1
  128. package/out/recipes/unit.js.map +1 -1
  129. package/out/tools/fetch/acra-sg.d.ts +23 -0
  130. package/out/tools/fetch/acra-sg.d.ts.map +1 -0
  131. package/out/tools/fetch/acra-sg.js +126 -0
  132. package/out/tools/fetch/acra-sg.js.map +1 -0
  133. package/out/tools/fetch/download.d.ts +65 -0
  134. package/out/tools/fetch/download.d.ts.map +1 -1
  135. package/out/tools/fetch/download.js +160 -2
  136. package/out/tools/fetch/download.js.map +1 -1
  137. package/out/tools/fetch/gcis-tw.d.ts +23 -0
  138. package/out/tools/fetch/gcis-tw.d.ts.map +1 -0
  139. package/out/tools/fetch/gcis-tw.js +135 -0
  140. package/out/tools/fetch/gcis-tw.js.map +1 -0
  141. package/out/tools/fetch/houjin-jp.d.ts +24 -0
  142. package/out/tools/fetch/houjin-jp.d.ts.map +1 -0
  143. package/out/tools/fetch/houjin-jp.js +102 -0
  144. package/out/tools/fetch/houjin-jp.js.map +1 -0
  145. package/out/tools/fetch/index.d.ts +27 -0
  146. package/out/tools/fetch/index.d.ts.map +1 -1
  147. package/out/tools/fetch/index.js +27 -0
  148. package/out/tools/fetch/index.js.map +1 -1
  149. package/out/tools/fetch/juso-kr.d.ts +30 -0
  150. package/out/tools/fetch/juso-kr.d.ts.map +1 -0
  151. package/out/tools/fetch/juso-kr.js +120 -0
  152. package/out/tools/fetch/juso-kr.js.map +1 -0
  153. package/out/tools/fetch/localdata-kr.d.ts +30 -0
  154. package/out/tools/fetch/localdata-kr.d.ts.map +1 -0
  155. package/out/tools/fetch/localdata-kr.js +139 -0
  156. package/out/tools/fetch/localdata-kr.js.map +1 -0
  157. package/out/tools/golden-relabel-street.d.ts.map +1 -1
  158. package/out/tools/golden-relabel-street.js +1 -2
  159. package/out/tools/golden-relabel-street.js.map +1 -1
  160. package/package.json +10 -10
  161. package/lib/name-prone-us-suffixes.ts +0 -12
  162. package/out/name-prone-us-suffixes.d.ts +0 -12
  163. package/out/name-prone-us-suffixes.d.ts.map +0 -1
  164. package/out/name-prone-us-suffixes.js +0 -12
  165. 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
+ }
@@ -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,