@mailwoman/corpus 10.4.0 → 10.6.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/data/PROVENANCE.md +14 -0
- package/data/address-source-register.json +32 -4
- package/data/source-resolutions.json +35 -0
- package/data/training-manifests/v0.7.2-address-systems.effective.json +1169 -0
- package/data/training-manifests/v0.7.2-address-systems.json +550 -0
- package/lib/adapters/cordis/adapter.ts +1 -1
- package/lib/adapters.ts +2 -0
- package/lib/be/adapters/brussels/adapter.ts +1 -1
- package/lib/be/adapters/wallonie/adapter.ts +1 -1
- package/lib/build/align.ts +1 -1
- package/lib/cz/adapters/cuzk/adapter.ts +1 -1
- package/lib/cz/tools/fetch/cuzk.ts +2 -2
- package/lib/es/tools/fetch/bizkaia.ts +1 -1
- package/lib/gb/adapters/os-open/adapter.ts +453 -0
- package/lib/gb/os-open/nearest-link.ts +169 -0
- package/lib/gb/os-open/open/names.ts +180 -0
- package/lib/gb/os-open/open/roads.ts +145 -0
- package/lib/gb/tools/fetch/os-open.ts +251 -0
- package/lib/nl/tools/fetch/kadaster.ts +1 -1
- package/lib/registers.ts +7 -0
- package/lib/source-register/effective-manifest.ts +12 -6
- package/lib/source-register/training-manifest.ts +30 -2
- package/lib/source-register.ts +1 -0
- package/lib/subvenue/surfaces.ts +1 -1
- package/lib/tools/fetch/gleif.ts +1 -1
- package/lib/tools/fetch.ts +2 -0
- package/lib/tools/lint/recipe-output.ts +1 -1
- package/lib/tools/migrate/source-names.ts +1 -1
- package/lib/us/adapters/usgov/nad/adapter.ts +1 -1
- package/lib/us/adapters/usgov/nppes/adapter.ts +1 -1
- package/out/adapters/cordis/adapter.js +1 -1
- package/out/adapters.d.ts.map +1 -1
- package/out/adapters.js +2 -0
- package/out/adapters.js.map +1 -1
- package/out/be/adapters/brussels/adapter.d.ts +1 -1
- package/out/be/adapters/brussels/adapter.js +1 -1
- package/out/be/adapters/wallonie/adapter.d.ts +1 -1
- package/out/be/adapters/wallonie/adapter.js +1 -1
- package/out/build/align.d.ts +1 -1
- package/out/cz/adapters/cuzk/adapter.d.ts +1 -1
- package/out/cz/adapters/cuzk/adapter.js +1 -1
- package/out/cz/tools/fetch/cuzk.js +2 -2
- package/out/cz/tools/fetch/cuzk.js.map +1 -1
- package/out/es/tools/fetch/bizkaia.js +1 -1
- package/out/es/tools/fetch/bizkaia.js.map +1 -1
- package/out/gb/adapters/os-open/adapter.d.ts +165 -0
- package/out/gb/adapters/os-open/adapter.d.ts.map +1 -0
- package/out/gb/adapters/os-open/adapter.js +298 -0
- package/out/gb/adapters/os-open/adapter.js.map +1 -0
- package/out/gb/os-open/nearest-link.d.ts +73 -0
- package/out/gb/os-open/nearest-link.d.ts.map +1 -0
- package/out/gb/os-open/nearest-link.js +133 -0
- package/out/gb/os-open/nearest-link.js.map +1 -0
- package/out/gb/os-open/open/names.d.ts +92 -0
- package/out/gb/os-open/open/names.d.ts.map +1 -0
- package/out/gb/os-open/open/names.js +107 -0
- package/out/gb/os-open/open/names.js.map +1 -0
- package/out/gb/os-open/open/roads.d.ts +80 -0
- package/out/gb/os-open/open/roads.d.ts.map +1 -0
- package/out/gb/os-open/open/roads.js +79 -0
- package/out/gb/os-open/open/roads.js.map +1 -0
- package/out/gb/tools/fetch/os-open.d.ts +91 -0
- package/out/gb/tools/fetch/os-open.d.ts.map +1 -0
- package/out/gb/tools/fetch/os-open.js +166 -0
- package/out/gb/tools/fetch/os-open.js.map +1 -0
- package/out/nl/tools/fetch/kadaster.d.ts +1 -1
- package/out/nl/tools/fetch/kadaster.js +1 -1
- package/out/registers.d.ts +7 -0
- package/out/registers.d.ts.map +1 -1
- package/out/registers.js +7 -0
- package/out/registers.js.map +1 -1
- package/out/source-register/effective-manifest.d.ts +9 -3
- package/out/source-register/effective-manifest.d.ts.map +1 -1
- package/out/source-register/effective-manifest.js +9 -4
- package/out/source-register/effective-manifest.js.map +1 -1
- package/out/source-register/training-manifest.d.ts +27 -0
- package/out/source-register/training-manifest.d.ts.map +1 -1
- package/out/source-register/training-manifest.js +13 -1
- package/out/source-register/training-manifest.js.map +1 -1
- package/out/source-register.d.ts +1 -1
- package/out/source-register.d.ts.map +1 -1
- package/out/source-register.js +1 -1
- package/out/source-register.js.map +1 -1
- package/out/subvenue/surfaces.d.ts +1 -1
- package/out/tools/fetch/gleif.d.ts +1 -1
- package/out/tools/fetch.d.ts +2 -0
- package/out/tools/fetch.d.ts.map +1 -1
- package/out/tools/fetch.js +2 -0
- package/out/tools/fetch.js.map +1 -1
- package/out/tools/lint/recipe-output.d.ts +1 -1
- package/out/tools/migrate/source-names.d.ts +1 -1
- package/out/us/adapters/usgov/nad/adapter.d.ts +1 -1
- package/out/us/adapters/usgov/nad/adapter.js +1 -1
- package/out/us/adapters/usgov/nppes/adapter.d.ts +1 -1
- package/out/us/adapters/usgov/nppes/adapter.js +1 -1
- package/package.json +14 -13
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Reads the road rows of OS Open Names.
|
|
7
|
+
*
|
|
8
|
+
* The CSV product is 819 files, one per 10 km National Grid square, with no header row. OS ships
|
|
9
|
+
* the 34 column names in `Doc/OS_Open_Names_Header.csv`, and the first data row of each file opens
|
|
10
|
+
* with a byte-order mark, which this reader strips from the `ID` value.
|
|
11
|
+
*
|
|
12
|
+
* A road row's `ID` is the roadNameTOID that OS Open Roads links reference, such as
|
|
13
|
+
* `osgb4000000010927776`. Two `LOCAL_TYPE` values are roads: `Named Road`, and
|
|
14
|
+
* `Section Of Named Road` for a road that OS splits where it crosses a postcode district or a
|
|
15
|
+
* settlement.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { CSVSpliterator } from "spliterator"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The 34 columns of an OS Open Names CSV, in file order, as `Doc/OS_Open_Names_Header.csv` names them.
|
|
22
|
+
*/
|
|
23
|
+
export const OPEN_NAMES_COLUMNS = [
|
|
24
|
+
"ID",
|
|
25
|
+
"NAMES_URI",
|
|
26
|
+
"NAME1",
|
|
27
|
+
"NAME1_LANG",
|
|
28
|
+
"NAME2",
|
|
29
|
+
"NAME2_LANG",
|
|
30
|
+
"TYPE",
|
|
31
|
+
"LOCAL_TYPE",
|
|
32
|
+
"GEOMETRY_X",
|
|
33
|
+
"GEOMETRY_Y",
|
|
34
|
+
"MOST_DETAIL_VIEW_RES",
|
|
35
|
+
"LEAST_DETAIL_VIEW_RES",
|
|
36
|
+
"MBR_XMIN",
|
|
37
|
+
"MBR_YMIN",
|
|
38
|
+
"MBR_XMAX",
|
|
39
|
+
"MBR_YMAX",
|
|
40
|
+
"POSTCODE_DISTRICT",
|
|
41
|
+
"POSTCODE_DISTRICT_URI",
|
|
42
|
+
"POPULATED_PLACE",
|
|
43
|
+
"POPULATED_PLACE_URI",
|
|
44
|
+
"POPULATED_PLACE_TYPE",
|
|
45
|
+
"DISTRICT_BOROUGH",
|
|
46
|
+
"DISTRICT_BOROUGH_URI",
|
|
47
|
+
"DISTRICT_BOROUGH_TYPE",
|
|
48
|
+
"COUNTY_UNITARY",
|
|
49
|
+
"COUNTY_UNITARY_URI",
|
|
50
|
+
"COUNTY_UNITARY_TYPE",
|
|
51
|
+
"REGION",
|
|
52
|
+
"REGION_URI",
|
|
53
|
+
"COUNTRY",
|
|
54
|
+
"COUNTRY_URI",
|
|
55
|
+
"RELATED_SPATIAL_OBJECT",
|
|
56
|
+
"SAME_AS_DBPEDIA",
|
|
57
|
+
"SAME_AS_GEONAMES",
|
|
58
|
+
] as const
|
|
59
|
+
|
|
60
|
+
const column = Object.fromEntries(OPEN_NAMES_COLUMNS.map((name, index) => [name, index])) as Record<
|
|
61
|
+
(typeof OPEN_NAMES_COLUMNS)[number],
|
|
62
|
+
number
|
|
63
|
+
>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `LOCAL_TYPE` values that mark a road row.
|
|
67
|
+
*/
|
|
68
|
+
export const OPEN_NAMES_ROAD_TYPES: ReadonlySet<string> = new Set(["Named Road", "Section Of Named Road"])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One road row of OS Open Names, with the columns the corpus adapter reads.
|
|
72
|
+
*/
|
|
73
|
+
export interface OpenNamesRoad {
|
|
74
|
+
/**
|
|
75
|
+
* The roadNameTOID, such as `osgb4000000010927776`.
|
|
76
|
+
*/
|
|
77
|
+
id: string
|
|
78
|
+
/**
|
|
79
|
+
* The road's name, in the language OS lists first.
|
|
80
|
+
*/
|
|
81
|
+
name1: string
|
|
82
|
+
/**
|
|
83
|
+
* The road's other-language name, Welsh or Gaelic, or an empty string.
|
|
84
|
+
*/
|
|
85
|
+
name2: string
|
|
86
|
+
/**
|
|
87
|
+
* The outward code of the postcode district holding the road, such as `LS6`.
|
|
88
|
+
*/
|
|
89
|
+
postcodeDistrict: string
|
|
90
|
+
/**
|
|
91
|
+
* The settlement holding the road, or an empty string.
|
|
92
|
+
*
|
|
93
|
+
* A bilingual settlement is written `<NAME1> / <NAME2>` of that settlement,
|
|
94
|
+
* such as `Stornoway / Steòrnabhagh`.
|
|
95
|
+
*/
|
|
96
|
+
populatedPlace: string
|
|
97
|
+
districtBorough: string
|
|
98
|
+
countyUnitary: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Counts from a read, with a reason for each row that did not yield.
|
|
103
|
+
*/
|
|
104
|
+
export interface OpenNamesReadStats {
|
|
105
|
+
/**
|
|
106
|
+
* Rows read across every file.
|
|
107
|
+
*/
|
|
108
|
+
read: number
|
|
109
|
+
/**
|
|
110
|
+
* Road rows yielded.
|
|
111
|
+
*/
|
|
112
|
+
roads: number
|
|
113
|
+
/**
|
|
114
|
+
* Road rows skipped because their `ID` was not among the requested ids.
|
|
115
|
+
*/
|
|
116
|
+
unrequested: number
|
|
117
|
+
/**
|
|
118
|
+
* Rows with a column count other than 34.
|
|
119
|
+
*/
|
|
120
|
+
malformed: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A stats object with every count at zero.
|
|
125
|
+
*/
|
|
126
|
+
export function createOpenNamesReadStats(): OpenNamesReadStats {
|
|
127
|
+
return { read: 0, roads: 0, unrequested: 0, malformed: 0 }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Streams the road rows of one Open Names CSV.
|
|
132
|
+
*
|
|
133
|
+
* @param ids When given, only roads whose `ID` is in the set are yielded.
|
|
134
|
+
*/
|
|
135
|
+
export async function* readOpenNamesRoads(
|
|
136
|
+
csvPath: string,
|
|
137
|
+
stats: OpenNamesReadStats,
|
|
138
|
+
ids?: ReadonlySet<string>
|
|
139
|
+
): AsyncGenerator<OpenNamesRoad> {
|
|
140
|
+
for await (const row of CSVSpliterator.fromAsync<string[]>(csvPath, { header: false })) {
|
|
141
|
+
stats.read++
|
|
142
|
+
|
|
143
|
+
if (row.length !== OPEN_NAMES_COLUMNS.length) {
|
|
144
|
+
stats.malformed++
|
|
145
|
+
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!OPEN_NAMES_ROAD_TYPES.has(row[column.LOCAL_TYPE]!)) continue
|
|
150
|
+
|
|
151
|
+
const id = row[column.ID]!.replace(/^/u, "")
|
|
152
|
+
|
|
153
|
+
if (ids && !ids.has(id)) {
|
|
154
|
+
stats.unrequested++
|
|
155
|
+
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
stats.roads++
|
|
160
|
+
|
|
161
|
+
yield {
|
|
162
|
+
id,
|
|
163
|
+
name1: row[column.NAME1]!.trim(),
|
|
164
|
+
name2: row[column.NAME2]!.trim(),
|
|
165
|
+
postcodeDistrict: row[column.POSTCODE_DISTRICT]!.trim(),
|
|
166
|
+
populatedPlace: row[column.POPULATED_PLACE]!.trim(),
|
|
167
|
+
districtBorough: row[column.DISTRICT_BOROUGH]!.trim(),
|
|
168
|
+
countyUnitary: row[column.COUNTY_UNITARY]!.trim(),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The settlement's first-listed name from a `POPULATED_PLACE` value.
|
|
175
|
+
*
|
|
176
|
+
* OS writes a bilingual settlement as `<NAME1> / <NAME2>`, and NAME1 is the form the adapter emits.
|
|
177
|
+
*/
|
|
178
|
+
export function populatedPlaceName1(populatedPlace: string): string {
|
|
179
|
+
return populatedPlace.split(" / ")[0]!.trim()
|
|
180
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Reads the named road links of an OS Open Roads GML tile.
|
|
7
|
+
*
|
|
8
|
+
* The GML product is one file per 100 km National Grid square. A tile holds `road:RoadLink`,
|
|
9
|
+
* `road:RoadNode` and `road:MotorwayJunction` features. Only the links hold a centreline and a
|
|
10
|
+
* name. A named link has a `road:name1` and a `road:roadNameTOID` whose `xlink:href` is `#` plus
|
|
11
|
+
* the roadNameTOID that OS Open Names uses as its `ID`. A link with only a road number carries a
|
|
12
|
+
* `road:roadNumberTOID` instead, and an unnamed link carries neither.
|
|
13
|
+
*
|
|
14
|
+
* The centreline is a `gml:LineString` in EPSG:27700, written as a `gml:posList` of easting and
|
|
15
|
+
* northing pairs. The reader honors the line's `srsDimension` so a 3D tile would still read its
|
|
16
|
+
* first two ordinates.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { openReadStream } from "@mailwoman/core/fs/streams"
|
|
20
|
+
import { elementAtPath, streamMarkupElements, textAtPath, type MarkupElement } from "@mailwoman/core/html/elements"
|
|
21
|
+
import type { PathBuilderLike } from "path-ts"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The feature element that carries a centreline.
|
|
25
|
+
*/
|
|
26
|
+
export const OPEN_ROADS_LINK_ELEMENT = "road:RoadLink"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One named road link.
|
|
30
|
+
*/
|
|
31
|
+
export interface OpenRoadsLink {
|
|
32
|
+
/**
|
|
33
|
+
* The link's `gml:id`.
|
|
34
|
+
*/
|
|
35
|
+
id: string
|
|
36
|
+
/**
|
|
37
|
+
* `road:name1` as the tile spells it.
|
|
38
|
+
*/
|
|
39
|
+
name1: string
|
|
40
|
+
/**
|
|
41
|
+
* The roadNameTOID, without the `#` of the reference, such as `osgb4000000010927776`.
|
|
42
|
+
*/
|
|
43
|
+
roadNameTOID: string
|
|
44
|
+
/**
|
|
45
|
+
* The centreline as flat `[easting0, northing0, easting1, northing1, …]` in EPSG:27700 meters.
|
|
46
|
+
*/
|
|
47
|
+
vertices: Float64Array
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Counts from a read.
|
|
52
|
+
*/
|
|
53
|
+
export interface OpenRoadsReadStats {
|
|
54
|
+
/**
|
|
55
|
+
* Road links read.
|
|
56
|
+
*/
|
|
57
|
+
links: number
|
|
58
|
+
/**
|
|
59
|
+
* Named links yielded.
|
|
60
|
+
*/
|
|
61
|
+
named: number
|
|
62
|
+
/**
|
|
63
|
+
* Links with no `road:name1` or no `road:roadNameTOID`.
|
|
64
|
+
*/
|
|
65
|
+
unnamed: number
|
|
66
|
+
/**
|
|
67
|
+
* Named links whose centreline held fewer than two vertices or a non-numeric ordinate.
|
|
68
|
+
*/
|
|
69
|
+
malformedGeometry: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A stats object with every count at zero.
|
|
74
|
+
*/
|
|
75
|
+
export function createOpenRoadsReadStats(): OpenRoadsReadStats {
|
|
76
|
+
return { links: 0, named: 0, unnamed: 0, malformedGeometry: 0 }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const LINE_PATH = ["net:centrelineGeometry", "gml:LineString"] as const
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The named link a `road:RoadLink` element describes, or a reason it describes none.
|
|
83
|
+
*/
|
|
84
|
+
export function readOpenRoadsLink(element: MarkupElement): OpenRoadsLink | "unnamed" | "malformed-geometry" {
|
|
85
|
+
const name1 = (textAtPath(element, "road:name1") ?? "").trim()
|
|
86
|
+
const href = elementAtPath(element, "road:roadNameTOID")?.attributes["xlink:href"] ?? ""
|
|
87
|
+
const roadNameTOID = href.replace(/^#/u, "").trim()
|
|
88
|
+
|
|
89
|
+
if (!name1 || !roadNameTOID) return "unnamed"
|
|
90
|
+
|
|
91
|
+
const dimension = Number(elementAtPath(element, ...LINE_PATH)?.attributes.srsDimension ?? "2") || 2
|
|
92
|
+
const posList = (textAtPath(element, ...LINE_PATH, "gml:posList") ?? "").trim()
|
|
93
|
+
const ordinates = posList ? posList.split(/\s+/u) : []
|
|
94
|
+
const vertexCount = Math.floor(ordinates.length / dimension)
|
|
95
|
+
|
|
96
|
+
if (vertexCount < 2 || ordinates.length % dimension !== 0) return "malformed-geometry"
|
|
97
|
+
|
|
98
|
+
const vertices = new Float64Array(vertexCount * 2)
|
|
99
|
+
|
|
100
|
+
for (let vertex = 0; vertex < vertexCount; vertex++) {
|
|
101
|
+
const easting = Number(ordinates[vertex * dimension])
|
|
102
|
+
const northing = Number(ordinates[vertex * dimension + 1])
|
|
103
|
+
|
|
104
|
+
if (!Number.isFinite(easting) || !Number.isFinite(northing)) return "malformed-geometry"
|
|
105
|
+
|
|
106
|
+
vertices[vertex * 2] = easting
|
|
107
|
+
vertices[vertex * 2 + 1] = northing
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { id: element.attributes["gml:id"] ?? "", name1, roadNameTOID, vertices }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Streams the named links of one Open Roads GML tile.
|
|
115
|
+
*/
|
|
116
|
+
export async function* readOpenRoadsLinks(
|
|
117
|
+
gmlPath: PathBuilderLike,
|
|
118
|
+
stats: OpenRoadsReadStats,
|
|
119
|
+
options: { signal?: AbortSignal } = {}
|
|
120
|
+
): AsyncGenerator<OpenRoadsLink> {
|
|
121
|
+
for await (const element of streamMarkupElements(openReadStream(gmlPath), OPEN_ROADS_LINK_ELEMENT, {
|
|
122
|
+
xml: true,
|
|
123
|
+
signal: options.signal,
|
|
124
|
+
})) {
|
|
125
|
+
stats.links++
|
|
126
|
+
|
|
127
|
+
const link = readOpenRoadsLink(element)
|
|
128
|
+
|
|
129
|
+
if (link === "unnamed") {
|
|
130
|
+
stats.unnamed++
|
|
131
|
+
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (link === "malformed-geometry") {
|
|
136
|
+
stats.malformedGeometry++
|
|
137
|
+
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stats.named++
|
|
142
|
+
|
|
143
|
+
yield link
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Downloads the three OS OpenData products the `gb-os-open` adapter joins, from the OS Downloads API.
|
|
7
|
+
*
|
|
8
|
+
* The API serves OpenData without a key. `GET /products/<id>/downloads` lists one archive per
|
|
9
|
+
* format and area with its OS-published MD5, and each archive is checked against that MD5 after
|
|
10
|
+
* transfer. A present archive whose MD5 already matches is kept rather than downloaded again.
|
|
11
|
+
*
|
|
12
|
+
* Each archive is unpacked into the subdirectory the adapter reads, `code-point-open/`,
|
|
13
|
+
* `open-names/` or `open-roads/`, so the fetch root is the adapter's `inputPath`. The archive
|
|
14
|
+
* stays beside its members, because its digest is the value the address-source register records.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { APIClient } from "@mailwoman/core/api"
|
|
18
|
+
import { ByteFormatter } from "@mailwoman/core/fs/formatters"
|
|
19
|
+
import { makeDirectories } from "@mailwoman/core/fs/writers"
|
|
20
|
+
import { extractZipEntries } from "@mailwoman/core/fs/zip"
|
|
21
|
+
import { md5File, sha256File } from "@mailwoman/core/hash"
|
|
22
|
+
import { OS_DOWNLOADS_API_BASE, type OSDownload } from "@mailwoman/spatial/gb/os-downloads"
|
|
23
|
+
import { PathBuilder, type PathBuilderLike } from "path-ts"
|
|
24
|
+
|
|
25
|
+
import { GB_OS_OPEN_ADAPTER_ID, GB_OS_OPEN_PRODUCT_DIRECTORIES } from "#gb/adapters/os-open/adapter"
|
|
26
|
+
import type { BaseFetchOptions, FetchSummary, SourceCollectionManifest, SourceManifest } from "#tools/fetch/download"
|
|
27
|
+
import { downloadToFile, writeManifest } from "#tools/fetch/download"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The license all three products are published under.
|
|
31
|
+
*/
|
|
32
|
+
export const GB_OS_OPEN_LICENSE_NAME = "Open Government Licence v3.0"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The attribution OGL v3 requires, in the wording OS asks for on Code-Point Open,
|
|
36
|
+
* which also carries Royal Mail and ONS data.
|
|
37
|
+
*/
|
|
38
|
+
export const GB_OS_OPEN_ATTRIBUTION =
|
|
39
|
+
"Contains OS data © Crown copyright and database right. Contains Royal Mail data © Royal Mail copyright and database right. Contains National Statistics data © Crown copyright and database right."
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One product the adapter reads: its OS Downloads API id, the archive format,
|
|
43
|
+
* and the subdirectory it unpacks into.
|
|
44
|
+
*/
|
|
45
|
+
export interface OSOpenProduct {
|
|
46
|
+
productID: string
|
|
47
|
+
format: string
|
|
48
|
+
directory: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The three products, in the order they are fetched.
|
|
53
|
+
*/
|
|
54
|
+
export const GB_OS_OPEN_PRODUCTS: readonly OSOpenProduct[] = [
|
|
55
|
+
{ productID: "CodePointOpen", format: "CSV", directory: GB_OS_OPEN_PRODUCT_DIRECTORIES.codePointOpen },
|
|
56
|
+
{ productID: "OpenNames", format: "CSV", directory: GB_OS_OPEN_PRODUCT_DIRECTORIES.openNames },
|
|
57
|
+
{ productID: "OpenRoads", format: "GML", directory: GB_OS_OPEN_PRODUCT_DIRECTORIES.openRoads },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The manifest entry for one product's archive.
|
|
62
|
+
*/
|
|
63
|
+
export interface OSOpenFileManifest extends SourceManifest {
|
|
64
|
+
product_id: string
|
|
65
|
+
format: string
|
|
66
|
+
md5: string
|
|
67
|
+
members: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The archive of `product` among the listed downloads, the Great Britain one where an area is stated.
|
|
72
|
+
*
|
|
73
|
+
* @throws Naming the formats offered, when none matches.
|
|
74
|
+
*/
|
|
75
|
+
export function selectOSDownload(product: OSOpenProduct, downloads: readonly OSDownload[]): OSDownload {
|
|
76
|
+
const matches = downloads.filter((download) => download.format === product.format)
|
|
77
|
+
const chosen = matches.find((download) => download.area === "GB") ?? (matches.length === 1 ? matches[0] : undefined)
|
|
78
|
+
|
|
79
|
+
if (!chosen) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${GB_OS_OPEN_ADAPTER_ID}: OS Downloads API lists no single ${product.format} archive for ${product.productID} ` +
|
|
82
|
+
`(listed: ${downloads.map((download) => `${download.format}/${download.area}`).join(", ") || "nothing"})`
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return chosen
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The archives the OS Downloads API lists for one product.
|
|
91
|
+
*/
|
|
92
|
+
export async function readOSDownloads(
|
|
93
|
+
client: Pick<APIClient, "fetch">,
|
|
94
|
+
product: OSOpenProduct,
|
|
95
|
+
options: { signal?: AbortSignal } = {}
|
|
96
|
+
): Promise<OSDownload[]> {
|
|
97
|
+
const { data } = await client.fetch<OSDownload[]>({
|
|
98
|
+
method: "GET",
|
|
99
|
+
url: `${OS_DOWNLOADS_API_BASE}/products/${product.productID}/downloads`,
|
|
100
|
+
timeout: 60_000,
|
|
101
|
+
signal: options.signal,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (!Array.isArray(data)) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
`${GB_OS_OPEN_ADAPTER_ID}: OS Downloads API answered ${product.productID} with no download list`
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return data
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Downloads and unpacks one product.
|
|
115
|
+
*/
|
|
116
|
+
export async function downloadOSOpenProduct(
|
|
117
|
+
client: Pick<APIClient, "fetch">,
|
|
118
|
+
product: OSOpenProduct,
|
|
119
|
+
options: {
|
|
120
|
+
outputDir: PathBuilderLike
|
|
121
|
+
force?: boolean
|
|
122
|
+
retries?: number
|
|
123
|
+
retryDelayMs?: number
|
|
124
|
+
signal?: AbortSignal
|
|
125
|
+
report?: (line: string) => void
|
|
126
|
+
}
|
|
127
|
+
): Promise<{ entry: OSOpenFileManifest; downloaded: boolean }> {
|
|
128
|
+
const { report } = options
|
|
129
|
+
const download = selectOSDownload(product, await readOSDownloads(client, product, { signal: options.signal }))
|
|
130
|
+
const productDir = PathBuilder.from(options.outputDir)(product.directory)
|
|
131
|
+
const archivePath = productDir(download.fileName)
|
|
132
|
+
|
|
133
|
+
report?.(
|
|
134
|
+
`=== ${GB_OS_OPEN_ADAPTER_ID} / ${product.productID} ${download.fileName} (${ByteFormatter.formatIEC(download.size)})`
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
await makeDirectories(productDir)
|
|
138
|
+
|
|
139
|
+
const present = options.force ? null : await md5File(archivePath).catch(() => null)
|
|
140
|
+
const downloaded = present !== download.md5
|
|
141
|
+
|
|
142
|
+
if (downloaded) {
|
|
143
|
+
await downloadToFile({
|
|
144
|
+
url: download.url,
|
|
145
|
+
dest: archivePath,
|
|
146
|
+
retries: options.retries,
|
|
147
|
+
retryDelayMs: options.retryDelayMs,
|
|
148
|
+
report,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const md5 = await md5File(archivePath)
|
|
152
|
+
|
|
153
|
+
if (md5 !== download.md5) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`${GB_OS_OPEN_ADAPTER_ID}: ${download.fileName} has md5 ${md5}, and OS publishes ${download.md5} for it`
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
report?.(` present, and its md5 matches the published ${download.md5}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const members = await extractZipEntries(archivePath, productDir, { skipExisting: true })
|
|
163
|
+
const sha256 = await sha256File(archivePath)
|
|
164
|
+
|
|
165
|
+
report?.(` ${members.length} members unpacked sha256=${sha256}`)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
entry: {
|
|
169
|
+
source_url: download.url,
|
|
170
|
+
filename: download.fileName,
|
|
171
|
+
product_id: product.productID,
|
|
172
|
+
format: product.format,
|
|
173
|
+
bytes: download.size,
|
|
174
|
+
md5: download.md5,
|
|
175
|
+
sha256,
|
|
176
|
+
members: members.length,
|
|
177
|
+
downloaded_at: new Date().toISOString(),
|
|
178
|
+
},
|
|
179
|
+
downloaded,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Per-invocation options for the registry entry.
|
|
185
|
+
*/
|
|
186
|
+
export interface FetchGBOSOpenOptions extends BaseFetchOptions {
|
|
187
|
+
force?: boolean
|
|
188
|
+
retries?: number
|
|
189
|
+
signal?: AbortSignal
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The registry entry: fetches all three products and writes `MANIFEST.json` at the adapter's input root.
|
|
194
|
+
*
|
|
195
|
+
* A product that fails is counted and the others are still taken.
|
|
196
|
+
*/
|
|
197
|
+
export async function fetchGBOSOpen(
|
|
198
|
+
options: FetchGBOSOpenOptions,
|
|
199
|
+
report?: (line: string) => void
|
|
200
|
+
): Promise<FetchSummary> {
|
|
201
|
+
const outputDir = options.outRoot(GB_OS_OPEN_ADAPTER_ID)
|
|
202
|
+
const files: OSOpenFileManifest[] = []
|
|
203
|
+
const failedCodes: string[] = []
|
|
204
|
+
|
|
205
|
+
let fetched = 0
|
|
206
|
+
let skipped = 0
|
|
207
|
+
|
|
208
|
+
await using client = new APIClient({ displayName: GB_OS_OPEN_ADAPTER_ID, minRequestIntervalMs: 250, retry: true })
|
|
209
|
+
|
|
210
|
+
for (const product of GB_OS_OPEN_PRODUCTS) {
|
|
211
|
+
if (options.signal?.aborted) break
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const { entry, downloaded } = await downloadOSOpenProduct(client, product, {
|
|
215
|
+
outputDir,
|
|
216
|
+
force: options.force,
|
|
217
|
+
retries: options.retries,
|
|
218
|
+
retryDelayMs: options.retryDelayMs,
|
|
219
|
+
signal: options.signal,
|
|
220
|
+
report,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
files.push(entry)
|
|
224
|
+
|
|
225
|
+
if (downloaded) {
|
|
226
|
+
fetched += 1
|
|
227
|
+
} else {
|
|
228
|
+
skipped += 1
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
report?.(` ✗ ${product.productID}: ${error instanceof Error ? error.message : String(error)}`)
|
|
232
|
+
|
|
233
|
+
failedCodes.push(product.productID)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const manifest: SourceCollectionManifest = {
|
|
238
|
+
source: GB_OS_OPEN_ADAPTER_ID,
|
|
239
|
+
source_url: OS_DOWNLOADS_API_BASE,
|
|
240
|
+
license: GB_OS_OPEN_LICENSE_NAME,
|
|
241
|
+
attribution: GB_OS_OPEN_ATTRIBUTION,
|
|
242
|
+
downloaded_at: new Date().toISOString(),
|
|
243
|
+
files,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await writeManifest(outputDir("MANIFEST.json"), manifest)
|
|
247
|
+
|
|
248
|
+
report?.(` MANIFEST written to ${outputDir("MANIFEST.json")}`)
|
|
249
|
+
|
|
250
|
+
return { fetched, skipped, failed: failedCodes.length, failedCodes }
|
|
251
|
+
}
|
|
@@ -59,7 +59,7 @@ export const NL_KADASTER_FEED_URL = "https://service.pdok.nl/kadaster/ad/atom/ad
|
|
|
59
59
|
export const NL_KADASTER_DOWNLOAD_FILENAME = "addresses.gml.gz"
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* The
|
|
62
|
+
* The manifest records this attribution.
|
|
63
63
|
*
|
|
64
64
|
* CC0 1.0 reserves no act and owes no attribution clause.
|
|
65
65
|
* This attribution is a credit the repository chooses, rather than a condition of the grant.
|
package/lib/registers.ts
CHANGED
|
@@ -204,6 +204,13 @@ export const SourceRegister = {
|
|
|
204
204
|
* HM Land Registry Price Paid Data, England and Wales.
|
|
205
205
|
*/
|
|
206
206
|
LandRegistryPricePaid: "gb-hm-land-registry-ppd",
|
|
207
|
+
/**
|
|
208
|
+
* Ordnance Survey's OpenData products under the Open Government Licence v3.0.
|
|
209
|
+
*
|
|
210
|
+
* The `gb-os-open` adapter joins three of them per row.
|
|
211
|
+
* Code-Point Open, OS Open Names and OS Open Roads share one publisher and one license.
|
|
212
|
+
*/
|
|
213
|
+
OrdnanceSurveyOpenData: "gb-os-opendata",
|
|
207
214
|
/**
|
|
208
215
|
* Australia's Geocoded National Address File.
|
|
209
216
|
*/
|
|
@@ -54,7 +54,8 @@ export function epochMixtureAuditPath(configPath: string): PathBuilder {
|
|
|
54
54
|
*/
|
|
55
55
|
export const ExclusionReason = {
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* Neither the config's `source_weights` map nor its `source_reps` map lists the source,
|
|
58
|
+
* so the sampler never considers it.
|
|
58
59
|
*/
|
|
59
60
|
UnweightedSource: "unweighted-source",
|
|
60
61
|
|
|
@@ -87,7 +88,8 @@ export interface EffectiveSourceRecord {
|
|
|
87
88
|
license: string
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
* The weight `source_weights` gives it,
|
|
91
|
+
* The weight `source_weights` gives it, the repetition count `source_reps` gives it,
|
|
92
|
+
* or `null` where both maps omit it.
|
|
91
93
|
*/
|
|
92
94
|
weight: number | null
|
|
93
95
|
|
|
@@ -348,7 +350,11 @@ export interface EffectiveConfigView {
|
|
|
348
350
|
}
|
|
349
351
|
|
|
350
352
|
/**
|
|
351
|
-
* Read `data.source_weights` and `data.augment_exclude_sources` from a training config.
|
|
353
|
+
* Read `data.source_weights`, `data.source_reps` and `data.augment_exclude_sources` from a training config.
|
|
354
|
+
*
|
|
355
|
+
* `source_weights` and `source_reps` both admit a source to the sampler, one as a share
|
|
356
|
+
* and one as a repetition count, so an entry in either block counts as the source's weight here.
|
|
357
|
+
* A source in neither block is one the sampler never considers.
|
|
352
358
|
*
|
|
353
359
|
* A line scanner instead of a YAML parse.
|
|
354
360
|
* `corpus audit` already reads these blocks this way.
|
|
@@ -368,7 +374,7 @@ export function readConfigView(text: string): EffectiveConfigView {
|
|
|
368
374
|
const sourceWeights: Record<string, number> = {}
|
|
369
375
|
const augmentExcludeSources: string[] = []
|
|
370
376
|
|
|
371
|
-
type ConfigField = "source_weights" | "augment_exclude_sources"
|
|
377
|
+
type ConfigField = "source_weights" | "source_reps" | "augment_exclude_sources"
|
|
372
378
|
|
|
373
379
|
let field: ConfigField | null = null
|
|
374
380
|
let fieldIndent = -1
|
|
@@ -385,7 +391,7 @@ export function readConfigView(text: string): EffectiveConfigView {
|
|
|
385
391
|
}
|
|
386
392
|
|
|
387
393
|
if (!field) {
|
|
388
|
-
const opener = /^([\t ]*)(source_weights|augment_exclude_sources):\s*(#.*)?$/.exec(raw)
|
|
394
|
+
const opener = /^([\t ]*)(source_weights|source_reps|augment_exclude_sources):\s*(#.*)?$/.exec(raw)
|
|
389
395
|
|
|
390
396
|
if (opener) {
|
|
391
397
|
field = opener[2] as ConfigField
|
|
@@ -399,7 +405,7 @@ export function readConfigView(text: string): EffectiveConfigView {
|
|
|
399
405
|
continue
|
|
400
406
|
}
|
|
401
407
|
|
|
402
|
-
if (field === "source_weights") {
|
|
408
|
+
if (field === "source_weights" || field === "source_reps") {
|
|
403
409
|
const entry = /^[\t ]+([\w-]+):\s*([\d.]+)/.exec(raw)
|
|
404
410
|
|
|
405
411
|
if (entry) {
|