yaohao 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/bin/yaohao.js +47 -0
  4. package/package.json +53 -0
  5. package/skills/yaohao/SKILL.md +128 -0
  6. package/src/commands/calendar.js +29 -0
  7. package/src/commands/cron.js +87 -0
  8. package/src/commands/eligibility.js +28 -0
  9. package/src/commands/family.js +17 -0
  10. package/src/commands/history.js +17 -0
  11. package/src/commands/init.js +102 -0
  12. package/src/commands/market.js +40 -0
  13. package/src/commands/notify.js +92 -0
  14. package/src/commands/result.js +18 -0
  15. package/src/commands/set.js +53 -0
  16. package/src/commands/status.js +17 -0
  17. package/src/commands/waitlist.js +17 -0
  18. package/src/commands/watch.js +159 -0
  19. package/src/constants.js +18 -0
  20. package/src/lib/config-manager.js +67 -0
  21. package/src/lib/notifier.js +190 -0
  22. package/src/output.js +15 -0
  23. package/src/source/_shared/crawl.js +169 -0
  24. package/src/source/_shared/parseUtils.js +141 -0
  25. package/src/source/_shared/titleClassify.js +30 -0
  26. package/src/source/beijing/calendar.js +65 -0
  27. package/src/source/beijing/constants.js +8 -0
  28. package/src/source/beijing/crawl.js +156 -0
  29. package/src/source/beijing/eligibility.js +110 -0
  30. package/src/source/beijing/index.js +23 -0
  31. package/src/source/beijing/parse.js +206 -0
  32. package/src/source/beijing/pdfExtract.js +41 -0
  33. package/src/source/beijing/service.js +190 -0
  34. package/src/source/guangzhou/calendar.js +54 -0
  35. package/src/source/guangzhou/constants.js +16 -0
  36. package/src/source/guangzhou/eligibility.js +88 -0
  37. package/src/source/guangzhou/index.js +22 -0
  38. package/src/source/guangzhou/parse.js +61 -0
  39. package/src/source/guangzhou/service.js +126 -0
  40. package/src/source/hangzhou/calendar.js +60 -0
  41. package/src/source/hangzhou/constants.js +16 -0
  42. package/src/source/hangzhou/eligibility.js +102 -0
  43. package/src/source/hangzhou/index.js +20 -0
  44. package/src/source/hangzhou/parse.js +59 -0
  45. package/src/source/hangzhou/service.js +122 -0
  46. package/src/source/index.js +54 -0
  47. package/src/source/shenzhen/calendar.js +44 -0
  48. package/src/source/shenzhen/constants.js +14 -0
  49. package/src/source/shenzhen/eligibility.js +90 -0
  50. package/src/source/shenzhen/index.js +20 -0
  51. package/src/source/shenzhen/parse.js +58 -0
  52. package/src/source/shenzhen/service.js +122 -0
