lovebug-report 0.2.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/README.md +51 -0
- package/package.json +35 -0
- package/src/cli.js +108 -0
- package/src/constants.js +85 -0
- package/src/errors.js +26 -0
- package/src/http.js +42 -0
- package/src/index.js +69 -0
- package/src/normalize.js +191 -0
- package/src/regions.js +103 -0
- package/src/reports.js +92 -0
- package/src/urls.js +48 -0
- package/src/util.js +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# lovebug-report
|
|
2
|
+
|
|
3
|
+
Public `lovebug.com` map lookup and anonymous report client for the `lovebug-report` k-skill.
|
|
4
|
+
|
|
5
|
+
## Source
|
|
6
|
+
|
|
7
|
+
- Public site: `https://xn--2i0bt2q2wd1wb.com/` (`러브버그.com`)
|
|
8
|
+
- Public map JSON:
|
|
9
|
+
- `GET /api/map/gu-score`
|
|
10
|
+
- `GET /api/map/weekly-report-count`
|
|
11
|
+
- `GET /api/map/clusters?level=sigungu&historicalYear=2026`
|
|
12
|
+
- `GET /api/map/areas?historicalYear=2026&includePolygon=false`
|
|
13
|
+
- Anonymous report surface observed in the Next.js bundle:
|
|
14
|
+
- Supabase RPC: `POST https://sewrbxfawkmusnyzjoab.supabase.co/rest/v1/rpc/submit_anonymous_report`
|
|
15
|
+
- Body keys: `p_gu_code`, `p_lng`, `p_lat`, `p_accuracy_m`, `p_level`, `p_device_hash`, `p_context`, `p_image_url`, `p_indoor`
|
|
16
|
+
|
|
17
|
+
No `k-skill-proxy` route is used because the map surfaces and anon report RPC are public and do not require an upstream API key. Reporting still uses the site's own validation and can reject duplicates, poor accuracy, or coordinates outside the selected gu.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
const { searchLovebugRegions, reportLovebug } = require("lovebug-report")
|
|
23
|
+
|
|
24
|
+
const status = await searchLovebugRegions({ query: "중랑", includeAreas: true })
|
|
25
|
+
console.log(status.items[0])
|
|
26
|
+
|
|
27
|
+
await reportLovebug({
|
|
28
|
+
guCode: "11070",
|
|
29
|
+
level: "많아요",
|
|
30
|
+
context: "길거리",
|
|
31
|
+
lng: 127.09,
|
|
32
|
+
lat: 37.59,
|
|
33
|
+
accuracyM: 25,
|
|
34
|
+
deviceHash: "stable-anonymous-device-id"
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
CLI:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
lovebug-report search --query 중랑 --include-areas
|
|
42
|
+
lovebug-report list --limit 10
|
|
43
|
+
lovebug-report report --gu-code 11070 --level 많아요 --context 길거리 --lng 127.09 --lat 37.59 --accuracy 25 --device-hash stable-id
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Boundaries and failure modes
|
|
47
|
+
|
|
48
|
+
- Search data is user-report-based and should be described as current community reports, not official pest-control truth.
|
|
49
|
+
- Anonymous reports require real coordinates. The upstream rejects `OUTSIDE_GU_AREA`, `ACCURACY_TOO_LOW`, and `ANON_DAILY_DUPLICATE` cases.
|
|
50
|
+
- This package does not bypass location validation, login, CAPTCHA, rate limits, or duplicate limits.
|
|
51
|
+
- Image upload is not automated; pass `imageUrl` only when an image is already hosted in a place the site accepts.
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lovebug-report",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "lovebug.com public map lookup and anonymous report client",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"lovebug-report": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"k-skill",
|
|
26
|
+
"korea",
|
|
27
|
+
"lovebug",
|
|
28
|
+
"map",
|
|
29
|
+
"report"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
|
|
33
|
+
"test": "node --test"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const {
|
|
3
|
+
findRegion,
|
|
4
|
+
getAreas,
|
|
5
|
+
getClusters,
|
|
6
|
+
listRegions,
|
|
7
|
+
reportLovebug,
|
|
8
|
+
searchLovebugRegions
|
|
9
|
+
} = require("./index")
|
|
10
|
+
|
|
11
|
+
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
|
|
12
|
+
if (options.help) {
|
|
13
|
+
printHelp(io)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const command = options.command || "search"
|
|
18
|
+
let result
|
|
19
|
+
if (command === "search") result = await searchLovebugRegions(options)
|
|
20
|
+
else if (command === "list") result = await listRegions(options)
|
|
21
|
+
else if (command === "find") result = await findRegion(options.query, options)
|
|
22
|
+
else if (command === "areas") result = await getAreas(options)
|
|
23
|
+
else if (command === "clusters") result = await getClusters(options)
|
|
24
|
+
else if (command === "report") result = await reportLovebug(options)
|
|
25
|
+
else throw new Error(`unknown command: ${command}`)
|
|
26
|
+
|
|
27
|
+
io.log(JSON.stringify(result, null, 2))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const options = {}
|
|
32
|
+
if (argv[0] && !argv[0].startsWith("-")) options.command = argv.shift()
|
|
33
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
34
|
+
const arg = argv[i]
|
|
35
|
+
if (arg === "--query" || arg === "-q" || arg === "--region") options.query = argv[++i] || ""
|
|
36
|
+
else if (arg === "--gu-code" || arg === "--gu") options.guCode = argv[++i] || ""
|
|
37
|
+
else if (arg === "--level") options.level = argv[++i] || ""
|
|
38
|
+
else if (arg === "--context") options.context = argv[++i] || ""
|
|
39
|
+
else if (arg === "--lng" || arg === "--longitude") options.lng = argv[++i] || ""
|
|
40
|
+
else if (arg === "--lat" || arg === "--latitude") options.lat = argv[++i] || ""
|
|
41
|
+
else if (arg === "--accuracy" || arg === "--accuracy-m") options.accuracyM = argv[++i] || ""
|
|
42
|
+
else if (arg === "--device-hash") options.deviceHash = argv[++i] || ""
|
|
43
|
+
else if (arg === "--image-url") options.imageUrl = argv[++i] || ""
|
|
44
|
+
else if (arg === "--indoor") options.indoor = true
|
|
45
|
+
else if (arg === "--outdoor") options.indoor = false
|
|
46
|
+
else if (arg === "--limit") options.limit = argv[++i] || ""
|
|
47
|
+
else if (arg === "--level-type") options.level = argv[++i] || ""
|
|
48
|
+
else if (arg === "--date") options.date = argv[++i] || ""
|
|
49
|
+
else if (arg === "--historical-year" || arg === "--year") options.historicalYear = argv[++i] || ""
|
|
50
|
+
else if (arg === "--historical-week" || arg === "--week") options.historicalWeek = argv[++i] || ""
|
|
51
|
+
else if (arg === "--include-areas") options.includeAreas = true
|
|
52
|
+
else if (arg === "--no-areas") options.includeAreas = false
|
|
53
|
+
else if (arg === "--include-polygon") options.includePolygon = true
|
|
54
|
+
else if (arg === "--help" || arg === "-h") options.help = true
|
|
55
|
+
else if (!options.query) options.query = arg
|
|
56
|
+
}
|
|
57
|
+
return options
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printHelp(io = console) {
|
|
61
|
+
io.log(`Usage: lovebug-report <command> [options]
|
|
62
|
+
|
|
63
|
+
Query lovebug.com public map surfaces and submit anonymous lovebug reports through the same public Supabase RPC used by the site.
|
|
64
|
+
|
|
65
|
+
Commands:
|
|
66
|
+
lovebug-report search --query 중랑 [--include-areas] Search gu/area lovebug status.
|
|
67
|
+
lovebug-report list --limit 10 List top gu score rows.
|
|
68
|
+
lovebug-report find --query 동안 Return the best matching gu row.
|
|
69
|
+
lovebug-report areas --query 중랑 Fetch eup/myeon/dong area snapshots.
|
|
70
|
+
lovebug-report clusters Fetch sigungu cluster snapshots.
|
|
71
|
+
lovebug-report report --gu-code 11070 --level 많아요 --context 길거리 --lng 127.09 --lat 37.59 --accuracy 25 --device-hash <stable-id>
|
|
72
|
+
|
|
73
|
+
Report options:
|
|
74
|
+
--gu-code <code> Required sigungu code from search/list output.
|
|
75
|
+
--level <0-3|label> 잠잠해요, 살짝 보임, 많아요, 매우 많아요.
|
|
76
|
+
--context <label> 실내, 길거리, 공원, 지하철·버스, 상가, 기타.
|
|
77
|
+
--lng, --lat <number> Current coordinates. lovebug.com rejects coordinates outside the gu.
|
|
78
|
+
--accuracy <meters> GPS accuracy in meters.
|
|
79
|
+
--device-hash <id> Required stable anonymous device id for duplicate/rate limits.
|
|
80
|
+
--image-url <url> Optional already-uploaded image URL.
|
|
81
|
+
|
|
82
|
+
Lookup options:
|
|
83
|
+
--query, -q <text> Region name/code query.
|
|
84
|
+
--limit <number> Max rows.
|
|
85
|
+
--date <YYYY-MM-DD> Snapshot date when supported by upstream.
|
|
86
|
+
--year <YYYY> Historical year (default 2026).
|
|
87
|
+
--week <number> Historical week.
|
|
88
|
+
--no-areas Skip area snapshot lookup in search.
|
|
89
|
+
`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatError(error) {
|
|
93
|
+
if (process.env.LOVEBUG_REPORT_DEBUG && error && error.stack) return error.stack
|
|
94
|
+
if (error && error.code && error.message) return `Error [${error.code}]: ${error.message}`
|
|
95
|
+
if (error && error.message) return `Error: ${error.message}`
|
|
96
|
+
return String(error)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function run(argv = process.argv.slice(2), io = console) {
|
|
100
|
+
return main(parseArgs(argv), io).catch((error) => {
|
|
101
|
+
io.error(formatError(error))
|
|
102
|
+
process.exitCode = 1
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (require.main === module) run()
|
|
107
|
+
|
|
108
|
+
module.exports = { formatError, main, parseArgs, printHelp, run }
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const LOVEBUG_BASE_URL = "https://xn--2i0bt2q2wd1wb.com"
|
|
2
|
+
const SUPABASE_URL = "https://sewrbxfawkmusnyzjoab.supabase.co"
|
|
3
|
+
const SUPABASE_REST_URL = `${SUPABASE_URL}/rest/v1`
|
|
4
|
+
const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNld3JieGZhd2ttdXNueXpqb2FiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzc5NDAzODAsImV4cCI6MjA5MzUxNjM4MH0.jOzkBBdRPQFvAhvgc2SvSfWDnEQCouFS2AXvJoAikrY"
|
|
5
|
+
const DEFAULT_HISTORICAL_YEAR = 2026
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 20000
|
|
7
|
+
const DEFAULT_DEVICE_HASH_NAMESPACE = "lovebug-report"
|
|
8
|
+
|
|
9
|
+
const LEVEL_LABELS = {
|
|
10
|
+
0: "잠잠해요",
|
|
11
|
+
1: "살짝 보임",
|
|
12
|
+
2: "많아요",
|
|
13
|
+
3: "매우 많아요"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SCORE_LABELS = {
|
|
17
|
+
0: "지금은 조용해요",
|
|
18
|
+
1: "조금 보여요",
|
|
19
|
+
2: "꽤 많이 보여요",
|
|
20
|
+
3: "엄청 많아요, 조심!"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ADVISORY_LABELS = {
|
|
24
|
+
0: "평상시 활동 OK",
|
|
25
|
+
1: "베란다 조명 끄면 도움돼요",
|
|
26
|
+
2: "외출 시 주의, 창문 방충망 점검",
|
|
27
|
+
3: "외출/환기 자제 권장, 흰 옷 피하기"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CONTEXT_LABELS = {
|
|
31
|
+
indoor: "실내",
|
|
32
|
+
street: "길거리",
|
|
33
|
+
park: "공원",
|
|
34
|
+
transit: "지하철·버스",
|
|
35
|
+
shop: "상가",
|
|
36
|
+
other: "기타"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CONTEXT_ALIASES = new Map([
|
|
40
|
+
["indoor", "indoor"],
|
|
41
|
+
["inside", "indoor"],
|
|
42
|
+
["실내", "indoor"],
|
|
43
|
+
["집", "indoor"],
|
|
44
|
+
["건물안", "indoor"],
|
|
45
|
+
["street", "street"],
|
|
46
|
+
["road", "street"],
|
|
47
|
+
["outdoor", "street"],
|
|
48
|
+
["outside", "street"],
|
|
49
|
+
["길거리", "street"],
|
|
50
|
+
["길", "street"],
|
|
51
|
+
["실외", "street"],
|
|
52
|
+
["바깥", "street"],
|
|
53
|
+
["park", "park"],
|
|
54
|
+
["공원", "park"],
|
|
55
|
+
["transit", "transit"],
|
|
56
|
+
["subway", "transit"],
|
|
57
|
+
["bus", "transit"],
|
|
58
|
+
["지하철", "transit"],
|
|
59
|
+
["버스", "transit"],
|
|
60
|
+
["지하철버스", "transit"],
|
|
61
|
+
["지하철·버스", "transit"],
|
|
62
|
+
["지하철ㆍ버스", "transit"],
|
|
63
|
+
["shop", "shop"],
|
|
64
|
+
["store", "shop"],
|
|
65
|
+
["상가", "shop"],
|
|
66
|
+
["가게", "shop"],
|
|
67
|
+
["매장", "shop"],
|
|
68
|
+
["other", "other"],
|
|
69
|
+
["기타", "other"]
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
ADVISORY_LABELS,
|
|
74
|
+
CONTEXT_ALIASES,
|
|
75
|
+
CONTEXT_LABELS,
|
|
76
|
+
DEFAULT_DEVICE_HASH_NAMESPACE,
|
|
77
|
+
DEFAULT_HISTORICAL_YEAR,
|
|
78
|
+
DEFAULT_TIMEOUT_MS,
|
|
79
|
+
LEVEL_LABELS,
|
|
80
|
+
LOVEBUG_BASE_URL,
|
|
81
|
+
SCORE_LABELS,
|
|
82
|
+
SUPABASE_ANON_KEY,
|
|
83
|
+
SUPABASE_REST_URL,
|
|
84
|
+
SUPABASE_URL
|
|
85
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class LovebugRequestError extends Error {
|
|
2
|
+
constructor(message, options = {}) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = "LovebugRequestError"
|
|
5
|
+
this.status = options.status ?? null
|
|
6
|
+
this.code = options.code ?? null
|
|
7
|
+
this.body = options.body
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function classifyReportError(payload) {
|
|
12
|
+
const text = typeof payload === "string" ? payload : JSON.stringify(payload || {})
|
|
13
|
+
if (text.includes("ANON_DAILY_DUPLICATE")) return "ANON_DAILY_DUPLICATE"
|
|
14
|
+
if (text.includes("OUTSIDE_GU_AREA")) return "OUTSIDE_GU_AREA"
|
|
15
|
+
if (text.includes("ACCURACY_TOO_LOW")) return "ACCURACY_TOO_LOW"
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function reportErrorMessage(code) {
|
|
20
|
+
if (code === "ANON_DAILY_DUPLICATE") return "anonymous device already submitted a report for this region today"
|
|
21
|
+
if (code === "OUTSIDE_GU_AREA") return "coordinates are outside the requested gu_code"
|
|
22
|
+
if (code === "ACCURACY_TOO_LOW") return "location accuracy is too low for the lovebug.com report surface"
|
|
23
|
+
return `lovebug report failed: ${code}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { LovebugRequestError, classifyReportError, reportErrorMessage }
|
package/src/http.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { DEFAULT_TIMEOUT_MS } = require("./constants")
|
|
2
|
+
const { LovebugRequestError, classifyReportError } = require("./errors")
|
|
3
|
+
|
|
4
|
+
function createTimeoutSignal(timeoutMs) {
|
|
5
|
+
if (!timeoutMs || timeoutMs <= 0) return null
|
|
6
|
+
const controller = new AbortController()
|
|
7
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
|
8
|
+
return { signal: controller.signal, cancel: () => clearTimeout(timeout) }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function requestJson(url, options = {}) {
|
|
12
|
+
const fetchImpl = options.fetch || globalThis.fetch
|
|
13
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetch is not available; pass options.fetch or use Node.js 18+")
|
|
14
|
+
const timeout = createTimeoutSignal(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
15
|
+
const signal = options.signal || timeout?.signal
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetchImpl(url, { ...options.init, signal })
|
|
18
|
+
const body = await parseResponseBody(response)
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new LovebugRequestError(`lovebug request failed: ${response.status}`, {
|
|
21
|
+
status: response.status,
|
|
22
|
+
body,
|
|
23
|
+
code: classifyReportError(body)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
return body
|
|
27
|
+
} finally {
|
|
28
|
+
timeout?.cancel()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function parseResponseBody(response) {
|
|
33
|
+
const text = await response.text()
|
|
34
|
+
if (!text) return null
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text)
|
|
37
|
+
} catch {
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { createTimeoutSignal, parseResponseBody, requestJson }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const {
|
|
2
|
+
ADVISORY_LABELS,
|
|
3
|
+
CONTEXT_LABELS,
|
|
4
|
+
LEVEL_LABELS,
|
|
5
|
+
LOVEBUG_BASE_URL,
|
|
6
|
+
SCORE_LABELS,
|
|
7
|
+
SUPABASE_ANON_KEY,
|
|
8
|
+
SUPABASE_REST_URL,
|
|
9
|
+
SUPABASE_URL
|
|
10
|
+
} = require("./constants")
|
|
11
|
+
const { LovebugRequestError } = require("./errors")
|
|
12
|
+
const {
|
|
13
|
+
normalizeContext,
|
|
14
|
+
normalizeGuScoreResponse,
|
|
15
|
+
normalizeLevel,
|
|
16
|
+
normalizeSnapshotResponse
|
|
17
|
+
} = require("./normalize")
|
|
18
|
+
const {
|
|
19
|
+
buildSubmitAnonymousReportRequest,
|
|
20
|
+
createDeviceHash,
|
|
21
|
+
reportLovebug
|
|
22
|
+
} = require("./reports")
|
|
23
|
+
const {
|
|
24
|
+
findRegion,
|
|
25
|
+
getAreas,
|
|
26
|
+
getClusters,
|
|
27
|
+
getGuScores,
|
|
28
|
+
getWeeklyReportCount,
|
|
29
|
+
listRegions,
|
|
30
|
+
searchLovebugRegions
|
|
31
|
+
} = require("./regions")
|
|
32
|
+
const {
|
|
33
|
+
buildAreasUrl,
|
|
34
|
+
buildBoundariesUrl,
|
|
35
|
+
buildClustersUrl,
|
|
36
|
+
buildGuScoreUrl,
|
|
37
|
+
buildWeeklyReportCountUrl
|
|
38
|
+
} = require("./urls")
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
ADVISORY_LABELS,
|
|
42
|
+
CONTEXT_LABELS,
|
|
43
|
+
LEVEL_LABELS,
|
|
44
|
+
LOVEBUG_BASE_URL,
|
|
45
|
+
SCORE_LABELS,
|
|
46
|
+
SUPABASE_ANON_KEY,
|
|
47
|
+
SUPABASE_REST_URL,
|
|
48
|
+
SUPABASE_URL,
|
|
49
|
+
LovebugRequestError,
|
|
50
|
+
buildAreasUrl,
|
|
51
|
+
buildBoundariesUrl,
|
|
52
|
+
buildClustersUrl,
|
|
53
|
+
buildGuScoreUrl,
|
|
54
|
+
buildSubmitAnonymousReportRequest,
|
|
55
|
+
buildWeeklyReportCountUrl,
|
|
56
|
+
createDeviceHash,
|
|
57
|
+
findRegion,
|
|
58
|
+
getAreas,
|
|
59
|
+
getClusters,
|
|
60
|
+
getGuScores,
|
|
61
|
+
getWeeklyReportCount,
|
|
62
|
+
listRegions,
|
|
63
|
+
normalizeContext,
|
|
64
|
+
normalizeGuScoreResponse,
|
|
65
|
+
normalizeLevel,
|
|
66
|
+
normalizeSnapshotResponse,
|
|
67
|
+
reportLovebug,
|
|
68
|
+
searchLovebugRegions
|
|
69
|
+
}
|
package/src/normalize.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
const {
|
|
2
|
+
ADVISORY_LABELS,
|
|
3
|
+
CONTEXT_ALIASES,
|
|
4
|
+
LEVEL_LABELS,
|
|
5
|
+
SCORE_LABELS
|
|
6
|
+
} = require("./constants")
|
|
7
|
+
const { buildGuScoreUrl } = require("./urls")
|
|
8
|
+
|
|
9
|
+
function cleanText(value) {
|
|
10
|
+
return String(value == null ? "" : value).replace(/\s+/g, " ").trim()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeToken(value) {
|
|
14
|
+
return cleanText(value).replace(/[\s._-]+/g, "").toLowerCase()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseBoolean(value, defaultValue = undefined) {
|
|
18
|
+
if (value == null || value === "") return defaultValue
|
|
19
|
+
if (typeof value === "boolean") return value
|
|
20
|
+
const token = normalizeToken(value)
|
|
21
|
+
if (["true", "1", "yes", "y", "include", "포함"].includes(token)) return true
|
|
22
|
+
if (["false", "0", "no", "n", "exclude", "미포함"].includes(token)) return false
|
|
23
|
+
return defaultValue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeLevel(value) {
|
|
27
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 3) return value
|
|
28
|
+
const token = normalizeToken(value)
|
|
29
|
+
if (["0", "quiet", "none", "잠잠", "잠잠해요", "없음", "안보임", "조용"].includes(token)) return 0
|
|
30
|
+
if (["1", "low", "slight", "살짝", "살짝보임", "조금", "조금보여요"].includes(token)) return 1
|
|
31
|
+
if (["2", "medium", "many", "많음", "많아요", "꽤많이", "꽤많이보여요"].includes(token)) return 2
|
|
32
|
+
if (["3", "high", "verymany", "peak", "매우많음", "매우많아요", "엄청많음", "엄청많아요", "조심"].includes(token)) return 3
|
|
33
|
+
throw new TypeError("level must be 0, 1, 2, 3 or one of the official Korean labels")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeContext(value = "other") {
|
|
37
|
+
const token = normalizeToken(value || "other")
|
|
38
|
+
const normalized = CONTEXT_ALIASES.get(token) || CONTEXT_ALIASES.get(cleanText(value))
|
|
39
|
+
if (!normalized) throw new TypeError(`unsupported report context: ${value}`)
|
|
40
|
+
return normalized
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeCode(value, label) {
|
|
44
|
+
const code = cleanText(value)
|
|
45
|
+
if (!/^\d{5,10}$/.test(code)) throw new TypeError(`${label} must be a Korean administrative code`)
|
|
46
|
+
return code
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeGuScoreResponse(payload) {
|
|
50
|
+
const features = Array.isArray(payload?.features) ? payload.features : []
|
|
51
|
+
const items = features.map((feature, index) => normalizeGuScoreFeature(feature, index + 1))
|
|
52
|
+
return { type: "gu-score", source_url: buildGuScoreUrl(), items }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeSnapshotResponse(payload, options = {}) {
|
|
56
|
+
const features = Array.isArray(payload?.features) ? payload.features : []
|
|
57
|
+
return {
|
|
58
|
+
type: options.type || payload?.level || "snapshot",
|
|
59
|
+
date: payload?.date || null,
|
|
60
|
+
level: payload?.level || null,
|
|
61
|
+
source_url: options.sourceUrl || null,
|
|
62
|
+
items: features.map(normalizeSnapshotFeature)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeGuScoreFeature(feature, rank = null) {
|
|
67
|
+
const properties = feature?.properties || {}
|
|
68
|
+
const level = properties.no_data ? 0 : normalizeLevelFromScore(properties.score)
|
|
69
|
+
return compactObject({
|
|
70
|
+
rank,
|
|
71
|
+
gu_code: cleanText(properties.gu_code),
|
|
72
|
+
gu_name: cleanText(properties.gu_name),
|
|
73
|
+
sido: cleanText(properties.sido),
|
|
74
|
+
score: Number(properties.score ?? 0),
|
|
75
|
+
score_label: properties.no_data ? "아직 정보가 부족해요" : SCORE_LABELS[level],
|
|
76
|
+
advisory: ADVISORY_LABELS[level],
|
|
77
|
+
level,
|
|
78
|
+
level_label: LEVEL_LABELS[level],
|
|
79
|
+
no_data: Boolean(properties.no_data),
|
|
80
|
+
coordinates: coordinatesFromGeometry(feature.geometry),
|
|
81
|
+
counts: compactObject({
|
|
82
|
+
report: numberOrNull(properties.report_count ?? properties.report_count_14d),
|
|
83
|
+
report_14d: numberOrNull(properties.report_count_14d),
|
|
84
|
+
report_24h: numberOrNull(properties.report_count_24h),
|
|
85
|
+
verified_14d: numberOrNull(properties.report_count_verified_14d),
|
|
86
|
+
spotted: numberOrNull(properties.spotted_count),
|
|
87
|
+
quiet: numberOrNull(properties.quiet_count ?? properties.quiet_count_14d),
|
|
88
|
+
low: numberOrNull(properties.low_count),
|
|
89
|
+
medium: numberOrNull(properties.medium_count),
|
|
90
|
+
high: numberOrNull(properties.high_count)
|
|
91
|
+
}),
|
|
92
|
+
metrics: compactObject({
|
|
93
|
+
intensity_score: numberOrNull(properties.intensity_score),
|
|
94
|
+
spotted_rate_score: numberOrNull(properties.spotted_rate_score),
|
|
95
|
+
quiet_penalty: numberOrNull(properties.quiet_penalty),
|
|
96
|
+
historical_score: numberOrNull(properties.historical_score),
|
|
97
|
+
confidence_cap: numberOrNull(properties.confidence_cap)
|
|
98
|
+
}),
|
|
99
|
+
source_url: buildGuScoreUrl()
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeSnapshotFeature(feature) {
|
|
104
|
+
const properties = feature?.properties || {}
|
|
105
|
+
const stats = properties.stats || {}
|
|
106
|
+
const classifiedLevel = clampLevel(stats.classified_level)
|
|
107
|
+
return compactObject({
|
|
108
|
+
area_code: cleanText(properties.code || properties.area_code),
|
|
109
|
+
area_name: cleanText(properties.name || properties.label),
|
|
110
|
+
gu_code: cleanText(properties.gu_code || properties.code),
|
|
111
|
+
gu_name: cleanText(properties.gu_name || properties.label),
|
|
112
|
+
sido: cleanText(properties.sido),
|
|
113
|
+
coordinates: coordinatesFromGeometry(feature.geometry),
|
|
114
|
+
centroid: properties.centroid ? { lng: Number(properties.centroid.lng), lat: Number(properties.centroid.lat) } : undefined,
|
|
115
|
+
stats: compactObject({
|
|
116
|
+
date: cleanText(stats.date),
|
|
117
|
+
level: classifiedLevel,
|
|
118
|
+
level_label: LEVEL_LABELS[classifiedLevel],
|
|
119
|
+
intensity: numberOrNull(stats.intensity),
|
|
120
|
+
confidence: numberOrNull(stats.confidence),
|
|
121
|
+
indoor_ratio: numberOrNull(stats.indoor_ratio),
|
|
122
|
+
report_count: numberOrNull(stats.report_count),
|
|
123
|
+
report_count_verified: numberOrNull(stats.report_count_verified),
|
|
124
|
+
hour_distribution: Array.isArray(stats.hour_distribution) ? stats.hour_distribution : undefined
|
|
125
|
+
}),
|
|
126
|
+
historical: normalizeHistorical(properties.historical)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizeHistorical(value) {
|
|
131
|
+
if (!value) return null
|
|
132
|
+
return compactObject({
|
|
133
|
+
year: numberOrNull(value.year),
|
|
134
|
+
week: numberOrNull(value.week),
|
|
135
|
+
updated_at: cleanText(value.updated_at),
|
|
136
|
+
mention_count: numberOrNull(value.mention_count),
|
|
137
|
+
classified_level: clampLevel(value.classified_level),
|
|
138
|
+
source_count: value.source_count || undefined,
|
|
139
|
+
source_urls: Array.isArray(value.source_urls)
|
|
140
|
+
? value.source_urls.map((item) => compactObject({
|
|
141
|
+
source: cleanText(item.source),
|
|
142
|
+
title: cleanText(item.title),
|
|
143
|
+
url: cleanText(item.url),
|
|
144
|
+
date: cleanText(item.date)
|
|
145
|
+
}))
|
|
146
|
+
: undefined
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function coordinatesFromGeometry(geometry) {
|
|
151
|
+
const coordinates = geometry && Array.isArray(geometry.coordinates) ? geometry.coordinates : []
|
|
152
|
+
if (coordinates.length < 2) return null
|
|
153
|
+
const [lng, lat] = coordinates
|
|
154
|
+
if (!Number.isFinite(Number(lng)) || !Number.isFinite(Number(lat))) return null
|
|
155
|
+
return { lng: Number(lng), lat: Number(lat) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function compactObject(value) {
|
|
159
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== ""))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function numberOrNull(value) {
|
|
163
|
+
const number = Number(value)
|
|
164
|
+
return Number.isFinite(number) ? number : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function clampLevel(value) {
|
|
168
|
+
const number = Number(value)
|
|
169
|
+
if (!Number.isFinite(number)) return 0
|
|
170
|
+
return Math.max(0, Math.min(3, Math.round(number)))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeLevelFromScore(score) {
|
|
174
|
+
const value = Number(score)
|
|
175
|
+
if (!Number.isFinite(value) || value <= 25) return 0
|
|
176
|
+
if (value <= 50) return 1
|
|
177
|
+
if (value <= 75) return 2
|
|
178
|
+
return 3
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
cleanText,
|
|
183
|
+
compactObject,
|
|
184
|
+
normalizeCode,
|
|
185
|
+
normalizeContext,
|
|
186
|
+
normalizeGuScoreResponse,
|
|
187
|
+
normalizeLevel,
|
|
188
|
+
normalizeSnapshotResponse,
|
|
189
|
+
normalizeToken,
|
|
190
|
+
parseBoolean
|
|
191
|
+
}
|
package/src/regions.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const { requestJson } = require("./http")
|
|
2
|
+
const {
|
|
3
|
+
cleanText,
|
|
4
|
+
normalizeGuScoreResponse,
|
|
5
|
+
normalizeSnapshotResponse,
|
|
6
|
+
normalizeToken,
|
|
7
|
+
parseBoolean
|
|
8
|
+
} = require("./normalize")
|
|
9
|
+
const {
|
|
10
|
+
buildAreasUrl,
|
|
11
|
+
buildClustersUrl,
|
|
12
|
+
buildGuScoreUrl,
|
|
13
|
+
buildWeeklyReportCountUrl
|
|
14
|
+
} = require("./urls")
|
|
15
|
+
const { parsePositiveInteger } = require("./util")
|
|
16
|
+
|
|
17
|
+
async function getGuScores(options = {}) {
|
|
18
|
+
const payload = await requestJson(buildGuScoreUrl(), options)
|
|
19
|
+
return normalizeGuScoreResponse(payload)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function getWeeklyReportCount(options = {}) {
|
|
23
|
+
const payload = await requestJson(buildWeeklyReportCountUrl(), options)
|
|
24
|
+
return { count: Number(payload?.count ?? 0), source_url: buildWeeklyReportCountUrl() }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getClusters(options = {}) {
|
|
28
|
+
const sourceUrl = buildClustersUrl(options)
|
|
29
|
+
const payload = await requestJson(sourceUrl, options)
|
|
30
|
+
return normalizeSnapshotResponse(payload, { type: "clusters", sourceUrl })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getAreas(options = {}) {
|
|
34
|
+
const sourceUrl = buildAreasUrl(options)
|
|
35
|
+
const payload = await requestJson(sourceUrl, options)
|
|
36
|
+
return normalizeSnapshotResponse(payload, { type: "areas", sourceUrl })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function listRegions(options = {}) {
|
|
40
|
+
const result = await getGuScores(options)
|
|
41
|
+
const limit = parsePositiveInteger(options.limit, { defaultValue: 20, max: 100 })
|
|
42
|
+
return { ...result, items: result.items.slice(0, limit) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findRegion(query, options = {}) {
|
|
46
|
+
const result = await searchLovebugRegions({ ...options, query, includeAreas: false })
|
|
47
|
+
return result.items[0] || null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function searchLovebugRegions(options = {}) {
|
|
51
|
+
const query = cleanText(options.query)
|
|
52
|
+
const limit = parsePositiveInteger(options.limit, { defaultValue: 10, max: 100 })
|
|
53
|
+
const includeAreas = parseBoolean(options.includeAreas, true)
|
|
54
|
+
const [guScores, weeklyReportCount, areas] = await Promise.all([
|
|
55
|
+
getGuScores(options),
|
|
56
|
+
getWeeklyReportCount(options).catch((error) => ({ count: null, warning: error.message })),
|
|
57
|
+
includeAreas ? getAreas({ ...options, includePolygon: false }).catch((error) => ({ items: [], warning: error.message })) : Promise.resolve({ items: [] })
|
|
58
|
+
])
|
|
59
|
+
const areaGroups = groupAreasByGu(areas.items || [], query)
|
|
60
|
+
const items = guScores.items
|
|
61
|
+
.filter((item) => regionMatches(item, query) || areaGroups.has(item.gu_code))
|
|
62
|
+
.slice(0, limit)
|
|
63
|
+
.map((item) => ({ ...item, areas: includeAreas ? areaGroups.get(item.gu_code) || [] : undefined }))
|
|
64
|
+
return {
|
|
65
|
+
type: "region-search",
|
|
66
|
+
query,
|
|
67
|
+
summary: {
|
|
68
|
+
matched_count: items.length,
|
|
69
|
+
weekly_report_count: weeklyReportCount.count,
|
|
70
|
+
source_urls: [buildGuScoreUrl(), buildWeeklyReportCountUrl(), includeAreas ? buildAreasUrl({ includePolygon: false }) : null].filter(Boolean),
|
|
71
|
+
warnings: [weeklyReportCount.warning, areas.warning].filter(Boolean)
|
|
72
|
+
},
|
|
73
|
+
items
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function regionMatches(item, query) {
|
|
78
|
+
if (!query) return true
|
|
79
|
+
const token = normalizeToken(query)
|
|
80
|
+
return [item.gu_code, item.gu_name, item.sido].some((value) => normalizeToken(value).includes(token))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function groupAreasByGu(areas, query) {
|
|
84
|
+
const token = normalizeToken(query)
|
|
85
|
+
const groups = new Map()
|
|
86
|
+
for (const area of areas) {
|
|
87
|
+
if (token && ![area.area_code, area.area_name, area.gu_code, area.gu_name, area.sido].some((value) => normalizeToken(value).includes(token))) continue
|
|
88
|
+
const list = groups.get(area.gu_code) || []
|
|
89
|
+
list.push(area)
|
|
90
|
+
groups.set(area.gu_code, list)
|
|
91
|
+
}
|
|
92
|
+
return groups
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
findRegion,
|
|
97
|
+
getAreas,
|
|
98
|
+
getClusters,
|
|
99
|
+
getGuScores,
|
|
100
|
+
getWeeklyReportCount,
|
|
101
|
+
listRegions,
|
|
102
|
+
searchLovebugRegions
|
|
103
|
+
}
|
package/src/reports.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const crypto = require("node:crypto")
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
DEFAULT_DEVICE_HASH_NAMESPACE,
|
|
5
|
+
DEFAULT_TIMEOUT_MS,
|
|
6
|
+
SUPABASE_ANON_KEY,
|
|
7
|
+
SUPABASE_REST_URL
|
|
8
|
+
} = require("./constants")
|
|
9
|
+
const { LovebugRequestError, classifyReportError, reportErrorMessage } = require("./errors")
|
|
10
|
+
const { createTimeoutSignal, parseResponseBody } = require("./http")
|
|
11
|
+
const {
|
|
12
|
+
cleanText,
|
|
13
|
+
normalizeCode,
|
|
14
|
+
normalizeContext,
|
|
15
|
+
normalizeLevel,
|
|
16
|
+
parseBoolean
|
|
17
|
+
} = require("./normalize")
|
|
18
|
+
|
|
19
|
+
function buildSubmitAnonymousReportRequest(options = {}) {
|
|
20
|
+
const guCode = normalizeCode(options.guCode || options.gu_code, "guCode")
|
|
21
|
+
const level = normalizeLevel(options.level)
|
|
22
|
+
const context = normalizeContext(options.context || "other")
|
|
23
|
+
const lng = Number(options.lng)
|
|
24
|
+
const lat = Number(options.lat)
|
|
25
|
+
if (!Number.isFinite(lng) || !Number.isFinite(lat)) throw new TypeError("lng and lat are required numeric coordinates")
|
|
26
|
+
const accuracyM = options.accuracyM == null || options.accuracyM === "" ? null : Number(options.accuracyM)
|
|
27
|
+
if (accuracyM != null && !Number.isFinite(accuracyM)) throw new TypeError("accuracyM must be numeric when provided")
|
|
28
|
+
const deviceHash = cleanText(options.deviceHash || options.device_hash)
|
|
29
|
+
if (!deviceHash) throw new TypeError("deviceHash is required for report submission")
|
|
30
|
+
const indoor = options.indoor == null ? context === "indoor" : Boolean(parseBoolean(options.indoor, options.indoor))
|
|
31
|
+
const body = {
|
|
32
|
+
p_gu_code: guCode,
|
|
33
|
+
p_lng: lng,
|
|
34
|
+
p_lat: lat,
|
|
35
|
+
p_accuracy_m: accuracyM,
|
|
36
|
+
p_level: level,
|
|
37
|
+
p_device_hash: deviceHash,
|
|
38
|
+
p_context: context,
|
|
39
|
+
p_image_url: options.imageUrl || options.image_url || null,
|
|
40
|
+
p_indoor: indoor
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
url: `${SUPABASE_REST_URL}/rpc/submit_anonymous_report`,
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
apikey: SUPABASE_ANON_KEY,
|
|
48
|
+
Authorization: `Bearer ${SUPABASE_ANON_KEY}`
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function reportLovebug(options = {}) {
|
|
55
|
+
const request = buildSubmitAnonymousReportRequest(options)
|
|
56
|
+
const fetchImpl = options.fetch || globalThis.fetch
|
|
57
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetch is not available; pass options.fetch or use Node.js 18+")
|
|
58
|
+
const timeout = createTimeoutSignal(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetchImpl(request.url, {
|
|
61
|
+
method: request.method,
|
|
62
|
+
headers: request.headers,
|
|
63
|
+
body: request.body,
|
|
64
|
+
signal: options.signal || timeout?.signal
|
|
65
|
+
})
|
|
66
|
+
const payload = await parseResponseBody(response)
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
const code = classifyReportError(payload) || `HTTP_${response.status}`
|
|
69
|
+
throw new LovebugRequestError(reportErrorMessage(code), { status: response.status, code, body: payload })
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
status: response.status,
|
|
74
|
+
report: JSON.parse(request.body),
|
|
75
|
+
response: payload,
|
|
76
|
+
source_url: request.url
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
timeout?.cancel()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createDeviceHash(options = {}) {
|
|
84
|
+
const seed = cleanText(options.seed || DEFAULT_DEVICE_HASH_NAMESPACE)
|
|
85
|
+
return crypto.createHash("sha256").update(seed).digest("hex")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
buildSubmitAnonymousReportRequest,
|
|
90
|
+
createDeviceHash,
|
|
91
|
+
reportLovebug
|
|
92
|
+
}
|
package/src/urls.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { DEFAULT_HISTORICAL_YEAR, LOVEBUG_BASE_URL } = require("./constants")
|
|
2
|
+
|
|
3
|
+
function buildUrl(path, params) {
|
|
4
|
+
const url = new URL(path, LOVEBUG_BASE_URL)
|
|
5
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
6
|
+
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value))
|
|
7
|
+
}
|
|
8
|
+
return url.toString()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildGuScoreUrl() {
|
|
12
|
+
return buildUrl("/api/map/gu-score")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildWeeklyReportCountUrl() {
|
|
16
|
+
return buildUrl("/api/map/weekly-report-count")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildClustersUrl(options = {}) {
|
|
20
|
+
return buildUrl("/api/map/clusters", {
|
|
21
|
+
level: options.level || "sigungu",
|
|
22
|
+
historicalYear: options.historicalYear ?? DEFAULT_HISTORICAL_YEAR,
|
|
23
|
+
historicalWeek: options.historicalWeek,
|
|
24
|
+
date: options.date
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildAreasUrl(options = {}) {
|
|
29
|
+
return buildUrl("/api/map/areas", {
|
|
30
|
+
historicalYear: options.historicalYear ?? DEFAULT_HISTORICAL_YEAR,
|
|
31
|
+
includePolygon: options.includePolygon === true ? "true" : "false",
|
|
32
|
+
historicalWeek: options.historicalWeek,
|
|
33
|
+
date: options.date
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildBoundariesUrl(options = {}) {
|
|
38
|
+
return buildUrl("/api/map/boundaries", { level: options.level || "sigungu" })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
buildAreasUrl,
|
|
43
|
+
buildBoundariesUrl,
|
|
44
|
+
buildClustersUrl,
|
|
45
|
+
buildGuScoreUrl,
|
|
46
|
+
buildWeeklyReportCountUrl,
|
|
47
|
+
buildUrl
|
|
48
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
function parsePositiveInteger(value, { defaultValue, max = 100 } = {}) {
|
|
2
|
+
if (value == null || value === "") return defaultValue
|
|
3
|
+
const number = Number(value)
|
|
4
|
+
if (!Number.isInteger(number) || number < 1) throw new TypeError("value must be a positive integer")
|
|
5
|
+
return Math.min(number, max)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
module.exports = { parsePositiveInteger }
|