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 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 }
@@ -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
+ }
@@ -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 }