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,190 @@
1
+ import { createHmac } from 'crypto';
2
+
3
+ /**
4
+ * Parse a notify URL into scheme and path.
5
+ * @param {string} url
6
+ * @returns {{ scheme: string, path: string } | null}
7
+ */
8
+ function parseNotifyUrl(url) {
9
+ const m = url.match(/^(\w+):\/\/(.+)$/);
10
+ if (!m) return null;
11
+ return { scheme: m[1], path: m[2] };
12
+ }
13
+
14
+ // ── Channel handlers ────────────────────────────────────────────────
15
+
16
+ async function sendBark(path, title, body) {
17
+ // bark://<key> (default server) or bark://<server>/<key>
18
+ const parts = path.split('/');
19
+ let server, key;
20
+ if (parts.length === 1) {
21
+ server = 'api.day.app';
22
+ key = parts[0];
23
+ } else {
24
+ server = parts[0];
25
+ key = parts.slice(1).join('/');
26
+ }
27
+ const url = `https://${server}/${key}/${encodeURIComponent(title)}/${encodeURIComponent(body)}`;
28
+ const res = await fetch(url);
29
+ if (!res.ok) throw new Error(`Bark HTTP ${res.status}`);
30
+ }
31
+
32
+ async function sendTelegram(path, title, body) {
33
+ // tgram://<bot_token>/<chat_id>
34
+ const idx = path.lastIndexOf('/');
35
+ if (idx === -1) throw new Error('Invalid Telegram URL: missing chat_id');
36
+ const token = path.slice(0, idx);
37
+ const chatId = path.slice(idx + 1);
38
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
39
+ const res = await fetch(url, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({
43
+ chat_id: chatId,
44
+ text: `*${title}*\n${body}`,
45
+ parse_mode: 'Markdown',
46
+ }),
47
+ });
48
+ if (!res.ok) throw new Error(`Telegram HTTP ${res.status}`);
49
+ }
50
+
51
+ async function sendDingTalk(path, title, body) {
52
+ // dingtalk://<access_token>[/<secret>]
53
+ const parts = path.split('/');
54
+ const token = parts[0];
55
+ const secret = parts[1];
56
+ let url = `https://oapi.dingtalk.com/robot/send?access_token=${token}`;
57
+ if (secret) {
58
+ const ts = Date.now();
59
+ const stringToSign = `${ts}\n${secret}`;
60
+ const sign = encodeURIComponent(
61
+ createHmac('sha256', secret).update(stringToSign).digest('base64'),
62
+ );
63
+ url += `&timestamp=${ts}&sign=${sign}`;
64
+ }
65
+ const res = await fetch(url, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ msgtype: 'text', text: { content: `${title}\n${body}` } }),
69
+ });
70
+ if (!res.ok) throw new Error(`DingTalk HTTP ${res.status}`);
71
+ }
72
+
73
+ async function sendWeCom(path, title, body) {
74
+ // wecom://<key>
75
+ const key = path;
76
+ const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${key}`;
77
+ const res = await fetch(url, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ msgtype: 'text', text: { content: `${title}\n${body}` } }),
81
+ });
82
+ if (!res.ok) throw new Error(`WeCom HTTP ${res.status}`);
83
+ }
84
+
85
+ async function sendFeishu(path, title, body) {
86
+ // feishu://<hook_id>[/<secret>]
87
+ const parts = path.split('/');
88
+ const hookId = parts[0];
89
+ const secret = parts[1];
90
+ const url = `https://open.feishu.cn/open-apis/bot/v2/hook/${hookId}`;
91
+ const payload = {
92
+ msg_type: 'text',
93
+ content: { text: `${title}\n${body}` },
94
+ };
95
+ if (secret) {
96
+ const timestamp = Math.floor(Date.now() / 1000).toString();
97
+ const stringToSign = `${timestamp}\n${secret}`;
98
+ const sign = createHmac('sha256', stringToSign).update(Buffer.alloc(0)).digest('base64');
99
+ payload.timestamp = timestamp;
100
+ payload.sign = sign;
101
+ }
102
+ const res = await fetch(url, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify(payload),
106
+ });
107
+ if (!res.ok) throw new Error(`Feishu HTTP ${res.status}`);
108
+ }
109
+
110
+ async function sendSlack(path, title, body) {
111
+ // slack://<T>/<B>/<token>
112
+ const url = `https://hooks.slack.com/services/${path}`;
113
+ const res = await fetch(url, {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ text: `*${title}*\n${body}` }),
117
+ });
118
+ if (!res.ok) throw new Error(`Slack HTTP ${res.status}`);
119
+ }
120
+
121
+ async function sendJson(path, title, body) {
122
+ // json://<host>/<path>
123
+ const idx = path.indexOf('/');
124
+ const host = idx === -1 ? path : path.slice(0, idx);
125
+ const urlPath = idx === -1 ? '' : path.slice(idx);
126
+ const url = `https://${host}${urlPath}`;
127
+ const res = await fetch(url, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ title, body }),
131
+ });
132
+ if (!res.ok) throw new Error(`Webhook HTTP ${res.status}`);
133
+ }
134
+
135
+ // ── Dispatcher ──────────────────────────────────────────────────────
136
+
137
+ const handlers = {
138
+ bark: sendBark,
139
+ barks: sendBark, // apprise 兼容:barks:// = bark:// over HTTPS
140
+ tgram: sendTelegram,
141
+ dingtalk: sendDingTalk,
142
+ wecom: sendWeCom,
143
+ feishu: sendFeishu,
144
+ slack: sendSlack,
145
+ json: sendJson,
146
+ };
147
+
148
+ /**
149
+ * Send a notification to all configured URLs.
150
+ * Best-effort: errors are logged but never thrown.
151
+ *
152
+ * @param {string[]} urls Notification URLs (e.g. ["bark://key", "tgram://token/chatid"])
153
+ * @param {string} title Notification title
154
+ * @param {string} body Notification body
155
+ * @returns {Promise<PromiseSettledResult[]>}
156
+ */
157
+ export async function notify(urls, title, body) {
158
+ if (!urls || urls.length === 0) return [];
159
+
160
+ const tasks = urls.map(async (url) => {
161
+ const parsed = parseNotifyUrl(url);
162
+ if (!parsed) {
163
+ console.error(`[notifier] invalid URL: ${url}`);
164
+ throw new Error(`Invalid notify URL: ${url}`);
165
+ }
166
+ const handler = handlers[parsed.scheme];
167
+ if (!handler) {
168
+ console.error(`[notifier] unsupported scheme: ${parsed.scheme}`);
169
+ throw new Error(`Unsupported notify scheme: ${parsed.scheme}`);
170
+ }
171
+ try {
172
+ await handler(parsed.path, title, body);
173
+ } catch (err) {
174
+ console.error(`[notifier] ${parsed.scheme} failed:`, err.message);
175
+ throw err;
176
+ }
177
+ });
178
+
179
+ return Promise.allSettled(tasks);
180
+ }
181
+
182
+ /**
183
+ * Send a test notification to all configured URLs.
184
+ *
185
+ * @param {string[]} urls Notification URLs
186
+ * @returns {Promise<PromiseSettledResult[]>}
187
+ */
188
+ export async function testNotify(urls) {
189
+ return notify(urls, '测试通知 / Test Notification', '如果你看到这条消息,说明通知配置正确。');
190
+ }
package/src/output.js ADDED
@@ -0,0 +1,15 @@
1
+ export function output(data) {
2
+ if (data.success === false) {
3
+ console.error(`错误: ${data.message}`);
4
+ } else {
5
+ console.log(data.message || '');
6
+ }
7
+ }
8
+
9
+ export function success(data, message) {
10
+ return { success: true, data, message };
11
+ }
12
+
13
+ export function error(message, data = null) {
14
+ return { success: false, data, message };
15
+ }
@@ -0,0 +1,169 @@
1
+ // 通用公告站抓取层:负责 HTTP 请求、编码处理、本地缓存。
2
+ // 适配国内多个政府摇号系统,部分站点使用老版 TLS 配置,需放宽 SSL 选项。
3
+
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { homedir } from 'node:os';
8
+ import { Agent, setGlobalDispatcher } from 'undici';
9
+
10
+ // 政府站普遍存在的两个 SSL 问题:
11
+ // 1) 证书链不完整(如深圳)→ rejectUnauthorized: false
12
+ // 2) 不支持安全重协商(如杭州)→ SSL_OP_LEGACY_SERVER_CONNECT (0x4)
13
+ //
14
+ // 仅用于抓取公开公告页面,不影响安全性。
15
+ setGlobalDispatcher(new Agent({
16
+ connect: {
17
+ rejectUnauthorized: false,
18
+ secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
19
+ },
20
+ }));
21
+
22
+ const DEFAULT_HEADERS = {
23
+ 'User-Agent':
24
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
25
+ '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
26
+ Accept:
27
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
28
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
29
+ };
30
+
31
+ const CACHE_DIR = path.join(homedir(), '.yaohao', 'cache');
32
+
33
+ /**
34
+ * 抓取一个 URL,返回 { url, status, html, contentType, fromCache, elapsedMs }
35
+ *
36
+ * @param {string} url
37
+ * @param {object} [opts]
38
+ * @param {boolean} [opts.useCache=true] 本地缓存,避免反复打官网
39
+ * @param {number} [opts.timeoutMs=15000]
40
+ * @param {number} [opts.retries=2]
41
+ * @param {number} [opts.maxCacheAgeMs] 缓存最大有效期(毫秒),默认无限
42
+ */
43
+ export async function fetchHtml(url, opts = {}) {
44
+ const { useCache = true, timeoutMs = 15000, retries = 2, maxCacheAgeMs } = opts;
45
+
46
+ if (useCache) {
47
+ const cached = await readCache(url, maxCacheAgeMs);
48
+ if (cached) {
49
+ return { ...cached, fromCache: true };
50
+ }
51
+ }
52
+
53
+ let lastErr = null;
54
+ for (let attempt = 0; attempt <= retries; attempt++) {
55
+ const t0 = Date.now();
56
+ const ac = new AbortController();
57
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
58
+ try {
59
+ const res = await fetch(url, {
60
+ headers: DEFAULT_HEADERS,
61
+ signal: ac.signal,
62
+ redirect: 'follow',
63
+ });
64
+ const contentType = res.headers.get('content-type') || '';
65
+ const buf = Buffer.from(await res.arrayBuffer());
66
+ const html = decode(buf, contentType);
67
+ const elapsedMs = Date.now() - t0;
68
+ const payload = {
69
+ url,
70
+ status: res.status,
71
+ contentType,
72
+ html,
73
+ elapsedMs,
74
+ fromCache: false,
75
+ savedAt: Date.now(),
76
+ };
77
+ if (useCache && res.status === 200) {
78
+ await writeCache(url, payload);
79
+ }
80
+ return payload;
81
+ } catch (err) {
82
+ lastErr = err;
83
+ if (attempt < retries) {
84
+ await sleep(500 * (attempt + 1));
85
+ }
86
+ } finally {
87
+ clearTimeout(timer);
88
+ }
89
+ }
90
+ throw new Error(`fetchHtml(${url}) failed after ${retries + 1} tries: ${lastErr?.message || lastErr}`);
91
+ }
92
+
93
+ /**
94
+ * 抓取一个二进制资源(PDF 等),返回 { url, status, buffer }。
95
+ * 走单独缓存目录 ~/.yaohao/cache/pdf/,按 url sha1 命名。
96
+ */
97
+ export async function fetchBinary(url, opts = {}) {
98
+ const { useCache = true, timeoutMs = 30000 } = opts;
99
+ const pdfDir = path.join(CACHE_DIR, 'binary');
100
+ await fs.mkdir(pdfDir, { recursive: true });
101
+ const file = path.join(pdfDir, crypto.createHash('sha1').update(url).digest('hex') + path.extname(url));
102
+
103
+ if (useCache) {
104
+ try {
105
+ const buf = await fs.readFile(file);
106
+ return { url, status: 200, buffer: buf, fromCache: true };
107
+ } catch {
108
+ /* miss */
109
+ }
110
+ }
111
+
112
+ const ac = new AbortController();
113
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
114
+ try {
115
+ const res = await fetch(url, { headers: DEFAULT_HEADERS, signal: ac.signal });
116
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
117
+ const buf = Buffer.from(await res.arrayBuffer());
118
+ await fs.writeFile(file, buf);
119
+ return { url, status: res.status, buffer: buf, fromCache: false };
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+
125
+ function decode(buf, contentType) {
126
+ let charset = 'utf-8';
127
+ const m = /charset=([\w-]+)/i.exec(contentType);
128
+ if (m) charset = m[1].toLowerCase();
129
+ const head = buf.slice(0, 1024).toString('ascii');
130
+ const metaMatch = /charset=["']?([\w-]+)/i.exec(head);
131
+ if (metaMatch) charset = metaMatch[1].toLowerCase();
132
+
133
+ if (charset === 'utf-8' || charset === 'utf8') {
134
+ return buf.toString('utf8');
135
+ }
136
+ try {
137
+ return new TextDecoder(charset).decode(buf);
138
+ } catch {
139
+ return buf.toString('utf8');
140
+ }
141
+ }
142
+
143
+ function sleep(ms) {
144
+ return new Promise((r) => setTimeout(r, ms));
145
+ }
146
+
147
+ function cacheKey(url) {
148
+ return crypto.createHash('sha1').update(url).digest('hex') + '.json';
149
+ }
150
+
151
+ async function readCache(url, maxAgeMs) {
152
+ try {
153
+ const file = path.join(CACHE_DIR, cacheKey(url));
154
+ const raw = await fs.readFile(file, 'utf8');
155
+ const payload = JSON.parse(raw);
156
+ if (maxAgeMs && payload.savedAt && Date.now() - payload.savedAt > maxAgeMs) {
157
+ return null;
158
+ }
159
+ return payload;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ async function writeCache(url, payload) {
166
+ await fs.mkdir(CACHE_DIR, { recursive: true });
167
+ const file = path.join(CACHE_DIR, cacheKey(url));
168
+ await fs.writeFile(file, JSON.stringify(payload), 'utf8');
169
+ }
@@ -0,0 +1,141 @@
1
+ // 通用 HTML 解析工具:所有城市 source 共用
2
+
3
+ export function stripTags(html) {
4
+ if (!html) return '';
5
+ let s = html.replace(/<script[\s\S]*?<\/script>/gi, '')
6
+ .replace(/<style[\s\S]*?<\/style>/gi, '');
7
+ s = s.replace(/<br\s*\/?>/gi, '\n');
8
+ s = s.replace(/<[^>]+>/g, '');
9
+ s = s.replace(/&nbsp;/g, ' ')
10
+ .replace(/&amp;/g, '&')
11
+ .replace(/&lt;/g, '<')
12
+ .replace(/&gt;/g, '>')
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&ldquo;|&rdquo;/g, '"')
15
+ .replace(/&lsquo;|&rsquo;/g, "'")
16
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
17
+ s = s.replace(/[ \t]+/g, ' ')
18
+ .replace(/\n\s*\n+/g, '\n')
19
+ .trim();
20
+ return s;
21
+ }
22
+
23
+ export function normalizeNumberLike(s) {
24
+ return s
25
+ .replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
26
+ .replace(/[,]/g, ',')
27
+ .replace(/[.]/g, '.')
28
+ .replace(/[%]/g, '%');
29
+ }
30
+
31
+ export function num(s) {
32
+ return Number(String(s).replace(/,/g, ''));
33
+ }
34
+
35
+ export function cnDateToIso(s) {
36
+ const m = /(\d{4})年(\d{1,2})月(\d{1,2})日/.exec(s);
37
+ if (!m) return null;
38
+ return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
39
+ }
40
+
41
+ export function isoFromUrlPath(url) {
42
+ // 从 URL 路径如 /20260511/ 或 /202657/ 抽日期
43
+ const m = /\/(\d{4})(\d{1,2})(\d{1,2})\//.exec(url);
44
+ if (!m) return null;
45
+ return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
46
+ }
47
+
48
+ export function absUrl(href, base) {
49
+ if (!href) return href;
50
+ if (/^https?:\/\//i.test(href)) return href;
51
+ try {
52
+ return new URL(href, base).toString();
53
+ } catch {
54
+ return href;
55
+ }
56
+ }
57
+
58
+ // 标题里的"YYYY 年第 N 期" / "YYYY 年 M 月" 期号
59
+ export function extractPeriod(title) {
60
+ // "2025 年第 2 期" 格式
61
+ let m = /(20\d{2})年(?:第)?\s*([一二三四五六七八九十0-9]+)\s*期/.exec(title);
62
+ if (m) {
63
+ const cnNum = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
64
+ const year = Number(m[1]);
65
+ const rawNo = m[2];
66
+ const no = /^\d+$/.test(rawNo) ? Number(rawNo) : cnNum[rawNo] ?? null;
67
+ if (no != null) return { year, no, label: `${year}年第${no}期`, kind: 'period' };
68
+ }
69
+ // "2026 年 5 月" 格式
70
+ m = /(20\d{2})年\s*([0-9]+)\s*月/.exec(title);
71
+ if (m) {
72
+ const year = Number(m[1]);
73
+ const month = Number(m[2]);
74
+ return { year, month, label: `${year}年${month}月`, kind: 'month' };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // 通用字段抽取:能识别尽量多的关键数字字段,找不到的留 undefined
80
+ export function extractMetricsFromText(rawText) {
81
+ const text = normalizeNumberLike(rawText.replace(/\s+/g, ' '));
82
+ const r = {};
83
+
84
+ // 北京风格:申请有效编码
85
+ let m = /家庭普通小客车指标申请共?计?\s*([\d,]+)\s*个?有效编码/.exec(text);
86
+ if (m) r.familyApplyCount = num(m[1]);
87
+ m = /个人普通小客车指标申请共?计?\s*([\d,]+)\s*个?有效编码/.exec(text);
88
+ if (m) r.personalApplyCount = num(m[1]);
89
+ m = /单位普通小客车指标申请共?计?\s*([\d,]+)\s*家/.exec(text);
90
+ if (m) r.unitApplyCount = num(m[1]);
91
+
92
+ // 北京风格:配置数
93
+ m = /家庭(?:和|与)个人普通小客车指标共?计?\s*([\d,]+)\s*个/.exec(text);
94
+ if (m) r.familyPersonAlloc = num(m[1]);
95
+ m = /配置单位普通小客车指标\s*([\d,]+)\s*个/.exec(text);
96
+ if (m) r.unitAlloc = num(m[1]);
97
+
98
+ // 配置数(PDF 通用:广深杭都用这个表述)
99
+ m = /有效编码总数[::]\s*([\d,]+)/.exec(text);
100
+ if (m) r.validEncodeCount = num(m[1]);
101
+ m = /基数序号总数[::]\s*([\d,]+)/.exec(text);
102
+ if (m) r.baseSeedTotal = num(m[1]);
103
+ m = /指标配置总数[::]\s*([\d,]+)/.exec(text);
104
+ if (m) r.allocTotal = num(m[1]);
105
+ m = /指标配置种子数[::]\s*([\d,]+)/.exec(text);
106
+ if (m) r.randomSeed = num(m[1]);
107
+
108
+ // 通用:配置指标数(广深杭"普通车增量指标 / 普通小汽车增量指标 X 个")
109
+ m = /配置(?:普通车|普通小汽车|增量)?指标\s*([\d,]+)\s*个/.exec(text);
110
+ if (m) r.totalAlloc = num(m[1]);
111
+ m = /(?:个人指标|个人增量指标)\s*([\d,]+)\s*个/.exec(text);
112
+ if (m) r.personalAlloc = num(m[1]);
113
+ m = /(?:单位指标|单位增量指标)\s*([\d,]+)\s*个/.exec(text);
114
+ if (m) r.unitAllocNum = num(m[1]);
115
+
116
+ // 通用:摇号方式 / 竞价方式
117
+ m = /(?:以)?摇号方式配置\s*([\d,]+)\s*个/.exec(text);
118
+ if (m) r.lotteryAlloc = num(m[1]);
119
+ m = /(?:以)?竞价方式配置\s*([\d,]+)\s*个/.exec(text);
120
+ if (m) r.biddingAlloc = num(m[1]);
121
+
122
+ // 中签率(明示)
123
+ m = /个人(?:普通(?:小客车)?指标)?中签率(?:约)?(?:为|是|:|:)?\s*([\d.]+%|1[//]\d+)/.exec(text);
124
+ if (m) r.personalRate = m[1];
125
+ m = /单位(?:普通(?:小客车)?指标)?中签率(?:约)?(?:为|是|:|:)?\s*([\d.]+%|1[//]\d+)/.exec(text);
126
+ if (m) r.unitRate = m[1];
127
+
128
+ // 家庭最低中签积分(北京)
129
+ m = /家庭(?:摇号)?(?:最低)?中签(?:家庭)?(?:积分|分值)(?:为|是|:|:)\s*([\d.]+)/.exec(text);
130
+ if (m) r.familyMinScore = Number(m[1]);
131
+
132
+ // 新能源排队
133
+ m = /新能源(?:小客车)?指标(?:轮候|排队)(?:.*?)(?:约|预计)?\s*([\d.]+)\s*年/.exec(text);
134
+ if (m) r.nevWaitYears = Number(m[1]);
135
+
136
+ // 失信被执行人
137
+ m = /共有\s*([\d,]+)\s*个失信被执行人/.exec(text);
138
+ if (m) r.blackListCount = num(m[1]);
139
+
140
+ return r;
141
+ }
@@ -0,0 +1,30 @@
1
+ // 通用标题分类:从公告标题判断类型
2
+ // 不同城市的公告分类大同小异:摇号结果 / 配置数量 / 资格审核 / 政策处罚 / 阶梯统计 等
3
+
4
+ export function classifyTitle(t) {
5
+ // 处罚类
6
+ if (/买卖|出租|承租|出借|借用/.test(t)) return 'penalty';
7
+ // 摇号结果
8
+ if (/摇号(?:配置)?结果|摇号结果公告|配置结果/.test(t)) return 'result';
9
+ // 配置数量通告
10
+ if (/配置数量|增量指标配置数量|配置工作有关事项/.test(t)) return 'config_notice';
11
+ // 申请审核结果
12
+ if (/申请审核结果和配置工作/.test(t)) return 'config_notice';
13
+ // 资格审核结果
14
+ if (/资格审核结果/.test(t)) return 'qualify_review';
15
+ // 摇号公告(待摇号)
16
+ if (/摇号公告|增量指标摇号公告/.test(t)) return 'lottery_notice';
17
+ // 竞价
18
+ if (/竞价(?:情况|公告|的通告)?/.test(t)) return 'bidding';
19
+ // 阶梯分布统计
20
+ if (/阶梯分布统计|阶梯摇号/.test(t)) return 'tier_stats';
21
+ // 家庭核查(北京)
22
+ if (/亲属关系和婚姻状况核查/.test(t)) return 'family_check';
23
+ // 年度配额
24
+ if (/指标配额|指标调控管理办法/.test(t)) return 'quota';
25
+ // FAQ / 解读
26
+ if (/十问十答|温馨提示|解读/.test(t)) return 'faq';
27
+ // 日历
28
+ if (/竞价日历|摇号日历/.test(t)) return 'calendar_notice';
29
+ return 'other';
30
+ }
@@ -0,0 +1,65 @@
1
+ // 北京小客车摇号关键日历(按往年节奏推断,年度更新由发版承担)
2
+ // 上半年申请窗口: 1/1 - 3/8
3
+ // 上半年资格审核结果公布: 4 月上旬
4
+ // 上半年摇号: 4 月下旬
5
+ // 下半年申请窗口: 8/1 - 10/8
6
+ // 下半年资格审核结果公布: 12 月中旬
7
+ // 下半年摇号: 12 月下旬
8
+
9
+ const SCHEDULE = {
10
+ 2026: [
11
+ { event: '上半年申请窗口开放', start: '2026-01-01', end: '2026-03-08' },
12
+ { event: '上半年资格审核结果公布', date: '2026-04-08' },
13
+ { event: '上半年摇号日', date: '2026-04-26' },
14
+ { event: '下半年申请窗口开放', start: '2026-08-01', end: '2026-10-08' },
15
+ { event: '下半年资格审核结果公布', date: '2026-12-08' },
16
+ { event: '下半年摇号日', date: '2026-12-26' },
17
+ ],
18
+ };
19
+
20
+ function daysBetween(from, to) {
21
+ const a = new Date(from); a.setHours(0, 0, 0, 0);
22
+ const b = new Date(to); b.setHours(0, 0, 0, 0);
23
+ return Math.round((b - a) / (1000 * 60 * 60 * 24));
24
+ }
25
+
26
+ function formatEvent(e, today) {
27
+ const range = e.date ? e.date : `${e.start} 至 ${e.end}`;
28
+ let status;
29
+ if (e.date) {
30
+ const days = daysBetween(today, e.date);
31
+ if (days > 0) status = `还有 ${days} 天`;
32
+ else if (days === 0) status = '就在今天';
33
+ else status = `已过 ${-days} 天`;
34
+ } else {
35
+ const dStart = daysBetween(today, e.start);
36
+ const dEnd = daysBetween(today, e.end);
37
+ if (dStart > 0) status = `${dStart} 天后开放`;
38
+ else if (dEnd >= 0) status = `进行中,剩 ${dEnd} 天`;
39
+ else status = `已结束 ${-dEnd} 天`;
40
+ }
41
+ return { range, event: e.event, status };
42
+ }
43
+
44
+ export function getCalendar(year) {
45
+ const y = year || new Date().getFullYear();
46
+ const schedule = SCHEDULE[y];
47
+ if (!schedule) {
48
+ return {
49
+ year: y,
50
+ available: Object.keys(SCHEDULE).map(Number),
51
+ schedule: [],
52
+ lines: [`暂无 ${y} 年日历,目前支持: ${Object.keys(SCHEDULE).join(', ')}`],
53
+ };
54
+ }
55
+ const today = new Date();
56
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
57
+ const items = schedule.map((e) => formatEvent(e, today));
58
+ const lines = [`北京小客车摇号 ${y} 年关键日历(今天 ${todayStr}):`, ''];
59
+ for (const it of items) {
60
+ lines.push(` ${it.range.padEnd(28)} ${it.event} [${it.status}]`);
61
+ }
62
+ lines.push('');
63
+ lines.push('注:日期按往年节奏推断,以官方公告为准。建议配合 `yaohao watch window` 订阅窗口开放推送。');
64
+ return { year: y, today: todayStr, schedule: items, lines };
65
+ }
@@ -0,0 +1,8 @@
1
+ // 北京小客车摇号系统 URL 常量
2
+ export const SYSTEM_URL = 'https://xkczb.jtw.beijing.gov.cn/';
3
+
4
+ export const URLS = {
5
+ announceList: 'https://xkczb.jtw.beijing.gov.cn/jggb/index.html',
6
+ policyList: 'https://xkczb.jtw.beijing.gov.cn/xwzz/index.html',
7
+ guideList: 'https://xkczb.jtw.beijing.gov.cn/bszn/index.html',
8
+ };