@mynameistito/hcc-bin-day 0.1.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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.mjs +301 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tito
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# hcc-bin-day-api
|
|
2
|
+
|
|
3
|
+
[](https://github.com/mynameistito/hcc-bin-day-api/actions/workflows/ci.yml)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
TypeScript client for the Hamilton City Council Fight the Landfill bin-day lookup API.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- searches Hamilton addresses
|
|
11
|
+
- resolves a street address to a bin collection schedule
|
|
12
|
+
- supports text and JSON output
|
|
13
|
+
|
|
14
|
+
## API endpoints
|
|
15
|
+
|
|
16
|
+
This client talks to the public Hamilton City Council backend used by the Fight the Landfill page:
|
|
17
|
+
|
|
18
|
+
- `GET /FightTheLandFill/get_Addresses?search_string=...`
|
|
19
|
+
- `GET /FightTheLandFill/get_Collection_Dates?address_string=...`
|
|
20
|
+
|
|
21
|
+
Base URL:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
https://api2.hcc.govt.nz
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun run src/index.ts
|
|
31
|
+
bun run src/index.ts search "12 Grey Street"
|
|
32
|
+
bun run src/index.ts lookup "12 Grey Street"
|
|
33
|
+
bun run src/index.ts schedule "12 Grey Street"
|
|
34
|
+
bun run src/index.ts --json lookup "12 Grey Street"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Output modes
|
|
38
|
+
|
|
39
|
+
- `search` returns matching addresses
|
|
40
|
+
- `schedule` returns the collection schedule for an exact match
|
|
41
|
+
- `lookup` searches for an exact match and falls back to suggestions
|
|
42
|
+
- `--json` prints structured JSON for scripting
|
|
43
|
+
|
|
44
|
+
## Project structure
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
src/
|
|
48
|
+
hamilton-api.ts
|
|
49
|
+
index.ts
|
|
50
|
+
types.ts
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
bun install
|
|
57
|
+
bun run check
|
|
58
|
+
bun run typecheck
|
|
59
|
+
bun run fix
|
|
60
|
+
bun run build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Tooling
|
|
64
|
+
|
|
65
|
+
- Type checking: `@typescript/native-preview` via `tsgo`
|
|
66
|
+
- Bundling: `tsdown` targeting Node.js
|
|
67
|
+
|
|
68
|
+
## Disclaimer
|
|
69
|
+
|
|
70
|
+
This project is unofficial and is not affiliated with, endorsed by, supported by, or associated with Hamilton City Council.
|
|
71
|
+
|
|
72
|
+
It is provided independently as a convenience for accessing publicly available collection data. API behavior, availability, response formats, and returned data may change without notice.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
//#region src/schedule.ts
|
|
2
|
+
const COLLECTION_DAY_NAMES = [
|
|
3
|
+
"Monday",
|
|
4
|
+
"Tuesday",
|
|
5
|
+
"Wednesday",
|
|
6
|
+
"Thursday",
|
|
7
|
+
"Friday",
|
|
8
|
+
"Saturday",
|
|
9
|
+
"Sunday"
|
|
10
|
+
];
|
|
11
|
+
const BINS_BY_WEEK = {
|
|
12
|
+
red: ["red bin", "food scraps bin"],
|
|
13
|
+
yellow: [
|
|
14
|
+
"yellow bin",
|
|
15
|
+
"glass crate",
|
|
16
|
+
"food scraps bin"
|
|
17
|
+
]
|
|
18
|
+
};
|
|
19
|
+
const parseCouncilDate = (value) => value.slice(0, 10);
|
|
20
|
+
const collectionDayName = (day) => {
|
|
21
|
+
const name = COLLECTION_DAY_NAMES[day - 1];
|
|
22
|
+
if (!name) throw new Error(`Invalid collection day: ${day}`);
|
|
23
|
+
return name;
|
|
24
|
+
};
|
|
25
|
+
const buildSchedule = (result) => {
|
|
26
|
+
const redBin = parseCouncilDate(result.RedBin);
|
|
27
|
+
const yellowBin = parseCouncilDate(result.YellowBin);
|
|
28
|
+
const upcomingWeek = redBin < yellowBin ? "red" : "yellow";
|
|
29
|
+
const nextDate = upcomingWeek === "red" ? redBin : yellowBin;
|
|
30
|
+
return {
|
|
31
|
+
address: result.Address,
|
|
32
|
+
collectionDay: result.CollectionDay,
|
|
33
|
+
collectionDayName: collectionDayName(result.CollectionDay),
|
|
34
|
+
collectionWeek: result.CollectionWeek,
|
|
35
|
+
nextCollection: {
|
|
36
|
+
bins: BINS_BY_WEEK[upcomingWeek],
|
|
37
|
+
date: nextDate,
|
|
38
|
+
type: upcomingWeek
|
|
39
|
+
},
|
|
40
|
+
redBin,
|
|
41
|
+
upcomingWeek,
|
|
42
|
+
yellowBin
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const formatWeekLabel = (week) => week === "red" ? "Red week" : "Yellow week";
|
|
46
|
+
const formatScheduleDate = (date) => {
|
|
47
|
+
return (/* @__PURE__ */ new Date(`${date}T12:00:00`)).toLocaleDateString("en-NZ", {
|
|
48
|
+
day: "numeric",
|
|
49
|
+
month: "long",
|
|
50
|
+
weekday: "long",
|
|
51
|
+
year: "numeric"
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const formatScheduleText = (schedule) => {
|
|
55
|
+
const oppositeWeek = schedule.upcomingWeek === "red" ? "yellow" : "red";
|
|
56
|
+
const oppositeDate = schedule.upcomingWeek === "red" ? schedule.yellowBin : schedule.redBin;
|
|
57
|
+
return [
|
|
58
|
+
`${schedule.address} — ${schedule.collectionDayName} collection`,
|
|
59
|
+
`${formatWeekLabel(schedule.upcomingWeek)} (${formatScheduleDate(schedule.nextCollection.date)})`,
|
|
60
|
+
` Put out: ${schedule.nextCollection.bins.join(", ")}`,
|
|
61
|
+
`${formatWeekLabel(oppositeWeek)} (${formatScheduleDate(oppositeDate)})`,
|
|
62
|
+
` Put out: ${BINS_BY_WEEK[oppositeWeek].join(", ")}`
|
|
63
|
+
].join("\n");
|
|
64
|
+
};
|
|
65
|
+
const toScheduleJson = (schedule) => {
|
|
66
|
+
const followingWeek = schedule.upcomingWeek === "red" ? "yellow" : "red";
|
|
67
|
+
const followingDate = schedule.upcomingWeek === "red" ? schedule.yellowBin : schedule.redBin;
|
|
68
|
+
return {
|
|
69
|
+
address: schedule.address,
|
|
70
|
+
collectionDay: schedule.collectionDay,
|
|
71
|
+
collectionDayName: schedule.collectionDayName,
|
|
72
|
+
collectionWeek: schedule.collectionWeek,
|
|
73
|
+
following: {
|
|
74
|
+
bins: [...BINS_BY_WEEK[followingWeek]],
|
|
75
|
+
date: followingDate,
|
|
76
|
+
dateFormatted: formatScheduleDate(followingDate),
|
|
77
|
+
week: followingWeek,
|
|
78
|
+
weekLabel: formatWeekLabel(followingWeek)
|
|
79
|
+
},
|
|
80
|
+
redBin: schedule.redBin,
|
|
81
|
+
upcoming: {
|
|
82
|
+
bins: [...schedule.nextCollection.bins],
|
|
83
|
+
date: schedule.nextCollection.date,
|
|
84
|
+
dateFormatted: formatScheduleDate(schedule.nextCollection.date),
|
|
85
|
+
week: schedule.upcomingWeek,
|
|
86
|
+
weekLabel: formatWeekLabel(schedule.upcomingWeek)
|
|
87
|
+
},
|
|
88
|
+
yellowBin: schedule.yellowBin
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/hamilton-api.ts
|
|
94
|
+
const API_BASE_URL = "https://api2.hcc.govt.nz";
|
|
95
|
+
const fetchJson = async (url) => {
|
|
96
|
+
const response = await fetch(url);
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
if (response.status === 404) return [];
|
|
99
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
|
100
|
+
}
|
|
101
|
+
return await response.json();
|
|
102
|
+
};
|
|
103
|
+
const searchAddresses = async (searchString) => {
|
|
104
|
+
const url = new URL("/FightTheLandFill/get_Addresses", API_BASE_URL);
|
|
105
|
+
url.searchParams.set("search_string", searchString);
|
|
106
|
+
return (await fetchJson(url.toString())).map((result) => result.Collection_Address);
|
|
107
|
+
};
|
|
108
|
+
const getCollectionSchedule = async (address) => {
|
|
109
|
+
const url = new URL("/FightTheLandFill/get_Collection_Dates", API_BASE_URL);
|
|
110
|
+
url.searchParams.set("address_string", address);
|
|
111
|
+
const [first] = await fetchJson(url.toString());
|
|
112
|
+
if (!first) return null;
|
|
113
|
+
return buildSchedule(first);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/normalize-address.ts
|
|
118
|
+
const STREET_TYPE_ALIASES = {
|
|
119
|
+
av: "avenue",
|
|
120
|
+
ave: "avenue",
|
|
121
|
+
avenue: "avenue",
|
|
122
|
+
blvd: "boulevard",
|
|
123
|
+
boulevard: "boulevard",
|
|
124
|
+
cct: "circuit",
|
|
125
|
+
circuit: "circuit",
|
|
126
|
+
cl: "close",
|
|
127
|
+
close: "close",
|
|
128
|
+
court: "court",
|
|
129
|
+
cres: "crescent",
|
|
130
|
+
crescent: "crescent",
|
|
131
|
+
ct: "court",
|
|
132
|
+
dr: "drive",
|
|
133
|
+
drive: "drive",
|
|
134
|
+
gr: "grove",
|
|
135
|
+
grove: "grove",
|
|
136
|
+
heights: "heights",
|
|
137
|
+
hts: "heights",
|
|
138
|
+
lane: "lane",
|
|
139
|
+
ln: "lane",
|
|
140
|
+
parade: "parade",
|
|
141
|
+
pde: "parade",
|
|
142
|
+
pl: "place",
|
|
143
|
+
place: "place",
|
|
144
|
+
rd: "road",
|
|
145
|
+
rise: "rise",
|
|
146
|
+
road: "road",
|
|
147
|
+
st: "street",
|
|
148
|
+
street: "street",
|
|
149
|
+
tce: "terrace",
|
|
150
|
+
terrace: "terrace",
|
|
151
|
+
way: "way"
|
|
152
|
+
};
|
|
153
|
+
const collapseWhitespace = (value) => value.trim().replaceAll(/\s+/gu, " ");
|
|
154
|
+
const normalizeUnitSuffix = (value) => value.replaceAll(/\b(?<number>\d+)\s*(?<suffix>[a-zA-Z])\b/gu, (_, number, suffix) => `${number}${suffix.toUpperCase()}`);
|
|
155
|
+
const expandStreetTypes = (value) => {
|
|
156
|
+
return value.split(" ").map((token) => STREET_TYPE_ALIASES[token.toLowerCase()] ?? token).join(" ");
|
|
157
|
+
};
|
|
158
|
+
const normalizeAddress = (address) => {
|
|
159
|
+
return expandStreetTypes(normalizeUnitSuffix(collapseWhitespace(address).toLowerCase()));
|
|
160
|
+
};
|
|
161
|
+
const expandAddressQuery = (query) => {
|
|
162
|
+
return expandStreetTypes(normalizeUnitSuffix(collapseWhitespace(query)));
|
|
163
|
+
};
|
|
164
|
+
const pickMatchingAddress = (query, matches) => {
|
|
165
|
+
const normalizedQuery = normalizeAddress(query);
|
|
166
|
+
const exactMatches = matches.filter((match) => normalizeAddress(match) === normalizedQuery);
|
|
167
|
+
if (exactMatches.length === 1) return exactMatches[0] ?? null;
|
|
168
|
+
return null;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/address.ts
|
|
173
|
+
const NO_ADDRESS_FOUND = "No address found";
|
|
174
|
+
const filterMatches = (matches) => matches.filter((match) => match !== NO_ADDRESS_FOUND);
|
|
175
|
+
const resolveAddressQuery = async (query) => {
|
|
176
|
+
const expandedQuery = expandAddressQuery(query);
|
|
177
|
+
let matches = filterMatches(await searchAddresses(query));
|
|
178
|
+
if (matches.length === 0 && expandedQuery !== query) matches = filterMatches(await searchAddresses(expandedQuery));
|
|
179
|
+
if (matches.length === 0) return {
|
|
180
|
+
matches: [],
|
|
181
|
+
ok: false
|
|
182
|
+
};
|
|
183
|
+
const matchedAddress = pickMatchingAddress(query, matches);
|
|
184
|
+
if (!matchedAddress) return {
|
|
185
|
+
matches,
|
|
186
|
+
ok: false
|
|
187
|
+
};
|
|
188
|
+
const schedule = await getCollectionSchedule(matchedAddress);
|
|
189
|
+
if (!schedule) return {
|
|
190
|
+
matches,
|
|
191
|
+
ok: false
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
matchedAddress,
|
|
195
|
+
ok: true,
|
|
196
|
+
schedule
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/index.ts
|
|
202
|
+
const TEXT_FLAGS = new Set([
|
|
203
|
+
"--text",
|
|
204
|
+
"--pretty",
|
|
205
|
+
"-p"
|
|
206
|
+
]);
|
|
207
|
+
const JSON_FLAGS = new Set(["--json", "-j"]);
|
|
208
|
+
const printHelp = () => {
|
|
209
|
+
console.log(`Hamilton bin-day client
|
|
210
|
+
|
|
211
|
+
Usage:
|
|
212
|
+
bun run src/index.ts search <address>
|
|
213
|
+
bun run src/index.ts schedule <address>
|
|
214
|
+
bun run src/index.ts lookup <address>
|
|
215
|
+
bun run src/index.ts schedule <address> --text
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
bun run src/index.ts schedule "14b mountbatten pl"
|
|
219
|
+
bun run src/index.ts schedule "14b mountbatten pl" --text
|
|
220
|
+
bun run src/index.ts lookup "12 grey st"
|
|
221
|
+
|
|
222
|
+
Output is JSON by default. Use --text (or --pretty) for human-readable output.
|
|
223
|
+
Address input is flexible: unit suffixes (14b -> 14B) and street types (pl, st, rd, etc.).
|
|
224
|
+
`);
|
|
225
|
+
};
|
|
226
|
+
const parseArgs = (argv) => {
|
|
227
|
+
const rawArgs = argv.slice(2);
|
|
228
|
+
let textFromFlag = false;
|
|
229
|
+
const args = [];
|
|
230
|
+
for (const arg of rawArgs) if (TEXT_FLAGS.has(arg) || JSON_FLAGS.has(arg)) {
|
|
231
|
+
if (TEXT_FLAGS.has(arg)) textFromFlag = true;
|
|
232
|
+
} else args.push(arg);
|
|
233
|
+
const [command, ...queryParts] = args;
|
|
234
|
+
const query = queryParts.join(" ");
|
|
235
|
+
return {
|
|
236
|
+
command,
|
|
237
|
+
json: !textFromFlag,
|
|
238
|
+
query
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
const printJson = (value) => {
|
|
242
|
+
console.log(JSON.stringify(value, null, 2));
|
|
243
|
+
};
|
|
244
|
+
const main = async () => {
|
|
245
|
+
const { json, command, query } = parseArgs(process.argv);
|
|
246
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
247
|
+
printHelp();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!query) {
|
|
251
|
+
printHelp();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (command === "search") {
|
|
255
|
+
const matches = await searchAddresses(query);
|
|
256
|
+
if (json) {
|
|
257
|
+
printJson({
|
|
258
|
+
command,
|
|
259
|
+
matches,
|
|
260
|
+
query
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
console.log("Matches:", matches);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (command === "schedule" || command === "lookup") {
|
|
268
|
+
const resolved = await resolveAddressQuery(query);
|
|
269
|
+
if (!resolved.ok) {
|
|
270
|
+
if (json) {
|
|
271
|
+
printJson({
|
|
272
|
+
command,
|
|
273
|
+
found: false,
|
|
274
|
+
matches: resolved.matches,
|
|
275
|
+
query
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
console.log(`No match found for: ${query}`);
|
|
280
|
+
if (resolved.matches.length > 0) console.log("Did you mean:", resolved.matches.slice(0, 10));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (json) {
|
|
284
|
+
printJson({
|
|
285
|
+
command,
|
|
286
|
+
found: true,
|
|
287
|
+
matchedAddress: resolved.matchedAddress,
|
|
288
|
+
query,
|
|
289
|
+
schedule: toScheduleJson(resolved.schedule)
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
console.log(formatScheduleText(resolved.schedule));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
printHelp();
|
|
297
|
+
};
|
|
298
|
+
await main();
|
|
299
|
+
|
|
300
|
+
//#endregion
|
|
301
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mynameistito/hcc-bin-day",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript client for Hamilton City Council Fight the Landfill bin-day lookup API.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"api",
|
|
7
|
+
"bin-day",
|
|
8
|
+
"hamilton",
|
|
9
|
+
"hamilton-city-council",
|
|
10
|
+
"typescript",
|
|
11
|
+
"waste"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/mynameistito/hcc-bin-day#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/mynameistito/hcc-bin-day/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Tito <contact@mynameistito.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/mynameistito/hcc-bin-day.git"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"hcc-bin-day-api": "dist/src/index.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "./dist/src/index.js",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsdown",
|
|
35
|
+
"dev": "bun run src/index.ts",
|
|
36
|
+
"check": "ultracite check",
|
|
37
|
+
"fix": "ultracite fix",
|
|
38
|
+
"typecheck": "tsgo --noEmit",
|
|
39
|
+
"knip": "bunx knip@latest",
|
|
40
|
+
"ci": "bun run check && bun run typecheck && bun run build",
|
|
41
|
+
"prepare": "lefthook install"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@typescript/native-preview": "^7.0.0-dev.20260624.1",
|
|
45
|
+
"lefthook": "^2.1.9",
|
|
46
|
+
"oxfmt": "^0.56.0",
|
|
47
|
+
"oxlint": "^1.71.0",
|
|
48
|
+
"tsdown": "0.16.0",
|
|
49
|
+
"ultracite": "7.8.3"
|
|
50
|
+
}
|
|
51
|
+
}
|