@@ -0,0 +1,102 @@
1
+ import { select, confirm } from '@inquirer/prompts';
2
+ import { isInitialized, updateUser } from '../lib/config-manager.js';
3
+ import { listImplemented, getCityLabel, listPlanned } from '../source/index.js';
4
+ import { DEFAULT_CITY } from '../constants.js';
5
+ import { output, success, error } from '../output.js';
6
+
7
+ export function registerInitCommand(program) {
8
+ program
9
+ .command('init')
10
+ .description('初始化(设置默认城市 / 指标类型 / 申请人类型 / 通知渠道)')
11
+ .option('--city <city>', '默认城市(非交互)')
12
+ .option('--reg-type <type>', '指标类型 PTC / XNY(非交互)')
13
+ .option('--apply-type <type>', '申请人类型 person / family(非交互)')
14
+ .option('--notify <url>', '通知渠道 URL(可多次指定)', (val, acc) => {
15
+ acc.push(val); return acc;
16
+ }, [])
17
+ .option('-f, --force', '已有配置时强制覆盖')
18
+ .action(async (opts) => {
19
+ try {
20
+ const alreadyInit = isInitialized();
21
+ const nonInteractive = opts.city || opts.regType || opts.applyType || opts.notify.length > 0;
22
+
23
+ if (alreadyInit && nonInteractive && !opts.force) {
24
+ output(error('已有配置,加 -f 强制覆盖,或用 `yaohao set <key> <value>` / `yaohao notify` 修改单项'));
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+
29
+ let city, regType, applyType, notifyUrls;
30
+
31
+ if (nonInteractive) {
32
+ city = opts.city || DEFAULT_CITY;
33
+ regType = opts.regType || 'PTC';
34
+ applyType = opts.applyType || 'person';
35
+ notifyUrls = opts.notify;
36
+ } else {
37
+ if (alreadyInit) {
38
+ const ok = await confirm({ message: '已有配置,是否覆盖?', default: false });
39
+ if (!ok) {
40
+ output(success(null, '已取消'));
41
+ return;
42
+ }
43
+ }
44
+ const implemented = listImplemented();
45
+ const planned = listPlanned();
46
+ city = await select({
47
+ message: '默认城市:',
48
+ choices: [
49
+ ...implemented.map((c) => ({ name: getCityLabel(c), value: c })),
50
+ ...planned.map((c) => ({
51
+ name: `${getCityLabel(c)}(v1 未实现,敬请期待)`,
52
+ value: c,
53
+ disabled: '(暂不可选)',
54
+ })),
55
+ ],
56
+ default: DEFAULT_CITY,
57
+ });
58
+ regType = await select({
59
+ message: '默认关注的指标类型:',
60
+ choices: [
61
+ { name: '普通指标', value: 'PTC' },
62
+ { name: '新能源指标', value: 'XNY' },
63
+ ],
64
+ default: 'PTC',
65
+ });
66
+ applyType = await select({
67
+ message: '申请人类型:',
68
+ choices: [
69
+ { name: '个人', value: 'person' },
70
+ { name: '家庭', value: 'family' },
71
+ ],
72
+ default: 'person',
73
+ });
74
+ notifyUrls = [];
75
+ }
76
+
77
+ updateUser({
78
+ default_city: city,
79
+ reg_type: regType,
80
+ apply_type: applyType,
81
+ notify_urls: notifyUrls,
82
+ });
83
+ output(success(
84
+ { default_city: city, reg_type: regType, apply_type: applyType, notify_count: notifyUrls.length },
85
+ [
86
+ '初始化完成。',
87
+ ` 默认城市: ${getCityLabel(city)}`,
88
+ ` 指标类型: ${regType === 'PTC' ? '普通指标' : '新能源指标'}`,
89
+ ` 申请人类型: ${applyType === 'person' ? '个人' : '家庭'}`,
90
+ ` 通知渠道: ${notifyUrls.length} 个${notifyUrls.length === 0 ? '(运行 `yaohao notify add <url>` 添加)' : ''}`,
91
+ ].join('\n'),
92
+ ));
93
+ } catch (err) {
94
+ if (err && err.name === 'ExitPromptError') {
95
+ output(success(null, '已取消'));
96
+ return;
97
+ }
98
+ output(error(`初始化失败: ${err.message}`));
99
+ process.exitCode = 1;
100
+ }
101
+ });
102
+ }
@@ -0,0 +1,40 @@
1
+ import { getSource, listImplemented } from '../source/index.js';
2
+ import { getUser } from '../lib/config-manager.js';
3
+ import { DEFAULT_CITY } from '../constants.js';
4
+ import { output, success, error } from '../output.js';
5
+
6
+ function resolveCity(opts) {
7
+ if (opts.city) return opts.city;
8
+ const user = getUser();
9
+ return user?.default_city || DEFAULT_CITY;
10
+ }
11
+
12
+ export function registerMarketCommand(program) {
13
+ program
14
+ .command('market')
15
+ .description('当前形势:申请数、配置数、中签率')
16
+ .option('--city <city>', `城市 (${listImplemented().join('|')})`)
17
+ .option('--no-pdf', '跳过 PDF 解析(更快,但拿不到中签率)')
18
+ .option('--no-cache', '禁用本地缓存')
19
+ .option('--json', '原始 JSON 输出')
20
+ .action(async (opts) => {
21
+ try {
22
+ const city = resolveCity(opts);
23
+ const source = getSource(city);
24
+ const result = await source.extractMarketMetrics(opts);
25
+ if (!result.ok) {
26
+ output(error(result.error));
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+ if (opts.json) {
31
+ console.log(JSON.stringify({ city, ...result.data }, null, 2));
32
+ return;
33
+ }
34
+ output(success({ city, ...result.data }, result.lines.join('\n')));
35
+ } catch (err) {
36
+ output(error(err.message));
37
+ process.exitCode = 1;
38
+ }
39
+ });
40
+ }
@@ -0,0 +1,92 @@
1
+ import { getUser, isInitialized, updateUser } from '../lib/config-manager.js';
2
+ import { testNotify } from '../lib/notifier.js';
3
+ import { output, success, error } from '../output.js';
4
+
5
+ export function registerNotifyCommand(program) {
6
+ const notify = program.command('notify').description('通知渠道管理');
7
+
8
+ notify
9
+ .command('add <url>')
10
+ .description('添加通知渠道')
11
+ .action(async (url) => {
12
+ try {
13
+ if (!isInitialized()) {
14
+ output(error('尚未初始化,请先运行 yaohao init'));
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+
19
+ const user = getUser();
20
+ const urls = user.notify_urls || [];
21
+
22
+ if (urls.includes(url)) {
23
+ output(success({ notify_urls: urls }, '该通知渠道已存在'));
24
+ return;
25
+ }
26
+
27
+ urls.push(url);
28
+ updateUser({ notify_urls: urls });
29
+ output(success({ notify_urls: urls }, `通知渠道已添加: ${url}`));
30
+ } catch (err) {
31
+ output(error(`添加通知渠道失败: ${err.message}`));
32
+ process.exitCode = 1;
33
+ }
34
+ });
35
+
36
+ notify
37
+ .command('remove <url>')
38
+ .description('移除通知渠道')
39
+ .action(async (url) => {
40
+ try {
41
+ if (!isInitialized()) {
42
+ output(error('尚未初始化,请先运行 yaohao init'));
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+
47
+ const user = getUser();
48
+ const urls = (user.notify_urls || []).filter((u) => u !== url);
49
+ updateUser({ notify_urls: urls });
50
+ output(success({ notify_urls: urls }, `通知渠道已移除: ${url}`));
51
+ } catch (err) {
52
+ output(error(`移除通知渠道失败: ${err.message}`));
53
+ process.exitCode = 1;
54
+ }
55
+ });
56
+
57
+ notify
58
+ .command('test')
59
+ .description('发送测试通知')
60
+ .action(async () => {
61
+ try {
62
+ if (!isInitialized()) {
63
+ output(error('尚未初始化,请先运行 yaohao init'));
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+
68
+ const user = getUser();
69
+ const urls = user.notify_urls || [];
70
+
71
+ if (urls.length === 0) {
72
+ output(error('未配置通知渠道,请先运行 yaohao notify add <url>'));
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+
77
+ const results = await testNotify(urls);
78
+ const succeeded = results.filter((r) => r.status === 'fulfilled').length;
79
+ const failed = results.filter((r) => r.status === 'rejected').length;
80
+
81
+ output(
82
+ success(
83
+ { succeeded, failed, total: urls.length },
84
+ `测试通知已发送: ${succeeded} 成功, ${failed} 失败 (共 ${urls.length} 个渠道)`,
85
+ ),
86
+ );
87
+ } catch (err) {
88
+ output(error(`测试通知失败: ${err.message}`));
89
+ process.exitCode = 1;
90
+ }
91
+ });
92
+ }
@@ -0,0 +1,18 @@
1
+ import { output, success } from '../output.js';
2
+
3
+ // v1 范围:本人是否中签的查询请登录官网。开奖结果公告的订阅见 `yaohao watch result`。
4
+ export function registerResultCommand(program) {
5
+ program
6
+ .command('result')
7
+ .description('(v1 不支持)查询本人是否中签')
8
+ .action(async () => {
9
+ output(success(
10
+ { supported: false },
11
+ [
12
+ '本人中签结果查询 v1 不支持(基于隐私和合规考虑)。',
13
+ '官方开奖公告订阅请用 `yaohao watch result`。',
14
+ '查个人结果请直接登录官网:https://apply.jtw.beijing.gov.cn/apply/',
15
+ ].join('\n'),
16
+ ));
17
+ });
18
+ }
@@ -0,0 +1,53 @@
1
+ import { isInitialized, updateUser } from '../lib/config-manager.js';
2
+ import { listImplemented } from '../source/index.js';
3
+ import { output, success, error } from '../output.js';
4
+
5
+ const VALID_KEYS = {
6
+ 'default-city': {
7
+ configKey: 'default_city',
8
+ validValues: listImplemented(),
9
+ description: '默认城市',
10
+ },
11
+ 'reg-type': {
12
+ configKey: 'reg_type',
13
+ validValues: ['PTC', 'XNY'],
14
+ description: '指标类型(PTC=普通指标,XNY=新能源指标)',
15
+ },
16
+ 'apply-type': {
17
+ configKey: 'apply_type',
18
+ validValues: ['person', 'family'],
19
+ description: '申请人类型(person=个人,family=家庭)',
20
+ },
21
+ };
22
+
23
+ export function registerSetCommand(program) {
24
+ program
25
+ .command('set <key> <value>')
26
+ .description(`修改配置项(支持: ${Object.keys(VALID_KEYS).join(', ')})`)
27
+ .action(async (key, value) => {
28
+ try {
29
+ if (!isInitialized()) {
30
+ output(error('尚未初始化,请先运行 yaohao init'));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ const keyDef = VALID_KEYS[key];
35
+ if (!keyDef) {
36
+ const validKeys = Object.keys(VALID_KEYS).join(', ');
37
+ output(error(`不支持的配置项: ${key},支持: ${validKeys}`));
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ if (!keyDef.validValues.includes(value)) {
42
+ output(error(`无效的值: ${value},${keyDef.description}必须是: ${keyDef.validValues.join(' / ')}`));
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ updateUser({ [keyDef.configKey]: value });
47
+ output(success({ key, value }, `${keyDef.description}已设置为: ${value}`));
48
+ } catch (err) {
49
+ output(error(`设置失败: ${err.message}`));
50
+ process.exitCode = 1;
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,17 @@
1
+ import { output, success } from '../output.js';
2
+
3
+ // v1 范围:仅做公开数据查询和提醒。本人账号状态查询请登录官网。
4
+ export function registerStatusCommand(program) {
5
+ program
6
+ .command('status')
7
+ .description('(v1 不支持)查看本人申请编码状态、阶梯倍率')
8
+ .action(async () => {
9
+ output(success(
10
+ { supported: false },
11
+ [
12
+ '本人账号状态查询 v1 不支持(基于隐私和合规考虑,本工具不持有用户密码)。',
13
+ '请直接登录官网查看:https://apply.jtw.beijing.gov.cn/apply/',
14
+ ].join('\n'),
15
+ ));
16
+ });
17
+ }
@@ -0,0 +1,17 @@
1
+ import { output, success } from '../output.js';
2
+
3
+ // v1 范围:本人新能源轮候位置查询请登录官网。新能源相关公告的订阅见 `yaohao watch result/policy`。
4
+ export function registerWaitlistCommand(program) {
5
+ program
6
+ .command('waitlist')
7
+ .description('(v1 不支持)新能源轮候位置(前面还有多少人)')
8
+ .action(async () => {
9
+ output(success(
10
+ { supported: false },
11
+ [
12
+ '本人新能源轮候位置 v1 不支持(基于隐私和合规考虑)。',
13
+ '请直接登录官网查看:https://apply.jtw.beijing.gov.cn/apply/',
14
+ ].join('\n'),
15
+ ));
16
+ });
17
+ }
@@ -0,0 +1,159 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { getSource, listImplemented } from '../source/index.js';
5
+ import { getUser, isInitialized } from '../lib/config-manager.js';
6
+ import { notify } from '../lib/notifier.js';
7
+ import { DEFAULT_CITY, CONFIG_DIR } from '../constants.js';
8
+ import { output, success, error } from '../output.js';
9
+
10
+ const SEEN_FILE = path.join(homedir(), CONFIG_DIR, 'cache', 'watch-seen.json');
11
+
12
+ const UNSUPPORTED_TARGETS = {
13
+ renewal: '年度延期确认提醒',
14
+ expiry: '申请编码到期提醒',
15
+ ranking: '家庭排名变化通知',
16
+ };
17
+
18
+ function resolveCity(opts) {
19
+ if (opts.city) return opts.city;
20
+ const user = getUser();
21
+ return user?.default_city || DEFAULT_CITY;
22
+ }
23
+
24
+ function loadSeen() {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(SEEN_FILE, 'utf-8'));
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+ function saveSeen(seen) {
32
+ fs.mkdirSync(path.dirname(SEEN_FILE), { recursive: true });
33
+ fs.writeFileSync(SEEN_FILE, JSON.stringify(seen, null, 2));
34
+ }
35
+ function fingerprint(item) {
36
+ return `${item.url}|${item.date}`;
37
+ }
38
+
39
+ async function runWatch(city, targetName, opts) {
40
+ if (UNSUPPORTED_TARGETS[targetName]) {
41
+ output(success(
42
+ { supported: false, target: targetName },
43
+ [
44
+ `${UNSUPPORTED_TARGETS[targetName]} v1 不支持(依赖本人账号登录态,基于隐私和合规考虑)。`,
45
+ '请直接登录官网查看本人状态。',
46
+ ].join('\n'),
47
+ ));
48
+ return;
49
+ }
50
+
51
+ const source = getSource(city);
52
+ const target = source.watchTargets?.[targetName];
53
+ if (!target) {
54
+ output(error(`${city} 暂不支持 watch ${targetName}`));
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ const seen = loadSeen();
60
+ const seenKey = `${city}_${targetName}`;
61
+ const seenForTarget = new Set(seen[seenKey] || []);
62
+ const isFirstRun = seenForTarget.size === 0;
63
+
64
+ let items;
65
+ try {
66
+ items = await target.fetch(opts);
67
+ } catch (err) {
68
+ output(error(`抓取失败: ${err.message}`));
69
+ process.exitCode = 1;
70
+ return;
71
+ }
72
+
73
+ const newItems = [];
74
+ for (const item of items) {
75
+ const fp = fingerprint(item);
76
+ if (seenForTarget.has(fp)) continue;
77
+ seenForTarget.add(fp);
78
+ newItems.push(item);
79
+ }
80
+
81
+ seen[seenKey] = Array.from(seenForTarget).slice(-200);
82
+ saveSeen(seen);
83
+
84
+ const shouldNotify = !isFirstRun && newItems.length > 0;
85
+
86
+ if (opts.json) {
87
+ console.log(JSON.stringify({ city, target: targetName, isFirstRun, newCount: newItems.length, items: newItems }, null, 2));
88
+ return;
89
+ }
90
+
91
+ const lines = [];
92
+ if (isFirstRun) {
93
+ lines.push(`[${city} ${target.desc}] 首次运行,已记录当前 ${items.length} 条历史(不推送),下次运行后开始检测新增`);
94
+ } else if (newItems.length === 0) {
95
+ lines.push(`[${city} ${target.desc}] 无新增内容`);
96
+ } else {
97
+ lines.push(`[${city} ${target.desc}] 检测到 ${newItems.length} 条新增:`);
98
+ for (const item of newItems) {
99
+ lines.push(` ${item.date} ${item.kind ? `[${item.kind}] ` : ''}${item.title}`);
100
+ lines.push(` ${item.url}`);
101
+ }
102
+ }
103
+
104
+ let notified = 0;
105
+ if (shouldNotify && opts.notify !== false && isInitialized()) {
106
+ const user = getUser();
107
+ const notifyUrls = user.notify_urls || [];
108
+ if (notifyUrls.length > 0) {
109
+ const title = `[yaohao] ${city} ${target.desc}:${newItems.length} 条新增`;
110
+ const body = newItems.map((i) => `${i.date} ${i.title}\n${i.url}`).join('\n\n');
111
+ try {
112
+ await notify(notifyUrls, title, body);
113
+ notified = notifyUrls.length;
114
+ lines.push('');
115
+ lines.push(`已推送到 ${notified} 个通知渠道`);
116
+ } catch (err) {
117
+ lines.push('');
118
+ lines.push(`(推送失败: ${err.message})`);
119
+ }
120
+ }
121
+ }
122
+
123
+ output(success(
124
+ { city, target: targetName, isFirstRun, newCount: newItems.length, items: newItems, notified },
125
+ lines.join('\n'),
126
+ ));
127
+ }
128
+
129
+ export function registerWatchCommand(program) {
130
+ const watch = program.command('watch').description('订阅/提醒类命令');
131
+
132
+ const supportedTargets = [
133
+ { name: 'result', desc: '开奖结果' },
134
+ { name: 'policy', desc: '政策变化' },
135
+ { name: 'window', desc: '申请窗口开放' },
136
+ ];
137
+
138
+ for (const t of supportedTargets) {
139
+ watch
140
+ .command(t.name)
141
+ .description(t.desc)
142
+ .option('--city <city>', `城市 (${listImplemented().join('|')})`)
143
+ .option('--no-cache', '禁用缓存,强制重新抓取')
144
+ .option('--no-notify', '不推送通知,只输出到 stdout')
145
+ .option('--json', '原始 JSON 输出')
146
+ .action(async (opts) => {
147
+ await runWatch(resolveCity(opts), t.name, opts);
148
+ });
149
+ }
150
+
151
+ for (const [name, desc] of Object.entries(UNSUPPORTED_TARGETS)) {
152
+ watch
153
+ .command(name)
154
+ .description(`(v1 不支持)${desc}`)
155
+ .action(async () => {
156
+ await runWatch(null, name, {});
157
+ });
158
+ }
159
+ }
@@ -0,0 +1,18 @@
1
+ // 全局通用常量(非城市特定)。城市特定的 URL / 规则放在 src/source/{city}/ 下。
2
+
3
+ export const CONFIG_DIR = '.yaohao';
4
+ export const CONFIG_FILE = 'config.json';
5
+
6
+ export const DEFAULT_CITY = 'beijing';
7
+
8
+ // 指标类型(适用于多数城市,少数城市可能有不同分类,由 source 自己处理)
9
+ export const REG_TYPE_MAP = {
10
+ PTC: '普通指标',
11
+ XNY: '新能源指标',
12
+ };
13
+
14
+ // 申请人类型
15
+ export const APPLY_TYPE_MAP = {
16
+ person: '个人',
17
+ family: '家庭',
18
+ };
@@ -0,0 +1,67 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { CONFIG_DIR, CONFIG_FILE, DEFAULT_CITY } from '../constants.js';
5
+
6
+ export function getConfigDir() {
7
+ return join(homedir(), CONFIG_DIR);
8
+ }
9
+
10
+ export function getConfigPath() {
11
+ return join(getConfigDir(), CONFIG_FILE);
12
+ }
13
+
14
+ export function ensureConfigDir() {
15
+ const dir = getConfigDir();
16
+ if (!existsSync(dir)) {
17
+ mkdirSync(dir, { recursive: true });
18
+ }
19
+ }
20
+
21
+ export function loadConfig() {
22
+ const configPath = getConfigPath();
23
+ if (!existsSync(configPath)) {
24
+ return null;
25
+ }
26
+ const raw = readFileSync(configPath, 'utf-8');
27
+ return JSON.parse(raw);
28
+ }
29
+
30
+ export function saveConfig(config) {
31
+ ensureConfigDir();
32
+ const configPath = getConfigPath();
33
+ writeFileSync(configPath, JSON.stringify(config, null, 4), 'utf-8');
34
+ }
35
+
36
+ function defaultUser() {
37
+ return {
38
+ default_city: DEFAULT_CITY,
39
+ reg_type: 'PTC',
40
+ apply_type: 'person',
41
+ notify_urls: [],
42
+ };
43
+ }
44
+
45
+ export function getUser() {
46
+ const config = loadConfig();
47
+ if (!config || !Array.isArray(config.users) || config.users.length === 0) {
48
+ return null;
49
+ }
50
+ return config.users[0];
51
+ }
52
+
53
+ export function updateUser(updates) {
54
+ let config = loadConfig();
55
+ if (!config) {
56
+ config = { users: [defaultUser()] };
57
+ }
58
+ if (!Array.isArray(config.users) || config.users.length === 0) {
59
+ config.users = [defaultUser()];
60
+ }
61
+ Object.assign(config.users[0], updates);
62
+ saveConfig(config);
63
+ }
64
+
65
+ export function isInitialized() {
66
+ return loadConfig() !== null;
67
+ }