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 { input, select, confirm } from '@inquirer/prompts';
2
+
3
+ // 杭州小客车增量指标资格规则(参考杭州市小客车指标管理办法)
4
+ // 简化版,复杂情况以官方说明为准
5
+
6
+ const PERSON_TYPES = {
7
+ HZHJ: '杭州市户籍居民',
8
+ XLZG: '驻杭部队现役军人或现役武警',
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, '不符合杭州摇号户籍/居住要求');
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 hasZheAPlate = await confirm({ message: '名下是否已有浙 A 牌小客车?', default: false });
37
+ if (hasZheAPlate) return result(false, '名下已有浙 A 牌小客车');
38
+
39
+ const hasIndicator = await confirm({ message: '是否已持有有效的增量指标?', default: false });
40
+ if (hasIndicator) return result(false, '已持有有效指标,不能重复申请');
41
+
42
+ if (personType === 'ZZZ') {
43
+ const sbYears = Number(await input({
44
+ message: '在浙连续缴纳社保的月数?',
45
+ default: '0',
46
+ validate: (v) => /^\d+(\.\d+)?$/.test(v) || '请输入数字',
47
+ }));
48
+ if (sbYears < 24) {
49
+ return result(false, `非杭籍连续社保需 24 个月,差 ${(24 - sbYears).toFixed(0)} 个月`, { sbYears });
50
+ }
51
+ }
52
+
53
+ // 特殊定向:阶梯/多孩/人才
54
+ const lotteryCount = Number(await input({
55
+ message: '累计参加杭州摇号次数(用于判断阶梯摇号资格)?没参加过填 0',
56
+ default: '0',
57
+ validate: (v) => /^\d+$/.test(v) || '请输入整数',
58
+ }));
59
+ const isTierEligible = lotteryCount >= 24;
60
+
61
+ const isMultiChild = await confirm({ message: '是否多孩家庭(2 孩及以上)?', default: false });
62
+ const isTalent = await confirm({ message: '是否经杭州市认定的人才(如 E 类及以上)?', default: false });
63
+
64
+ const lines = ['符合杭州摇号申请条件:'];
65
+ lines.push(' ✓ 个人普通小客车增量指标(每月 26 日摇号)');
66
+ lines.push(' ✓ 个人新能源小客车增量指标');
67
+ if (isTierEligible) lines.push(' ✓ 个人阶梯摇号(累计摇号已达 24 次)');
68
+ if (isMultiChild) lines.push(' ✓ 多孩家庭可直接申领指标(无需摇号)');
69
+ if (isTalent) lines.push(' ✓ 经认定的人才可直接申领指标');
70
+ lines.push('');
71
+ lines.push('下一步:在"浙里办"APP 或浙江政务服务网申报,定位选择"杭州市"');
72
+ lines.push('提示:杭州业务已迁移至"浙里办",原 hzxkctk.cn 仅保留查询和公告功能');
73
+
74
+ return result(true, lines.join('\n'), {
75
+ personType,
76
+ personTypeLabel: PERSON_TYPES[personType],
77
+ age,
78
+ lotteryCount,
79
+ eligibility: {
80
+ regular_lottery: true,
81
+ new_energy: true,
82
+ tiered_lottery: isTierEligible,
83
+ multi_child_direct: isMultiChild,
84
+ talent_direct: isTalent,
85
+ },
86
+ });
87
+ } catch (err) {
88
+ if (err && err.name === 'ExitPromptError') {
89
+ return { pass: null, cancelled: true, lines: ['已取消'] };
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ function result(pass, message, extra = {}) {
96
+ return {
97
+ pass,
98
+ message,
99
+ ...extra,
100
+ lines: [pass ? message : `不符合:${message}`],
101
+ };
102
+ }
@@ -0,0 +1,20 @@
1
+ import { SYSTEM_URL } from './constants.js';
2
+ import { getCalendar } from './calendar.js';
3
+ import { checkEligibility } from './eligibility.js';
4
+ import { extractMarketMetrics, watchTargets } from './service.js';
5
+
6
+ export const meta = {
7
+ name: 'hangzhou',
8
+ label: '杭州',
9
+ systemUrl: SYSTEM_URL,
10
+ applyTypes: ['person', 'unit', 'tiered', 'multi_child', 'talent'],
11
+ regTypes: ['普通指标', '新能源', '阶梯摇号'],
12
+ supported: true,
13
+ };
14
+
15
+ export {
16
+ getCalendar,
17
+ checkEligibility,
18
+ extractMarketMetrics,
19
+ watchTargets,
20
+ };
@@ -0,0 +1,59 @@
1
+ // 杭州公告解析:hzxkctk.cn/tzgg/ 用 <dd><a class="text"><span class="date"> 结构(同北京)
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
+ // 杭州结构与北京相同:<dd><a class="text" href>...</a><span class="date">YYYY-MM-DD</span></dd>
16
+ const ddRe = /<dd[^>]*>([\s\S]*?)<\/dd>/gi;
17
+ for (const m of html.matchAll(ddRe)) {
18
+ const block = m[1];
19
+ const aMatch = /<a\s+class="text"\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i.exec(block);
20
+ const dMatch = /<span\s+class="date">([\s\S]*?)<\/span>/i.exec(block);
21
+ if (!aMatch) continue;
22
+ const title = stripTags(aMatch[2]);
23
+ const date = dMatch ? stripTags(dMatch[1]) : (isoFromUrlPath(aMatch[1]) || null);
24
+ items.push({
25
+ title,
26
+ date,
27
+ url: absUrl(aMatch[1], baseUrl),
28
+ period: extractPeriod(title),
29
+ kind: classifyTitle(title),
30
+ });
31
+ }
32
+ return items;
33
+ }
34
+
35
+ export function parseDetailPage(html, url) {
36
+ // 杭州详情页通常用 .content / .article 类名
37
+ let scope = html;
38
+ const articleMatch =
39
+ /<div[^>]+class="[^"]*(?:article|content|TRS_Editor|main_content)[^"]*"[^>]*>([\s\S]*?)<\/div>/i.exec(html);
40
+ if (articleMatch) scope = articleMatch[1];
41
+
42
+ const titleM = /<h1[^>]*>([\s\S]*?)<\/h1>|<h2[^>]*>([\s\S]*?)<\/h2>/i.exec(html);
43
+ const title = titleM ? stripTags(titleM[1] || titleM[2]) : null;
44
+
45
+ const dateText = (/发布(?:日期|时间)[::]\s*([0-9-/]+|\d{4}年\d{1,2}月\d{1,2}日)/.exec(html) || [])[1] || null;
46
+ const dateIso = dateText
47
+ ? (cnDateToIso(dateText) || (/^\d{4}-\d{1,2}-\d{1,2}/.test(dateText) ? dateText.replace(/\//g, '-') : null))
48
+ : isoFromUrlPath(url);
49
+
50
+ const attachments = [];
51
+ const aRe = /<a[^>]+href="([^"]+\.(?:pdf|docx?|xlsx?|zip))"[^>]*>([\s\S]*?)<\/a>/gi;
52
+ for (const m of html.matchAll(aRe)) {
53
+ attachments.push({ url: absUrl(m[1], url), name: stripTags(m[2]).trim() });
54
+ }
55
+
56
+ const bodyText = stripTags(scope);
57
+ const metrics = commonMetrics(bodyText);
58
+ return { url, title, dateText, dateIso, bodyText, attachments, metrics };
59
+ }
@@ -0,0 +1,122 @@
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.tzggList, { useCache, maxCacheAgeMs: MAX_CACHE_AGE });
10
+ return parseListPage(page.html, URLS.tzggList);
11
+ }
12
+
13
+ export async function extractMarketMetrics(opts = {}) {
14
+ try {
15
+ const useCache = opts.cache !== false;
16
+ const items = await fetchList(opts);
17
+
18
+ const cfg = items.find((i) => i.kind === 'config_notice' && /配置数量/.test(i.title));
19
+ const result = items.find((i) => i.kind === 'result');
20
+ const audit = items.find((i) => i.kind === 'qualify_review');
21
+
22
+ const detailsToParse = [cfg, result, audit].filter(Boolean);
23
+ const detailMetrics = {};
24
+ for (const it of detailsToParse) {
25
+ try {
26
+ const page = await fetchHtml(it.url, { useCache });
27
+ const d = parseDetailPage(page.html, it.url);
28
+ detailMetrics[it.kind] = { ...d.metrics, title: d.title, url: it.url, date: it.date };
29
+ } catch {}
30
+ }
31
+
32
+ const cfgM = detailMetrics.config_notice || {};
33
+ const resM = detailMetrics.result || {};
34
+ const totalAlloc = cfgM.totalAlloc ?? resM.totalAlloc ?? null;
35
+ const personalAlloc = cfgM.personalAlloc ?? resM.personalAlloc ?? null;
36
+ const unitAlloc = cfgM.unitAllocNum ?? resM.unitAllocNum ?? null;
37
+ const lotteryAlloc = cfgM.lotteryAlloc ?? resM.lotteryAlloc ?? null;
38
+ const biddingAlloc = cfgM.biddingAlloc ?? resM.biddingAlloc ?? null;
39
+
40
+ const data = {
41
+ city: 'hangzhou',
42
+ period: cfg?.period?.label || result?.period?.label || null,
43
+ source: SYSTEM_URL,
44
+ latestConfig: cfg ? { date: cfg.date, title: cfg.title, url: cfg.url } : null,
45
+ latestResult: result ? { date: result.date, title: result.title, url: result.url } : null,
46
+ latestAudit: audit ? { date: audit.date, title: audit.title, url: audit.url } : null,
47
+ alloc: { total: totalAlloc, personal: personalAlloc, unit: unitAlloc, lottery: lotteryAlloc, bidding: biddingAlloc },
48
+ details: detailMetrics,
49
+ };
50
+
51
+ const lines = [];
52
+ lines.push(`杭州小客车摇号 ${data.period || '当期'} 形势播报`);
53
+ lines.push(`数据来源: ${SYSTEM_URL}`);
54
+ lines.push('');
55
+ if (cfg) {
56
+ lines.push(`【最新配置数量公告】${cfg.date}`);
57
+ lines.push(` ${cfg.title}`);
58
+ lines.push(` ${cfg.url}`);
59
+ }
60
+ if (result) {
61
+ lines.push('');
62
+ lines.push(`【最新摇号结果】${result.date}`);
63
+ lines.push(` ${result.title}`);
64
+ lines.push(` ${result.url}`);
65
+ }
66
+ if (audit) {
67
+ lines.push('');
68
+ lines.push(`【最新资格审核结果】${audit.date}`);
69
+ lines.push(` ${audit.title}`);
70
+ lines.push(` ${audit.url}`);
71
+ }
72
+ if (totalAlloc || personalAlloc || unitAlloc || lotteryAlloc || biddingAlloc) {
73
+ lines.push('');
74
+ lines.push('【本期配置指标】');
75
+ if (totalAlloc) lines.push(` 总配置: ${fmt(totalAlloc)}`);
76
+ if (personalAlloc) lines.push(` 个人: ${fmt(personalAlloc)}`);
77
+ if (unitAlloc) lines.push(` 单位: ${fmt(unitAlloc)}`);
78
+ if (lotteryAlloc) lines.push(` 其中摇号: ${fmt(lotteryAlloc)}`);
79
+ if (biddingAlloc) lines.push(` 其中竞价: ${fmt(biddingAlloc)}`);
80
+ }
81
+ lines.push('');
82
+ lines.push('提示:杭州业务已迁移至"浙里办"APP / 浙江政务服务网');
83
+ lines.push('每月 26 日摇号;阶梯摇号每年举办一次(累计摇号 24 次以上可参与)');
84
+ return { ok: true, data, lines };
85
+ } catch (err) {
86
+ return {
87
+ ok: false,
88
+ error: `杭州数据抓取失败: ${err.message}`,
89
+ lines: [`错误: ${err.message}`, '', `请直接访问 ${SYSTEM_URL}`],
90
+ };
91
+ }
92
+ }
93
+
94
+ export const watchTargets = {
95
+ result: {
96
+ desc: '杭州摇号结果',
97
+ async fetch(opts = {}) {
98
+ const items = await fetchList(opts);
99
+ return items.filter((i) => i.kind === 'result' || i.kind === 'config_notice');
100
+ },
101
+ },
102
+ policy: {
103
+ desc: '杭州政策变化',
104
+ async fetch(opts = {}) {
105
+ const items = await fetchList(opts);
106
+ return items.filter((i) => i.kind === 'quota' || i.kind === 'faq' || i.kind === 'penalty');
107
+ },
108
+ },
109
+ window: {
110
+ desc: '杭州申请窗口/资格审核',
111
+ async fetch(opts = {}) {
112
+ const items = await fetchList(opts);
113
+ return items.filter((i) => i.kind === 'qualify_review' || i.kind === 'calendar_notice');
114
+ },
115
+ },
116
+ };
117
+
118
+ function fmt(n) {
119
+ if (n == null) return '未知';
120
+ if (typeof n === 'number') return n.toLocaleString('zh-CN');
121
+ return String(n);
122
+ }
@@ -0,0 +1,54 @@
1
+ // source registry:注册所有支持的城市 source
2
+ // 接口规范见 source/beijing/index.js
3
+
4
+ import * as beijing from './beijing/index.js';
5
+ import * as guangzhou from './guangzhou/index.js';
6
+ import * as shenzhen from './shenzhen/index.js';
7
+ import * as hangzhou from './hangzhou/index.js';
8
+
9
+ const SOURCES = {
10
+ beijing,
11
+ guangzhou,
12
+ shenzhen,
13
+ hangzhou,
14
+ };
15
+
16
+ const IMPLEMENTED = ['beijing', 'guangzhou', 'shenzhen', 'hangzhou'];
17
+ const PLANNED = [];
18
+
19
+ const CITY_LABELS = {
20
+ beijing: '北京',
21
+ guangzhou: '广州',
22
+ shenzhen: '深圳',
23
+ hangzhou: '杭州',
24
+ };
25
+
26
+ export function listSources() {
27
+ return Object.keys(SOURCES);
28
+ }
29
+
30
+ export function listImplemented() {
31
+ return IMPLEMENTED;
32
+ }
33
+
34
+ export function listPlanned() {
35
+ return PLANNED;
36
+ }
37
+
38
+ export function getCityLabel(city) {
39
+ return CITY_LABELS[city] || city;
40
+ }
41
+
42
+ export function getSource(city) {
43
+ if (!city) {
44
+ throw new Error('未指定城市,请用 --city <beijing|guangzhou|shenzhen|hangzhou> 或运行 `yaohao init` 设置默认城市');
45
+ }
46
+ if (PLANNED.includes(city)) {
47
+ throw new Error(`${getCityLabel(city)}(${city})source 尚未实现,敬请期待。当前可用城市: ${IMPLEMENTED.map(getCityLabel).join(', ')}`);
48
+ }
49
+ const source = SOURCES[city];
50
+ if (!source) {
51
+ throw new Error(`未知城市: ${city}。支持的城市: ${IMPLEMENTED.concat(PLANNED).join(', ')}`);
52
+ }
53
+ return source;
54
+ }
@@ -0,0 +1,44 @@
1
+ // 深圳摇号关键日历:月度循环
2
+ // 每月 8 日前申请当月、每月 23 日左右公布资格审核结果、每月 26 日摇号
3
+ // 2026 年阶梯摇号:4-12 月每月,24 次为 1 阶梯
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
+ export function getCalendar(year) {
21
+ const y = year || new Date().getFullYear();
22
+ const today = new Date();
23
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
24
+ const currentMonth = today.getFullYear() === y ? today.getMonth() + 1 : 1;
25
+
26
+ const items = [];
27
+ for (let m = currentMonth; m <= Math.min(currentMonth + 3, 12); m++) {
28
+ const apply = `${y}-${pad(m)}-${pad(MONTHLY_RHYTHM.applyDeadlineDay)}`;
29
+ const audit = `${y}-${pad(m)}-23`;
30
+ const lottery = `${y}-${pad(m)}-${pad(MONTHLY_RHYTHM.lotteryDay)}`;
31
+ items.push({ event: `${m} 月申请截止`, date: apply, status: statusOf(today, apply) });
32
+ items.push({ event: `${m} 月资格审核结果`, date: audit, status: statusOf(today, audit) });
33
+ items.push({ event: `${m} 月摇号日`, date: lottery, status: statusOf(today, lottery) });
34
+ }
35
+
36
+ const lines = [`深圳小汽车增量指标摇号 ${y} 年关键日历(今天 ${todayStr}):`, ''];
37
+ for (const it of items) {
38
+ lines.push(` ${it.date.padEnd(28)} ${it.event} [${it.status}]`);
39
+ }
40
+ lines.push('');
41
+ lines.push('规则:每月 8 日前申请当月、23 日左右公布审核结果、26 日摇号(遇周末/节假日顺延)');
42
+ lines.push('2026 年 4-12 月实施阶梯摇号:累计 24 次未中签升 1 个阶梯,每升 1 阶增加 1 个摇号编码');
43
+ return { year: y, today: todayStr, schedule: items, lines };
44
+ }
@@ -0,0 +1,14 @@
1
+ // 深圳市小汽车增量调控管理信息系统
2
+ export const SYSTEM_URL = 'https://xqctk.jtys.sz.gov.cn/';
3
+
4
+ export const URLS = {
5
+ system: 'https://xqctk.jtys.sz.gov.cn/',
6
+ gblList: 'https://xqctk.jtys.sz.gov.cn/gbl/index.html',
7
+ applyEntry: 'https://apply.jtys.sz.gov.cn/apply/',
8
+ };
9
+
10
+ // 深圳摇号节奏:每月 8 日前申请当月、9 日及之后入下月;摇号日为每月 26 日(遇周末/节假日顺延)
11
+ export const MONTHLY_RHYTHM = {
12
+ applyDeadlineDay: 8,
13
+ lotteryDay: 26,
14
+ };
@@ -0,0 +1,90 @@
1
+ import { input, select, confirm } from '@inquirer/prompts';
2
+
3
+ // 深圳小汽车增量指标资格规则(参考深圳市小汽车增量调控管理实施细则)
4
+ // 简化版,复杂情况以官方说明为准
5
+
6
+ const PERSON_TYPES = {
7
+ SZHJ: '深圳市户籍居民',
8
+ XLZG: '驻深现役军人或现役武警',
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, '不符合深圳摇号户籍/居住要求');
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 hasShenzhenPlate = await confirm({ message: '名下是否已有深圳籍小汽车?', default: false });
37
+ if (hasShenzhenPlate) return result(false, '名下已有深圳籍小汽车');
38
+
39
+ const hasIndicator = await confirm({ message: '是否已持有有效的增量指标?', default: false });
40
+ if (hasIndicator) return result(false, '已持有有效指标,不能重复申请');
41
+
42
+ // 非深户走居住证 + 社保
43
+ if (personType === 'ZZZ') {
44
+ // 2026 政策:取消非深户籍人员申请新能源小汽车增量指标的社保限制
45
+ // 但普通指标仍要 24 个月连续社保
46
+ const wantNev = await confirm({ message: '只关心新能源指标吗?(新能源已取消非深户社保限制)', default: false });
47
+ if (!wantNev) {
48
+ const sbYears = Number(await input({
49
+ message: '在深连续缴纳社保的月数?',
50
+ default: '0',
51
+ validate: (v) => /^\d+(\.\d+)?$/.test(v) || '请输入数字',
52
+ }));
53
+ if (sbYears < 24) {
54
+ return result(false, `非深户籍普通指标需 24 个月连续社保,差 ${(24 - sbYears).toFixed(0)} 个月。可考虑新能源(已无此限)`, { sbYears });
55
+ }
56
+ }
57
+ }
58
+
59
+ const lines = ['符合深圳摇号申请条件:'];
60
+ lines.push(' ✓ 个人普通小汽车增量指标(摇号或竞价)');
61
+ lines.push(' ✓ 个人新能源小汽车增量指标');
62
+ if (personType !== 'ZZZ') {
63
+ lines.push(' ✓ 个人混合动力小汽车增量指标(条件放宽)');
64
+ }
65
+ lines.push('');
66
+ lines.push('下一步:每月 8 日前登录 xqctk.jtys.sz.gov.cn 提交申请。');
67
+ lines.push('提示:2026 年 4-12 月实施阶梯摇号,累计摇号次数越多编码越多');
68
+
69
+ return result(true, lines.join('\n'), {
70
+ personType,
71
+ personTypeLabel: PERSON_TYPES[personType],
72
+ age,
73
+ eligibility: { regular: true, new_energy: true },
74
+ });
75
+ } catch (err) {
76
+ if (err && err.name === 'ExitPromptError') {
77
+ return { pass: null, cancelled: true, lines: ['已取消'] };
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+
83
+ function result(pass, message, extra = {}) {
84
+ return {
85
+ pass,
86
+ message,
87
+ ...extra,
88
+ lines: [pass ? message : `不符合:${message}`],
89
+ };
90
+ }
@@ -0,0 +1,20 @@
1
+ import { SYSTEM_URL } from './constants.js';
2
+ import { getCalendar } from './calendar.js';
3
+ import { checkEligibility } from './eligibility.js';
4
+ import { extractMarketMetrics, watchTargets } from './service.js';
5
+
6
+ export const meta = {
7
+ name: 'shenzhen',
8
+ label: '深圳',
9
+ systemUrl: SYSTEM_URL,
10
+ applyTypes: ['person', 'unit'],
11
+ regTypes: ['普通指标', '混合动力', '新能源'],
12
+ supported: true,
13
+ };
14
+
15
+ export {
16
+ getCalendar,
17
+ checkEligibility,
18
+ extractMarketMetrics,
19
+ watchTargets,
20
+ };
@@ -0,0 +1,58 @@
1
+ // 深圳公告解析:xqctk.jtys.sz.gov.cn/gbl/ 结构与杭州/北京一致
2
+ // <dd><a class="text" href>...</a><span class="date">YYYY-MM-DD</span></dd>
3
+
4
+ import {
5
+ stripTags,
6
+ extractMetricsFromText as commonMetrics,
7
+ cnDateToIso,
8
+ isoFromUrlPath,
9
+ absUrl,
10
+ extractPeriod,
11
+ } from '../_shared/parseUtils.js';
12
+ import { classifyTitle } from '../_shared/titleClassify.js';
13
+
14
+ export function parseListPage(html, baseUrl) {
15
+ const items = [];
16
+ const ddRe = /<dd[^>]*>([\s\S]*?)<\/dd>/gi;
17
+ for (const m of html.matchAll(ddRe)) {
18
+ const block = m[1];
19
+ const aMatch = /<a\s+class="text"\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i.exec(block);
20
+ const dMatch = /<span\s+class="date">([\s\S]*?)<\/span>/i.exec(block);
21
+ if (!aMatch) continue;
22
+ const title = stripTags(aMatch[2]);
23
+ const date = dMatch ? stripTags(dMatch[1]) : (isoFromUrlPath(aMatch[1]) || null);
24
+ items.push({
25
+ title,
26
+ date,
27
+ url: absUrl(aMatch[1], baseUrl),
28
+ period: extractPeriod(title),
29
+ kind: classifyTitle(title),
30
+ });
31
+ }
32
+ return items;
33
+ }
34
+
35
+ export function parseDetailPage(html, url) {
36
+ let scope = html;
37
+ const articleMatch =
38
+ /<div[^>]+class="[^"]*(?:article|content|TRS_Editor|main_content|subpage_article)[^"]*"[^>]*>([\s\S]*?)<\/div>/i.exec(html);
39
+ if (articleMatch) scope = articleMatch[1];
40
+
41
+ const titleM = /<h1[^>]*>([\s\S]*?)<\/h1>|<h2[^>]*>([\s\S]*?)<\/h2>/i.exec(html);
42
+ const title = titleM ? stripTags(titleM[1] || titleM[2]) : null;
43
+
44
+ const dateText = (/发布(?:日期|时间)[::]\s*([0-9-/]+|\d{4}年\d{1,2}月\d{1,2}日)/.exec(html) || [])[1] || null;
45
+ const dateIso = dateText
46
+ ? (cnDateToIso(dateText) || (/^\d{4}-\d{1,2}-\d{1,2}/.test(dateText) ? dateText.replace(/\//g, '-') : null))
47
+ : isoFromUrlPath(url);
48
+
49
+ const attachments = [];
50
+ const aRe = /<a[^>]+href="([^"]+\.(?:pdf|docx?|xlsx?|zip))"[^>]*>([\s\S]*?)<\/a>/gi;
51
+ for (const m of html.matchAll(aRe)) {
52
+ attachments.push({ url: absUrl(m[1], url), name: stripTags(m[2]).trim() });
53
+ }
54
+
55
+ const bodyText = stripTags(scope);
56
+ const metrics = commonMetrics(bodyText);
57
+ return { url, title, dateText, dateIso, bodyText, attachments, metrics };
58
+ }