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
+ // 北京摇号公告抓取 + 形势数据汇总
2
+ import { fetchHtml } from './crawl.js';
3
+ import { parseListPage, parseDetailPage } from './parse.js';
4
+ import { extractPdfMetrics } from './pdfExtract.js';
5
+ import { URLS } from './constants.js';
6
+
7
+ // market 命令:抓最近一期 config_notice + 两个 result PDF,输出形势数据
8
+ export async function extractMarketMetrics(opts = {}) {
9
+ const useCache = opts.cache !== false;
10
+ const withPdf = opts.pdf !== false;
11
+
12
+ const listPage = await fetchHtml(URLS.announceList, {
13
+ useCache,
14
+ maxCacheAgeMs: 5 * 60 * 1000,
15
+ });
16
+ const items = parseListPage(listPage.html, URLS.announceList);
17
+
18
+ const cfg = items.find((i) => i.kind === 'config_notice');
19
+ if (!cfg) {
20
+ return {
21
+ ok: false,
22
+ error: '未找到"申请审核结果和配置工作"通告',
23
+ lines: ['错误:未找到"申请审核结果和配置工作"通告'],
24
+ };
25
+ }
26
+
27
+ const cfgDetail = parseDetailPage(
28
+ (await fetchHtml(cfg.url, { useCache })).html,
29
+ cfg.url,
30
+ );
31
+ const cfgM = cfgDetail.metrics;
32
+
33
+ const periodLabel = cfg.period?.label;
34
+ const results = items.filter(
35
+ (i) => i.kind === 'result' && i.period?.label === periodLabel,
36
+ );
37
+ const familyPersonResult = results.find((r) => /家庭和个人/.test(r.title));
38
+ const unitResult = results.find((r) => /单位/.test(r.title));
39
+
40
+ let familyPersonPdf = null;
41
+ let unitPdf = null;
42
+ let familyPersonPdfM = {};
43
+ let unitPdfM = {};
44
+
45
+ if (familyPersonResult) {
46
+ const d = parseDetailPage(
47
+ (await fetchHtml(familyPersonResult.url, { useCache })).html,
48
+ familyPersonResult.url,
49
+ );
50
+ familyPersonPdf = d.attachments.find((a) => /\.pdf$/i.test(a.url))?.url ?? null;
51
+ }
52
+ if (unitResult) {
53
+ const d = parseDetailPage(
54
+ (await fetchHtml(unitResult.url, { useCache })).html,
55
+ unitResult.url,
56
+ );
57
+ unitPdf = d.attachments.find((a) => /\.pdf$/i.test(a.url))?.url ?? null;
58
+ }
59
+
60
+ if (withPdf) {
61
+ if (familyPersonPdf) {
62
+ try { familyPersonPdfM = await extractPdfMetrics(familyPersonPdf); }
63
+ catch (err) { familyPersonPdfM = { _error: err.message }; }
64
+ }
65
+ if (unitPdf) {
66
+ try { unitPdfM = await extractPdfMetrics(unitPdf); }
67
+ catch (err) { unitPdfM = { _error: err.message }; }
68
+ }
69
+ }
70
+
71
+ const unitAlloc = unitPdfM.allocTotal ?? cfgM.unitAlloc ?? null;
72
+ const unitValid = unitPdfM.validEncodeCount ?? null;
73
+ const unitRate = unitAlloc != null && unitValid
74
+ ? `${(unitAlloc / unitValid * 100).toFixed(3)}% (${unitAlloc.toLocaleString()}/${unitValid.toLocaleString()})`
75
+ : null;
76
+
77
+ const fpAlloc = familyPersonPdfM.allocTotal ?? cfgM.familyPersonAlloc ?? null;
78
+ const fpApply = (cfgM.familyApplyCount ?? 0) + (cfgM.personalApplyCount ?? 0);
79
+ const fpRate = fpAlloc != null && fpApply > 0
80
+ ? `${(fpAlloc / fpApply * 100).toFixed(3)}% (${fpAlloc.toLocaleString()}/${fpApply.toLocaleString()}) 理论值,实际家庭按积分确定中签`
81
+ : null;
82
+
83
+ const data = {
84
+ period: periodLabel,
85
+ source: cfg.url,
86
+ publishDate: cfgDetail.dateIso,
87
+ apply: {
88
+ family: cfgM.familyApplyCount ?? null,
89
+ personal: cfgM.personalApplyCount ?? null,
90
+ unit: cfgM.unitApplyCount ?? null,
91
+ },
92
+ alloc: {
93
+ familyAndPersonal: fpAlloc,
94
+ unit: unitAlloc,
95
+ },
96
+ rate: {
97
+ familyAndPersonal: fpRate,
98
+ unit: unitRate,
99
+ },
100
+ pdfMetrics: { familyPerson: familyPersonPdfM, unit: unitPdfM },
101
+ attachments: { familyPerson: familyPersonPdf, unit: unitPdf },
102
+ familyMinScore: 'N/A (官方未在结构化字段中公布)',
103
+ nev: { waitYears: cfgM.nevWaitYears ?? null },
104
+ blackListCount: cfgM.blackListCount ?? null,
105
+ };
106
+
107
+ const lines = [];
108
+ lines.push(`北京小客车摇号 ${data.period || '当期'} 形势播报`);
109
+ lines.push(`公告: ${data.source}`);
110
+ lines.push(`发布: ${data.publishDate || '未知'}`);
111
+ lines.push('');
112
+ lines.push('【有效申请编码】(来源:HTML 通告)');
113
+ lines.push(` 家庭: ${fmt(data.apply.family)}`);
114
+ lines.push(` 个人: ${fmt(data.apply.personal)}`);
115
+ lines.push(` 单位: ${fmt(data.apply.unit)} 家`);
116
+ if (unitValid) {
117
+ lines.push(` 单位(PDF 有效编码总数,按企业规模/纳税倍率): ${fmt(unitValid)}`);
118
+ }
119
+ lines.push('');
120
+ lines.push('【本期配置指标】');
121
+ lines.push(` 家庭+个人(同池): ${fmt(data.alloc.familyAndPersonal)}`);
122
+ lines.push(` 单位: ${fmt(data.alloc.unit)}`);
123
+ lines.push('');
124
+ lines.push('【中签率】');
125
+ lines.push(` 家庭+个人: ${data.rate.familyAndPersonal || 'N/A'}`);
126
+ lines.push(` 单位: ${data.rate.unit || 'N/A'}`);
127
+ lines.push('');
128
+ lines.push('【家庭摇号最低中签积分】');
129
+ lines.push(` ${data.familyMinScore}`);
130
+ if (data.nev.waitYears != null) {
131
+ lines.push('');
132
+ lines.push(`【新能源轮候】预计 ${data.nev.waitYears} 年`);
133
+ }
134
+ if (data.blackListCount != null) {
135
+ lines.push('');
136
+ lines.push(`【失信限制】${fmt(data.blackListCount)} 人`);
137
+ }
138
+ if (!withPdf) {
139
+ lines.push('');
140
+ lines.push('(已跳过 PDF 解析,中签率使用 HTML 字段近似)');
141
+ }
142
+ return { ok: true, data, lines };
143
+ }
144
+
145
+ // watch 命令:fetch 候选 items 列表
146
+ export const watchTargets = {
147
+ result: {
148
+ desc: '开奖结果',
149
+ async fetch(opts = {}) {
150
+ const useCache = opts.cache !== false;
151
+ const page = await fetchHtml(URLS.announceList, {
152
+ useCache,
153
+ maxCacheAgeMs: 5 * 60 * 1000,
154
+ });
155
+ const items = parseListPage(page.html, URLS.announceList);
156
+ return items.filter((i) => i.kind === 'result' || i.kind === 'config_notice');
157
+ },
158
+ },
159
+ policy: {
160
+ desc: '政策变化',
161
+ async fetch(opts = {}) {
162
+ const useCache = opts.cache !== false;
163
+ const page = await fetchHtml(URLS.policyList, {
164
+ useCache,
165
+ maxCacheAgeMs: 5 * 60 * 1000,
166
+ });
167
+ return parseListPage(page.html, URLS.policyList);
168
+ },
169
+ },
170
+ window: {
171
+ desc: '申请窗口开放',
172
+ async fetch(opts = {}) {
173
+ const useCache = opts.cache !== false;
174
+ const [a, b] = await Promise.all([
175
+ fetchHtml(URLS.announceList, { useCache, maxCacheAgeMs: 5 * 60 * 1000 }),
176
+ fetchHtml(URLS.guideList, { useCache, maxCacheAgeMs: 5 * 60 * 1000 }),
177
+ ]);
178
+ const aItems = parseListPage(a.html, URLS.announceList)
179
+ .filter((i) => i.kind === 'quota' || i.kind === 'qualify_review');
180
+ const bItems = parseListPage(b.html, URLS.guideList);
181
+ return [...aItems, ...bItems];
182
+ },
183
+ },
184
+ };
185
+
186
+ function fmt(n) {
187
+ if (n == null) return '未知';
188
+ if (typeof n === 'number') return n.toLocaleString('zh-CN');
189
+ return String(n);
190
+ }
@@ -0,0 +1,54 @@
1
+ // 广州摇号关键日历:月度循环。
2
+ // 申请窗口截至每月 12 日 24 时;摇号日为每月 25 日(遇非工作日顺延)
3
+
4
+ import { MONTHLY_RHYTHM } from './constants.js';
5
+
6
+ function daysBetween(from, to) {
7
+ const a = new Date(from); a.setHours(0, 0, 0, 0);
8
+ const b = new Date(to); b.setHours(0, 0, 0, 0);
9
+ return Math.round((b - a) / (1000 * 60 * 60 * 24));
10
+ }
11
+
12
+ function pad(n) { return String(n).padStart(2, '0'); }
13
+
14
+ function statusOf(today, dateStr) {
15
+ const days = daysBetween(today, dateStr);
16
+ if (days > 0) return `还有 ${days} 天`;
17
+ if (days === 0) return '就在今天';
18
+ return `已过 ${-days} 天`;
19
+ }
20
+
21
+ function buildMonthSchedule(year) {
22
+ const months = [];
23
+ for (let m = 1; m <= 12; m++) {
24
+ const deadline = `${year}-${pad(m)}-${pad(MONTHLY_RHYTHM.applyDeadlineDay)}`;
25
+ const lottery = `${year}-${pad(m)}-${pad(MONTHLY_RHYTHM.lotteryDay)}`;
26
+ months.push({ month: m, deadline, lottery });
27
+ }
28
+ return months;
29
+ }
30
+
31
+ export function getCalendar(year) {
32
+ const y = year || new Date().getFullYear();
33
+ const today = new Date();
34
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
35
+ const months = buildMonthSchedule(y);
36
+
37
+ // 只显示当前及未来 4 个月(避免冗余)
38
+ const currentMonth = today.getFullYear() === y ? today.getMonth() + 1 : 1;
39
+ const upcoming = months.filter((m) => m.month >= currentMonth).slice(0, 4);
40
+
41
+ const items = upcoming.flatMap((m) => [
42
+ { event: `${m.month} 月申请截止`, date: m.deadline, status: statusOf(today, m.deadline) },
43
+ { event: `${m.month} 月摇号日`, date: m.lottery, status: statusOf(today, m.lottery) },
44
+ ]);
45
+
46
+ const lines = [`广州中小客车摇号 ${y} 年关键日历(今天 ${todayStr}):`, ''];
47
+ for (const it of items) {
48
+ lines.push(` ${it.date.padEnd(28)} ${it.event} [${it.status}]`);
49
+ }
50
+ lines.push('');
51
+ lines.push('规则:申请窗口截至每月 12 日 24 时,摇号日为每月 25 日(遇非工作日顺延,以官方公告为准)');
52
+ lines.push('提示:累计参加摇号 72 次以上的个人可直接申领普通车增量指标,不占配置额度');
53
+ return { year: y, today: todayStr, schedule: items, lines };
54
+ }
@@ -0,0 +1,16 @@
1
+ // 广州市中小客车指标调控管理信息系统
2
+ export const SYSTEM_URL = 'https://jtzl.jtj.gz.gov.cn/';
3
+
4
+ export const URLS = {
5
+ system: 'https://jtzl.jtj.gz.gov.cn/',
6
+ // 公告/政策由广州市交通运输局门户提供
7
+ noticeList: 'https://jtj.gz.gov.cn/gkmlpt/index',
8
+ // 调控办与摇号相关栏目(通过门户搜索的二级路径)
9
+ noticeIndex: 'https://jtj.gz.gov.cn/',
10
+ };
11
+
12
+ // 广州摇号节奏:申请窗口截至每月 12 日 24 时;摇号日为每月 25 日(遇非工作日顺延)
13
+ export const MONTHLY_RHYTHM = {
14
+ applyDeadlineDay: 12,
15
+ lotteryDay: 25,
16
+ };
@@ -0,0 +1,88 @@
1
+ import { input, select, confirm } from '@inquirer/prompts';
2
+
3
+ // 广州市中小客车增量指标资格规则(参考广州本地宝 2025 摇号申请指引)
4
+ // 简化版,复杂情况以《广州市小客车指标调控管理办法》为准
5
+
6
+ const PERSON_TYPES = {
7
+ GZHJ: '广州市户籍人员',
8
+ WJZJ: '驻穗武警 / 现役军人',
9
+ GANG_AO_TAI: '港澳台居民 / 持外国人永久居留证 / 华侨',
10
+ ZZZ: '持有效《广东省居住证》(连续居住 + 连续社保)',
11
+ };
12
+
13
+ export async function checkEligibility() {
14
+ try {
15
+ const personType = await select({
16
+ message: '户籍/居住类型?',
17
+ choices: [
18
+ ...Object.entries(PERSON_TYPES).map(([k, v]) => ({ name: v, value: k })),
19
+ { name: '以上都不是', value: 'NONE' },
20
+ ],
21
+ });
22
+ if (personType === 'NONE') {
23
+ return result(false, '不符合广州摇号户籍/居住要求', { reason: 'huji_not_match' });
24
+ }
25
+
26
+ const age = Number(await input({
27
+ message: '年龄?',
28
+ default: '30',
29
+ validate: (v) => /^\d+$/.test(v) && Number(v) > 0 || '请输入正整数',
30
+ }));
31
+ if (age < 18) return result(false, '需年满 18 周岁', { age });
32
+
33
+ const hasLicense = await confirm({ message: '是否持有有效机动车驾驶证?', default: true });
34
+ if (!hasLicense) return result(false, '需持有有效机动车驾驶证');
35
+
36
+ const hasYueAPlate = await confirm({ message: '名下是否已有粤 A 牌小客车?', default: false });
37
+ if (hasYueAPlate) return result(false, '名下已有粤 A 牌小客车');
38
+
39
+ const hasIndicator = await confirm({ message: '是否已持有有效的增量指标或指标确认通知书?', default: false });
40
+ if (hasIndicator) return result(false, '已持有有效指标,不能重复申请');
41
+
42
+ // 非穗籍走居住证 + 社保
43
+ if (personType === 'ZZZ') {
44
+ const sbYears = Number(await input({
45
+ message: '在穗连续缴纳社保的月数(断缴会清零,需中等社保险种)?',
46
+ default: '0',
47
+ validate: (v) => /^\d+(\.\d+)?$/.test(v) || '请输入数字',
48
+ }));
49
+ if (sbYears < 24) {
50
+ return result(false, `非穗籍连续社保需满 24 个月,差 ${(24 - sbYears).toFixed(0)} 个月`, { sbYears });
51
+ }
52
+ }
53
+
54
+ const lines = ['符合广州摇号申请条件:'];
55
+ lines.push(' ✓ 个人普通车增量指标(摇号或竞价)');
56
+ lines.push(' ✓ 个人节能车增量指标(无额度限制)');
57
+ lines.push(' ✓ 个人新能源车增量指标(无额度限制)');
58
+ lines.push('');
59
+ lines.push('下一步:在每月 12 日 24 时前登录 jtzl.jtj.gz.gov.cn 提交申请。');
60
+ lines.push('提示:本判定为简化版,复杂情况以《广州市小客车指标调控管理办法》为准。');
61
+
62
+ return result(true, lines.join('\n'), {
63
+ personType,
64
+ personTypeLabel: PERSON_TYPES[personType],
65
+ age,
66
+ eligibility: {
67
+ regular_lottery: true,
68
+ regular_bidding: true,
69
+ energy_saving: true,
70
+ new_energy: true,
71
+ },
72
+ });
73
+ } catch (err) {
74
+ if (err && err.name === 'ExitPromptError') {
75
+ return { pass: null, cancelled: true, lines: ['已取消'] };
76
+ }
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ function result(pass, message, extra = {}) {
82
+ return {
83
+ pass,
84
+ message,
85
+ ...extra,
86
+ lines: [pass ? message : `不符合:${message}`],
87
+ };
88
+ }
@@ -0,0 +1,22 @@
1
+ // 广州中小客车摇号 source
2
+ import { SYSTEM_URL } from './constants.js';
3
+ import { getCalendar } from './calendar.js';
4
+ import { checkEligibility } from './eligibility.js';
5
+ import { extractMarketMetrics, watchTargets } from './service.js';
6
+
7
+ export const meta = {
8
+ name: 'guangzhou',
9
+ label: '广州',
10
+ systemUrl: SYSTEM_URL,
11
+ applyTypes: ['person', 'unit'],
12
+ regTypes: ['普通指标', '节能车', '新能源'],
13
+ supported: true,
14
+ notes: '形势播报和 watch 爬虫待补全,calendar 和 eligibility 已可用',
15
+ };
16
+
17
+ export {
18
+ getCalendar,
19
+ checkEligibility,
20
+ extractMarketMetrics,
21
+ watchTargets,
22
+ };
@@ -0,0 +1,61 @@
1
+ // 广州公告页解析:jtzl.jtj.gz.gov.cn 首页含 <dl><dt><a><dd>YYYY-MM-DD</dd> 结构
2
+
3
+ import {
4
+ stripTags,
5
+ extractMetricsFromText as commonMetrics,
6
+ cnDateToIso,
7
+ isoFromUrlPath,
8
+ absUrl,
9
+ extractPeriod,
10
+ } from '../_shared/parseUtils.js';
11
+ import { classifyTitle } from '../_shared/titleClassify.js';
12
+
13
+ export function parseListPage(html, baseUrl) {
14
+ const items = [];
15
+ const dlRe = /<dl[^>]*>([\s\S]*?)<\/dl>/gi;
16
+ for (const m of html.matchAll(dlRe)) {
17
+ const block = m[1];
18
+ const aMatch = /<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i.exec(block);
19
+ const dateMatch = /<dd[^>]*>\s*(\d{4}-\d{1,2}-\d{1,2})\s*<\/dd>/i.exec(block);
20
+ if (!aMatch) continue;
21
+ const href = aMatch[1];
22
+ if (!/\/index\/gbl\//i.test(href)) continue; // 只要公告页
23
+ const title = stripTags(aMatch[2]);
24
+ const date = dateMatch ? dateMatch[1] : (isoFromUrlPath(href) || null);
25
+ items.push({
26
+ title,
27
+ date,
28
+ url: absUrl(href, baseUrl),
29
+ period: extractPeriod(title),
30
+ kind: classifyTitle(title),
31
+ });
32
+ }
33
+ return items;
34
+ }
35
+
36
+ export function parseDetailPage(html, url) {
37
+ // 广州详情页正文区域大致用 .article 或 .content 类名
38
+ let scope = html;
39
+ const articleMatch =
40
+ /<div[^>]+class="[^"]*(?:article|content|TRS_Editor)[^"]*"[^>]*>([\s\S]*?)<\/div>/i.exec(html);
41
+ if (articleMatch) scope = articleMatch[1];
42
+
43
+ const titleM = /<h1[^>]*>([\s\S]*?)<\/h1>|<h2[^>]*>([\s\S]*?)<\/h2>/i.exec(html);
44
+ const title = titleM ? stripTags(titleM[1] || titleM[2]) : null;
45
+
46
+ const dateText = (/发布(?:日期|时间)[::]\s*([0-9-/]+|\d{4}年\d{1,2}月\d{1,2}日)/.exec(html) || [])[1] || null;
47
+ const dateIso = dateText
48
+ ? (cnDateToIso(dateText) || (/^\d{4}-\d{1,2}-\d{1,2}/.test(dateText) ? dateText.replace(/\//g, '-') : null))
49
+ : isoFromUrlPath(url);
50
+
51
+ const attachments = [];
52
+ const aRe = /<a[^>]+href="([^"]+\.(?:pdf|docx?|xlsx?|zip))"[^>]*>([\s\S]*?)<\/a>/gi;
53
+ for (const m of html.matchAll(aRe)) {
54
+ attachments.push({ url: absUrl(m[1], url), name: stripTags(m[2]).trim() });
55
+ }
56
+
57
+ const bodyText = stripTags(scope);
58
+ const metrics = commonMetrics(bodyText);
59
+
60
+ return { url, title, dateText, dateIso, bodyText, attachments, metrics };
61
+ }
@@ -0,0 +1,126 @@
1
+ import { fetchHtml } from '../_shared/crawl.js';
2
+ import { parseListPage, parseDetailPage } from './parse.js';
3
+ import { URLS, SYSTEM_URL } from './constants.js';
4
+
5
+ const MAX_CACHE_AGE = 5 * 60 * 1000;
6
+
7
+ async function fetchList(opts = {}) {
8
+ const useCache = opts.cache !== false;
9
+ const page = await fetchHtml(URLS.system, { useCache, maxCacheAgeMs: MAX_CACHE_AGE });
10
+ return parseListPage(page.html, URLS.system);
11
+ }
12
+
13
+ export async function extractMarketMetrics(opts = {}) {
14
+ try {
15
+ const useCache = opts.cache !== false;
16
+ const items = await fetchList(opts);
17
+
18
+ // 找最近一期"配置数量的通告"
19
+ const cfg = items.find((i) => i.kind === 'config_notice' && /配置数量/.test(i.title));
20
+ // 找最近一期"配置结果"
21
+ const result = items.find((i) => i.kind === 'result');
22
+ // 找最近一期"阶梯统计"
23
+ const tier = items.find((i) => i.kind === 'tier_stats');
24
+
25
+ const detailsToParse = [cfg, result, tier].filter(Boolean);
26
+ const detailMetrics = {};
27
+ for (const it of detailsToParse) {
28
+ try {
29
+ const page = await fetchHtml(it.url, { useCache });
30
+ const d = parseDetailPage(page.html, it.url);
31
+ detailMetrics[it.kind] = { ...d.metrics, title: d.title, url: it.url, date: it.date };
32
+ } catch {}
33
+ }
34
+
35
+ const cfgM = detailMetrics.config_notice || {};
36
+ const resM = detailMetrics.result || {};
37
+
38
+ const totalAlloc = cfgM.totalAlloc ?? resM.totalAlloc ?? null;
39
+ const personalAlloc = cfgM.personalAlloc ?? resM.personalAlloc ?? null;
40
+ const unitAlloc = cfgM.unitAllocNum ?? resM.unitAllocNum ?? null;
41
+ const lotteryAlloc = cfgM.lotteryAlloc ?? resM.lotteryAlloc ?? null;
42
+ const biddingAlloc = cfgM.biddingAlloc ?? resM.biddingAlloc ?? null;
43
+
44
+ const data = {
45
+ city: 'guangzhou',
46
+ period: cfg?.period?.label || result?.period?.label || null,
47
+ source: cfg?.url || result?.url || SYSTEM_URL,
48
+ latestConfig: cfg ? { date: cfg.date, title: cfg.title, url: cfg.url } : null,
49
+ latestResult: result ? { date: result.date, title: result.title, url: result.url } : null,
50
+ latestTier: tier ? { date: tier.date, title: tier.title, url: tier.url } : null,
51
+ alloc: { total: totalAlloc, personal: personalAlloc, unit: unitAlloc, lottery: lotteryAlloc, bidding: biddingAlloc },
52
+ details: detailMetrics,
53
+ };
54
+
55
+ const lines = [];
56
+ lines.push(`广州中小客车摇号 ${data.period || '当期'} 形势播报`);
57
+ lines.push(`数据来源: ${SYSTEM_URL}`);
58
+ lines.push('');
59
+ if (cfg) {
60
+ lines.push(`【最新配置数量通告】${cfg.date}`);
61
+ lines.push(` ${cfg.title}`);
62
+ lines.push(` ${cfg.url}`);
63
+ }
64
+ if (result) {
65
+ lines.push('');
66
+ lines.push(`【最新摇号配置结果】${result.date}`);
67
+ lines.push(` ${result.title}`);
68
+ lines.push(` ${result.url}`);
69
+ }
70
+ if (tier) {
71
+ lines.push('');
72
+ lines.push(`【最新阶梯分布统计】${tier.date}`);
73
+ lines.push(` ${tier.title}`);
74
+ lines.push(` ${tier.url}`);
75
+ }
76
+ if (totalAlloc || personalAlloc || unitAlloc || lotteryAlloc || biddingAlloc) {
77
+ lines.push('');
78
+ lines.push('【本期配置指标】');
79
+ if (totalAlloc) lines.push(` 总配置: ${fmt(totalAlloc)}`);
80
+ if (personalAlloc) lines.push(` 个人: ${fmt(personalAlloc)}`);
81
+ if (unitAlloc) lines.push(` 单位: ${fmt(unitAlloc)}`);
82
+ if (lotteryAlloc) lines.push(` 其中摇号: ${fmt(lotteryAlloc)}`);
83
+ if (biddingAlloc) lines.push(` 其中竞价: ${fmt(biddingAlloc)}`);
84
+ }
85
+ lines.push('');
86
+ lines.push('提示:广州按月配置;累计摇号 72 次以上可直接申领(不占额度)');
87
+ lines.push('完整中签率/具体编码需查询单篇公告全文');
88
+ return { ok: true, data, lines };
89
+ } catch (err) {
90
+ return {
91
+ ok: false,
92
+ error: `广州数据抓取失败: ${err.message}`,
93
+ lines: [`错误: ${err.message}`, '', `请直接访问 ${SYSTEM_URL}`],
94
+ };
95
+ }
96
+ }
97
+
98
+ export const watchTargets = {
99
+ result: {
100
+ desc: '广州摇号结果',
101
+ async fetch(opts = {}) {
102
+ const items = await fetchList(opts);
103
+ return items.filter((i) => i.kind === 'result' || i.kind === 'config_notice');
104
+ },
105
+ },
106
+ policy: {
107
+ desc: '广州政策变化',
108
+ async fetch(opts = {}) {
109
+ const items = await fetchList(opts);
110
+ return items.filter((i) => i.kind === 'quota' || i.kind === 'faq' || i.kind === 'penalty');
111
+ },
112
+ },
113
+ window: {
114
+ desc: '广州申请窗口/资格审核',
115
+ async fetch(opts = {}) {
116
+ const items = await fetchList(opts);
117
+ return items.filter((i) => i.kind === 'qualify_review' || i.kind === 'tier_stats' || i.kind === 'lottery_notice');
118
+ },
119
+ },
120
+ };
121
+
122
+ function fmt(n) {
123
+ if (n == null) return '未知';
124
+ if (typeof n === 'number') return n.toLocaleString('zh-CN');
125
+ return String(n);
126
+ }
@@ -0,0 +1,60 @@
1
+ // 杭州摇号关键日历:月度循环 + 阶梯摇号特殊期
2
+ // 常规:每月 1-8 日申报、23 日审核结果、26 日摇号
3
+ // 2026 阶梯摇号 J1 期:申请 2025-12-15 至 2026-01-14,摇号 2026-01-30
4
+
5
+ import { MONTHLY_RHYTHM } from './constants.js';
6
+
7
+ function daysBetween(from, to) {
8
+ const a = new Date(from); a.setHours(0, 0, 0, 0);
9
+ const b = new Date(to); b.setHours(0, 0, 0, 0);
10
+ return Math.round((b - a) / (1000 * 60 * 60 * 24));
11
+ }
12
+ function pad(n) { return String(n).padStart(2, '0'); }
13
+ function statusOf(today, dateStr) {
14
+ const days = daysBetween(today, dateStr);
15
+ if (days > 0) return `还有 ${days} 天`;
16
+ if (days === 0) return '就在今天';
17
+ return `已过 ${-days} 天`;
18
+ }
19
+
20
+ const TIERED_LOTTERY = {
21
+ 2026: [{ name: '2026J1 期个人阶梯摇号', applyStart: '2025-12-15', applyEnd: '2026-01-14', lottery: '2026-01-30' }],
22
+ };
23
+
24
+ export function getCalendar(year) {
25
+ const y = year || new Date().getFullYear();
26
+ const today = new Date();
27
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
28
+ const currentMonth = today.getFullYear() === y ? today.getMonth() + 1 : 1;
29
+
30
+ const items = [];
31
+ // 常规月度日历
32
+ for (let m = currentMonth; m <= Math.min(currentMonth + 3, 12); m++) {
33
+ const apply = `${y}-${pad(m)}-${pad(MONTHLY_RHYTHM.applyEndDay)}`;
34
+ const audit = `${y}-${pad(m)}-${pad(MONTHLY_RHYTHM.auditDay)}`;
35
+ const lottery = `${y}-${pad(m)}-${pad(MONTHLY_RHYTHM.lotteryDay)}`;
36
+ items.push({ event: `${m} 月申报截止`, date: apply, status: statusOf(today, apply) });
37
+ items.push({ event: `${m} 月资格审核结果`, date: audit, status: statusOf(today, audit) });
38
+ items.push({ event: `${m} 月摇号日`, date: lottery, status: statusOf(today, lottery) });
39
+ }
40
+ // 阶梯摇号
41
+ const tiered = TIERED_LOTTERY[y] || [];
42
+ for (const t of tiered) {
43
+ items.push({ event: `${t.name}(申报)`, date: `${t.applyStart} 至 ${t.applyEnd}`, status: 'see below' });
44
+ items.push({ event: `${t.name}(摇号)`, date: t.lottery, status: statusOf(today, t.lottery) });
45
+ }
46
+
47
+ const lines = [`杭州小客车摇号 ${y} 年关键日历(今天 ${todayStr}):`, ''];
48
+ for (const it of items) {
49
+ if (it.status === 'see below') {
50
+ lines.push(` ${it.date.padEnd(28)} ${it.event}`);
51
+ } else {
52
+ lines.push(` ${String(it.date).padEnd(28)} ${it.event} [${it.status}]`);
53
+ }
54
+ }
55
+ lines.push('');
56
+ lines.push('规则:每月 1-8 日申报、23 日左右审核结果、26 日摇号(遇非工作日顺延)');
57
+ lines.push('阶梯摇号:累计摇号 24 次以上可参与,每年举办一次');
58
+ lines.push('业务已迁移至"浙里办"APP / 浙江政务服务网');
59
+ return { year: y, today: todayStr, schedule: items, lines };
60
+ }
@@ -0,0 +1,16 @@
1
+ // 杭州市小客车总量调控管理信息系统
2
+ export const SYSTEM_URL = 'https://hzxkctk.cn/';
3
+
4
+ export const URLS = {
5
+ system: 'https://hzxkctk.cn/',
6
+ tzggList: 'https://hzxkctk.cn/tzgg/',
7
+ };
8
+
9
+ // 杭州摇号节奏:常规月度摇号在 26 日;申报期为每月 1-8 日(具体每月公告);
10
+ // 阶梯摇号每年一次(如 2026J1 期 2026-01-30,申请期 2025-12-15 至 2026-01-14)
11
+ export const MONTHLY_RHYTHM = {
12
+ applyStartDay: 1,
13
+ applyEndDay: 8,
14
+ auditDay: 23,
15
+ lotteryDay: 26,
16
+ };