Skill: tianlinle/cantian-bazi (version 0.0.8)

--- name: cantian-bazi description: 以命理场景为主的八字排盘、黄历查询与大运/流年/流月/流日/流时区间查询技能。用于用户请求“算八字”“四柱排盘”“阳历/农历转八字”“查黄历/宜忌”“查未来10年流年”“查下个月流日/流时”等场景；关键词包括：八字、四柱、命理、大运、流年、流月、流日、流时、时辰、阳历转八字、农历转八字、黄历、宜忌、干支日期。真太阳时换算属于辅助能力，仅在需要校时定盘时使用。 / Bazi-first skill for charting, Chinese almanac lookup, and range queries of decade/annual/monthly/daily/hourly luck cycles. Use for requests like “calculate my Bazi”, “convert solar/lunar datetime to Bazi”, “check huangli”, “query next 10 years”, and “check next month daily/hourly cycles”. Keywords focus on Bazi/Four Pillars and fortune-cycle analysis; true-solar-time conversion is an auxiliary step only when time correction is required. --- # 八字排盘、黄历与大运流年查询 (Bazi, Calendar & Fortune Range) ## 何时使用 / When to Use - 用户要根据阳历出生时间计算四柱八字。 / User wants Four Pillars from Gregorian birth datetime. - 用户要根据农历出生时间计算四柱八字。 / User wants Four Pillars from lunar birth datetime. - 用户要查询某一天对应的农历、干支、宜忌等信息。 / User wants Chinese calendar info for a specific date. - 用户要“查黄历”或看某天宜忌。 / User wants almanac-style daily auspicious/inauspicious info. - 用户要查询一段时间内的大运、流年、流月、流日或流时。 / User wants decade/annual/monthly/daily/hourly fortune cycles in a time range. ## 前置依赖 / Prerequisites - 推荐运行环境：Node 24（可直接运行 TypeScript 源码）。 / Recommended runtime: Node 24 (can run TypeScript source directly). - 兼容方案：若 Node 版本较低，使用 `tsx` 执行。 / Fallback: use `tsx` on lower Node versions. - 执行目录：在 skill 根目录（`SKILL.md` 所在目录）执行以下命令。 / Run commands in the skill root (where `SKILL.md` is located). ```bash npm i # 仅在需要兼容运行时安装 # Install only for fallback mode npm i -D tsx ``` ## 脚本清单 / Script Index - `scripts/buildBaziFromSolar.ts`：根据阳历时间生成八字 Markdown。 / Build Bazi from solar datetime. - `scripts/buildBaziFromLunar.ts`：根据农历时间生成八字 Markdown。 / Build Bazi from lunar datetime. - `scripts/convertToTrueSolarTime.ts`：将北京时间按经度换算为真太阳时（输出可直接给其它脚本使用）。 / Convert Beijing time to true solar time by longitude. - `scripts/getChineseCalendar.ts`：查询指定日期（默认今天）的农历与干支信息。 / Get Chinese calendar for a given date (defaults to today). - `scripts/queryFortuneRange.ts`：按时间区间查询大运与流年/流月/流日/流时（流时按时辰段合并）。 / Query decade fortune and annual/monthly/daily/hourly cycles in a time range. ## 脚本与参数 / Scripts & Parameters ### `scripts/buildBaziFromSolar.ts` ```bash # 推荐方式 node scripts/buildBaziFromSolar.ts <solarTime> [gender] [sect] # 兼容方式（fallback） tsx scripts/buildBaziFromSolar.ts <solarTime> [gender] [sect] ``` 参数定义 / Parameters: - `solarTime`（必填 / required） - 格式：ISO 8601 日期时间（不带时区），如 `1990-05-15T14:30:00` - 默认值：无 - 非法输入：格式不合法或为空时，由底层解析失败并报错 - `gender`（可选 / optional） - 取值范围：`1`（男 / male）、`0`（女 / female） - 默认值：`1` - 非法输入：抛错 `性别参数无效。男性传 1，女性传 0。` - `sect`（可选 / optional） - 取值范围：`1`（23:00-23:59 视为明天）、`2`（23:00-23:59 视为当天） - 默认值：`2` - 非法输入：抛错 `早晚子时配置参数无效。传 1 表示 23:00-23:59 日干支为明天，传 2 表示 23:00-23:59 日干支为当天。` ### `scripts/buildBaziFromLunar.ts` ```bash # 推荐方式 node scripts/buildBaziFromLunar.ts <lunarTime> [gender] [sect] # 兼容方式（fallback） tsx scripts/buildBaziFromLunar.ts <lunarTime> [gender] [sect] ``` 参数定义 / Parameters: - `lunarTime`（必填 / required） - 格式：ISO 8601 日期时间（不带时区），如 `1990-04-21T14:30:00` - 默认值：无 - 非法输入：格式不合法或为空时，由底层解析失败并报错 - `gender`（可选 / optional） - 取值范围：`1`（男 / male）、`0`（女 / female） - 默认值：`1` - 非法输入：抛错 `性别参数无效。男性传 1，女性传 0。` - `sect`（可选 / optional） - 取值范围：`1`（23:00-23:59 视为明天）、`2`（23:00-23:59 视为当天） - 默认值：`2` - 非法输入：抛错 `早晚子时配置参数无效。传 1 表示 23:00-23:59 日干支为明天，传 2 表示 23:00-23:59 日干支为当天。` ### `scripts/convertToTrueSolarTime.ts`（真太阳时换算） ```bash # 推荐方式 node scripts/convertToTrueSolarTime.ts <beijingTime> <longitudeOrCity> [standardLongitude] # 兼容方式（fallback） tsx scripts/convertToTrueSolarTime.ts <beijingTime> <longitudeOrCity> [standardLongitude] ``` 参数定义 / Parameters: - `beijingTime`（必填 / required） - 含义：北京时间（东八区标准时）输入时间 - 格式：`YYYY-MM-DDTHH:mm[:ss]`（不带时区），如 `1990-05-15T14:30:00` - 默认值：无 - 非法输入：抛错 `时间格式无效...请使用不带时区的 YYYY-MM-DDTHH:mm[:ss]...` - `longitudeOrCity`（必填 / required） - 含义：可直接传经度，或传城市名由脚本自动匹配经度 - 经度格式：十进制度数，范围 `-180` 到 `180`（如武汉 `114.17`） - 城市格式：城市名（支持常见后缀，如 `武汉`、`武汉市`） - 默认值：无 - 非法输入：地点无法识别时抛错 `无法识别地点...请传经度或城市名...` - `standardLongitude`（可选 / optional） - 含义：标准经度，默认 `120`（北京时间基准经线） - 取值范围：`-180` 到 `180` - 默认值：`120` - 非法输入：抛错 `standardLongitude 无效...` 或 `standardLongitude 超出范围...` 输出 / Output: - 标准输出仅返回 1 行真太阳时时间字符串（`YYYY-MM-DDTHH:mm:ss`），可直接作为 `buildBaziFromSolar.ts` 与 `queryFortuneRange.ts` 的 `solar` 时间参数。 ### `scripts/getChineseCalendar.ts`（黄历/农历查询） ```bash # 推荐方式（不传参默认今天） node scripts/getChineseCalendar.ts [date] # 兼容方式（fallback） tsx scripts/getChineseCalendar.ts [date] ``` 参数定义 / Parameters: - `date`（可选 / optional） - 格式：`YYYY-MM-DD`（兼容 `YYYY/MM/DD`），如 `2024-02-10` - 默认值：当天日期 - 取值范围：有效公历日期 - 非法输入： - 格式错误时抛错 `日期格式无效。请传入 YYYY-MM-DD（也兼容 YYYY/MM/DD）。` - 日期不存在时抛错 `日期值无效。请确认年月日是实际存在的日期。` ### `scripts/queryFortuneRange.ts`（大运/流年/流月/流日/流时区间查询） ```bash # 推荐方式 node scripts/queryFortuneRange.ts '<json>' # 兼容方式（fallback） tsx scripts/queryFortuneRange.ts '<json>' ``` 参数定义 / Parameters: - `json`（必填 / required） - 格式：JSON 字符串，结构如下： - `birth.calendar`（必填）：`solar` 或 `lunar` - `birth.time`（必填）：出生时间（不带时区），如 `1990-05-15T14:30:00` - `birth.gender`（可选）：`1` 男，`0` 女，默认 `1` - `birth.sect`（可选）：`1` 或 `2`，默认 `2` - `query.startDateTime`（必填）：`YYYY-MM-DD` 或 `YYYY-MM-DDTHH` - `query.endDateTime`（必填）：`YYYY-MM-DD` 或 `YYYY-MM-DDTHH` - `query.level`（可选）：`year`、`month`、`day`、`hour`，默认 `year` - 自动补全规则： - `startDateTime` 传 `YYYY-MM-DD` 时补为 `T00` - `endDateTime` 传 `YYYY-MM-DD` 时补为 `T23` - 结果规则： - `level` 采用层级语义：`year < month < day < hour` - 自动补父级：查 `day` 自动包含 `year+month`；查 `hour` 自动包含 `year+month+day` - `level=hour` 时返回流时，输出会将连续相同流时合并为时辰段（2 小时语义） - 查询层级达到 `month` 时返回按连续干支月分段的流月区间 - 流年/流月/流日/流时结果均包含：干支、十神、神煞、与原命盘的刑冲合会关系 - 输出固定为完整 Markdown（按层级标题展开全部字段，不使用代码块），不做省略与去重 - 非法输入： - JSON 非法时抛错 `参数 JSON 解析失败。请传入合法 JSON。` - 时间格式错误时抛错 `时间格式无效...请使用 YYYY-MM-DD 或 YYYY-MM-DDTHH...` - `startDateTime` 晚于 `endDateTime` 时抛错 `时间范围无效：startDateTime 不能晚于 endDateTime。` - `level=hour` 且范围超过 62 天时抛错 `hour 查询区间过大...` ## 示例 / Examples ```bash # buildBaziFromSolar.ts 最小可用示例 node scripts/buildBaziFromSolar.ts "1990-05-15T14:30:00" # buildBaziFromLunar.ts 最小可用示例 node scripts/buildBaziFromLunar.ts "1990-04-21T14:30:00" # convertToTrueSolarTime.ts 最小可用示例（武汉经度） node scripts/convertToTrueSolarTime.ts "1990-05-15T14:30:00" 114.17 # convertToTrueSolarTime.ts 城市名示例（无需手查经度） node scripts/convertToTrueSolarTime.ts "1990-05-15T14:30:00" 武汉 # getChineseCalendar.ts 最小可用示例（默认今天） node scripts/getChineseCalendar.ts # getChineseCalendar.ts 指定日期 node scripts/getChineseCalendar.ts 2024-02-10 # queryFortuneRange.ts: 查询 2026-2028 流年 node scripts/queryFortuneRange.ts '{"birth":{"calendar":"solar","time":"1990-05-15T14:30:00","gender":1,"sect":2},"query":{"startDateTime":"2026-01-01","endDateTime":"2028-12-31","level":"year"}}' # queryFortuneRange.ts: 查询下个月流日（按日） node scripts/queryFortuneRange.ts '{"birth":{"calendar":"solar","time":"1990-05-15T14:30:00"},"query":{"startDateTime":"2026-05-01","endDateTime":"2026-05-31","level":"day"}}' # queryFortuneRange.ts: 查询某段流时（按时辰段合并） node scripts/queryFortuneRange.ts '{"birth":{"calendar":"solar","time":"1990-05-15T14:30:00"},"query":{"startDateTime":"2026-05-01T09","endDateTime":"2026-05-03T18","level":"hour"}}' # 两步流程示例：先换算真太阳时，再排盘（示例结果需替换为实际换算输出） node scripts/convertToTrueSolarTime.ts "1990-05-15T14:30:00" 114.17 node scripts/buildBaziFromSolar.ts "1990-05-15T14:10:20" 1 2 ``` ## 注意事项 / Notes 1. 所有命令均在 skill 根目录执行，不依赖仓库根目录路径。 / Run all commands in the skill root; do not rely on repo-root paths. 2. 时间字符串不要携带时区后缀（如 `Z`、`+08:00`），以免产生与预期不一致的换日结果。 / Do not append timezone suffixes (such as `Z` or `+08:00`) to avoid unexpected day shifts. 3. 若要按真太阳时排盘，请先运行 `convertToTrueSolarTime.ts`，再把输出时间传给其它脚本；其它脚本本身不做真太阳时换算。 / For true solar time workflow, run `convertToTrueSolarTime.ts` first and pass its output to other scripts. 4. 涉及 23:00-23:59 出生时，建议显式传 `sect`，避免晚子时归属歧义。 / For births between 23:00-23:59, explicitly set `sect` to avoid ambiguity. 5. `queryFortuneRange.ts` 的 `startDateTime/endDateTime` 只支持 `YYYY-MM-DD` 或 `YYYY-MM-DDTHH`，不支持分钟秒。 / `queryFortuneRange.ts` supports only `YYYY-MM-DD` and `YYYY-MM-DDTHH` for range inputs. 6. 真太阳时换算只依赖经度，不需要纬度；脚本支持直接传城市名自动匹配经度。 / True solar time conversion uses longitude only; latitude is not required.

path: package.json
content: {
  "private": true,
  "type": "module",
  "dependencies": {
    "cantian-tymext": "^0.0.26"
  }
}


path: tsconfig.json
content: {
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist-js",
    "rootDir": "scripts",
    "rewriteRelativeImportExtensions": true
  }
}


path: scripts/convertToTrueSolarTime.ts
content: import {
  convertBeijingDateTimeStringToTrueSolarTime,
  listSupportedCityNames,
  resolveCityLongitude,
} from './util.ts';

function fail(message: string): never {
  throw new Error(message);
}

function parseLongitude(raw: string, fieldName: string): number {
  const value = Number.parseFloat(raw);
  if (!Number.isFinite(value)) {
    fail(`${fieldName} 无效。请传入十进制度数，例如 114.17。`);
  }
  if (value < -180 || value > 180) {
    fail(`${fieldName} 超出范围。可用范围：-180 到 180。`);
  }
  return value;
}

function parseLongitudeOrCity(raw: string): number {
  const number = Number.parseFloat(raw);
  if (Number.isFinite(number)) {
    return parseLongitude(raw, 'longitude');
  }

  const longitude = resolveCityLongitude(raw);
  if (longitude !== undefined) {
    return longitude;
  }

  const sampleCities = listSupportedCityNames().slice(0, 8).join('、');
  fail(`无法识别地点：${raw}。请传经度（如 114.17）或城市名（如 武汉）。示例城市：${sampleCities}。`);
}

const [, , beijingTimeString, locationRaw, standardLongitudeRaw] = process.argv;
if (!beijingTimeString || !locationRaw) {
  fail(
    '参数不足。用法：node scripts/convertToTrueSolarTime.ts <beijingTime> <longitudeOrCity> [standardLongitude]'
  );
}

const longitude = parseLongitudeOrCity(locationRaw);
const standardLongitude =
  standardLongitudeRaw === undefined ? 120 : parseLongitude(standardLongitudeRaw, 'standardLongitude');

const trueSolarTime = convertBeijingDateTimeStringToTrueSolarTime(
  beijingTimeString,
  longitude,
  standardLongitude
);

// 输出可直接传给其它脚本的时间字符串
console.log(trueSolarTime);


path: scripts/queryFortuneRange.ts
content: import {
  appendRelation,
  EarthBranch,
  HeavenStem,
  SixtyCycleDay,
  SixtyCycleHour,
  SixtyCycle,
  SixtyCycleYear,
  SolarDay,
  SolarTime,
  buildBaziFromLunar,
  buildBaziFromSolar,
  getShenFromDayun,
} from 'cantian-tymext';

type Gender = 0 | 1;
type Sect = 1 | 2;
type IncludeUnit = 'year' | 'month' | 'day' | 'hour';

interface QueryInput {
  birth: {
    calendar: 'solar' | 'lunar';
    time: string;
    gender?: Gender;
    sect?: Sect;
  };
  query: {
    startDateTime: string;
    endDateTime: string;
    level?: IncludeUnit;
  };
}

interface DateParseResult {
  date: Date;
  precision: 'date' | 'hour';
}

interface DecadeFortuneItem {
  干支: string;
  开始年份: number;
  结束: number;
  开始年龄: number;
  结束年龄: number;
  天干十神?: string;
  地支十神?: string[];
  地支藏干?: string[];
}

interface TenGodDetail {
  天干十神: string;
  地支十神: string[];
  地支藏干: string[];
}

interface GanzhiExtraDetail {
  纳音: string;
  星运: string;
  自坐: string;
  空亡: string;
}

type RelationDetail = unknown;

interface BaseTimelineItem {
  年份: number;
  时间: string;
  流年干支: string;
  流年十神: TenGodDetail;
  流年扩展: GanzhiExtraDetail;
  流年神煞: string[];
  流年刑冲合会: RelationDetail;
  流月干支: string;
  流月十神: TenGodDetail;
  流月扩展: GanzhiExtraDetail;
  流月神煞: string[];
  流月刑冲合会: RelationDetail;
  流日干支: string;
  流日十神: TenGodDetail;
  流日扩展: GanzhiExtraDetail;
  流日神煞: string[];
  流日刑冲合会: RelationDetail;
  当前大运: DecadeFortuneItem | null;
}

interface MonthPeriod {
  开始日期: string;
  结束日期: string;
  流月干支: string;
  流月十神: TenGodDetail;
  流月扩展: GanzhiExtraDetail;
  流月神煞: string[];
  流月刑冲合会: RelationDetail;
  流年干支: string;
  流年十神: TenGodDetail;
  流年扩展: GanzhiExtraDetail;
  流年神煞: string[];
  流年刑冲合会: RelationDetail;
  当前大运: DecadeFortuneItem | null;
}

interface HourPeriod extends BaseTimelineItem {
  开始时间: string;
  结束时间: string;
  流时干支: string;
  流时十神: TenGodDetail;
  流时扩展: GanzhiExtraDetail;
  流时神煞: string[];
  流时刑冲合会: RelationDetail;
}

function fail(message: string): never {
  throw new Error(message);
}

function pad2(value: number): string {
  return String(value).padStart(2, '0');
}

function formatDate(date: Date): string {
  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}

function formatDateHour(date: Date): string {
  return `${formatDate(date)}T${pad2(date.getHours())}`;
}

function addDays(date: Date, days: number): Date {
  return new Date(
    date.getFullYear(),
    date.getMonth(),
    date.getDate() + days,
    date.getHours(),
    date.getMinutes(),
    date.getSeconds()
  );
}

function addHours(date: Date, hours: number): Date {
  return new Date(
    date.getFullYear(),
    date.getMonth(),
    date.getDate(),
    date.getHours() + hours,
    date.getMinutes(),
    date.getSeconds()
  );
}

function normalizeInputDateTime(value: string, isEnd: boolean): DateParseResult {
  const trimmed = value.trim();
  const dateOnly = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
  const hourOnly = /^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2})$/;

  let y = 0;
  let m = 0;
  let d = 0;
  let h = 0;
  let precision: DateParseResult['precision'] = 'date';

  const dateMatch = dateOnly.exec(trimmed);
  if (dateMatch) {
    y = Number.parseInt(dateMatch[1], 10);
    m = Number.parseInt(dateMatch[2], 10);
    d = Number.parseInt(dateMatch[3], 10);
    h = isEnd ? 23 : 0;
  } else {
    const hourMatch = hourOnly.exec(trimmed);
    if (!hourMatch) {
      fail(
        `时间格式无效：${value}。请使用 YYYY-MM-DD 或 YYYY-MM-DDTHH（例如 2026-05-01 或 2026-05-01T09）。`
      );
    }
    y = Number.parseInt(hourMatch[1], 10);
    m = Number.parseInt(hourMatch[2], 10);
    d = Number.parseInt(hourMatch[3], 10);
    h = Number.parseInt(hourMatch[4], 10);
    precision = 'hour';
  }

  if (h < 0 || h > 23) {
    fail(`小时值无效：${value}。HH 取值范围为 00-23。`);
  }

  const date = new Date(y, m - 1, d, h, 0, 0);
  if (
    date.getFullYear() !== y ||
    date.getMonth() + 1 !== m ||
    date.getDate() !== d ||
    date.getHours() !== h
  ) {
    fail(`时间值无效：${value}。请确认年月日和小时是实际存在的时间。`);
  }

  return { date, precision };
}

function parseInput(args: string[]): QueryInput {
  const raw = args[2];
  if (!raw) {
    fail('缺少参数。请传入 JSON 字符串参数。');
  }

  let input: QueryInput;
  try {
    input = JSON.parse(raw) as QueryInput;
  } catch {
    fail('参数 JSON 解析失败。请传入合法 JSON。');
  }

  if (!input.birth || !input.query) {
    fail('参数缺少 birth 或 query 字段。');
  }

  if (!['solar', 'lunar'].includes(input.birth.calendar)) {
    fail('birth.calendar 无效。可选值：solar、lunar。');
  }

  if (!input.birth.time) {
    fail('birth.time 不能为空。');
  }

  if (input.birth.gender !== undefined && ![0, 1].includes(input.birth.gender)) {
    fail('birth.gender 无效。男性传 1，女性传 0。');
  }

  if (input.birth.sect !== undefined && ![1, 2].includes(input.birth.sect)) {
    fail(
      'birth.sect 无效。传 1 表示 23:00-23:59 日干支为明天，传 2 表示 23:00-23:59 日干支为当天。'
    );
  }

  if (!input.query.startDateTime || !input.query.endDateTime) {
    fail('query.startDateTime 和 query.endDateTime 必填。');
  }

  const level = input.query.level ?? 'year';
  const allowed = new Set<IncludeUnit>(['year', 'month', 'day', 'hour']);
  if (!allowed.has(level)) {
    fail('query.level 含无效值。可选：year、month、day、hour。');
  }

  input.query.level = level;

  return input;
}

function renderTenGod(lines: string[], detail: TenGodDetail, level: number) {
  lines.push(`${'#'.repeat(level)} 十神`);
  lines.push(`天干十神 ${detail.天干十神}`);
  lines.push(`地支十神 ${detail.地支十神.join('、')}`);
  lines.push(`地支藏干 ${detail.地支藏干.join('、')}`);
}

function renderExtra(lines: string[], detail: GanzhiExtraDetail, level: number) {
  lines.push(`${'#'.repeat(level)} 扩展`);
  lines.push(`纳音 ${detail.纳音}`);
  lines.push(`星运 ${detail.星运}`);
  lines.push(`自坐 ${detail.自坐}`);
  lines.push(`空亡 ${detail.空亡}`);
}

function renderRelationList(lines: string[], title: string, relations: RelationDetail, level: number) {
  lines.push(`${'#'.repeat(level)} ${title}`);
  const list = Array.isArray(relations) ? relations : [];
  if (list.length === 0) {
    lines.push('无');
    return;
  }
  for (let i = 0; i < list.length; i++) {
    const item = list[i] as Record<string, unknown>;
    const rel = String(item.关系 ?? '');
    const assoc = Array.isArray(item.关联柱) ? item.关联柱.join('、') : String(item.关联柱 ?? '');
    const desc = String(item.描述 ?? '');
    lines.push(`${rel} ${assoc}${desc}`);
  }
}

function buildMarkdownReport(output: {
  meta: {
    generatedAt: string;
    inputEcho: QueryInput;
    normalizedRange: { startDateTime: string; endDateTime: string; startPrecision: string; endPrecision: string };
    hourMode?: string;
  };
  natal: {
    八字: string;
    日主: string;
    起运日期?: string;
    起运年龄?: number;
    大运: DecadeFortuneItem[];
  };
  result: {
    years: Array<{
      年份: number;
      流年干支: string;
      流年十神: TenGodDetail;
      流年扩展: GanzhiExtraDetail;
      流年神煞: string[];
      流年刑冲合会: RelationDetail;
      当前大运: DecadeFortuneItem | null;
    }>;
    months: MonthPeriod[];
    days: BaseTimelineItem[];
    hours: HourPeriod[];
  };
}): string {
  const lines: string[] = ['# 运势流转', ''];

  lines.push('## 查询范围');
  lines.push(`开始 ${output.meta.normalizedRange.startDateTime}`);
  lines.push(`结束 ${output.meta.normalizedRange.endDateTime}`);
  lines.push('');

  lines.push('## 命盘');
  lines.push(`八字 ${output.natal.八字}`);
  lines.push(`日主 ${output.natal.日主}`);
  lines.push(`起运日期 ${output.natal.起运日期 ?? ''}`);
  lines.push(`起运年龄 ${output.natal.起运年龄 ?? ''}`);
  lines.push('### 大运');
  output.natal.大运.forEach((d) => {
    lines.push(`#### ${d.干支}大运 ${d.开始年份}-${d.结束}年`);
    lines.push(`开始年份 ${d.开始年份}`);
    lines.push(`结束 ${d.结束}`);
    lines.push(`开始年龄 ${d.开始年龄}`);
    lines.push(`结束年龄 ${d.结束年龄}`);
    lines.push(`天干十神 ${d.天干十神 ?? ''}`);
    lines.push(`地支十神 ${(d.地支十神 ?? []).join('、')}`);
    lines.push(`地支藏干 ${(d.地支藏干 ?? []).join('、')}`);
  });
  lines.push('');

  lines.push('## 流转结果');
  if (output.result.years.length > 0) {
    lines.push('### 流年');
    output.result.years.forEach((y) => {
      lines.push(`#### ${y.流年干支}流年 ${y.年份}年`);
      lines.push(`年份 ${y.年份}`);
      renderTenGod(lines, y.流年十神, 5);
      renderExtra(lines, y.流年扩展, 5);
      lines.push(`##### 流年神煞`);
      lines.push(y.流年神煞.join('、'));
      renderRelationList(lines, '流年刑冲合会', y.流年刑冲合会, 5);
    });
  }

  if (output.result.months.length > 0) {
    lines.push('### 流月');
    output.result.months.forEach((m) => {
      lines.push(`#### ${m.流月干支}流月 ${m.开始日期}-${m.结束日期}`);
      lines.push(`开始日期 ${m.开始日期}`);
      lines.push(`结束日期 ${m.结束日期}`);
      lines.push(`流年干支 ${m.流年干支}`);
      renderTenGod(lines, m.流月十神, 5);
      renderExtra(lines, m.流月扩展, 5);
      lines.push(`##### 流月神煞`);
      lines.push(m.流月神煞.join('、'));
      renderRelationList(lines, '流月刑冲合会', m.流月刑冲合会, 5);
    });
  }

  if (output.result.days.length > 0) {
    lines.push('### 流日');
    output.result.days.forEach((d) => {
      lines.push(`#### ${d.流日干支}流日 ${d.时间}`);
      lines.push(`年份 ${d.年份}`);
      lines.push(`时间 ${d.时间}`);
      lines.push(`流年干支 ${d.流年干支}`);
      lines.push(`流月干支 ${d.流月干支}`);
      renderTenGod(lines, d.流日十神, 5);
      renderExtra(lines, d.流日扩展, 5);
      lines.push(`##### 流日神煞`);
      lines.push(d.流日神煞.join('、'));
      renderRelationList(lines, '流日刑冲合会', d.流日刑冲合会, 5);
    });
  }

  if (output.result.hours.length > 0) {
    lines.push('### 流时');
    output.result.hours.forEach((h) => {
      lines.push(`#### ${h.流时干支}流时 ${h.开始时间}-${h.结束时间}`);
      lines.push(`流年干支 ${h.流年干支}`);
      lines.push(`流月干支 ${h.流月干支}`);
      lines.push(`流日干支 ${h.流日干支}`);
      renderTenGod(lines, h.流时十神, 5);
      renderExtra(lines, h.流时扩展, 5);
      lines.push(`##### 流时神煞`);
      lines.push(h.流时神煞.join('、'));
      renderRelationList(lines, '流时刑冲合会', h.流时刑冲合会, 5);
    });
  }

  return lines.join('\n');
}

function getCurrentDayun(dayunList: DecadeFortuneItem[], year: number): DecadeFortuneItem | null {
  const found = dayunList.find((d) => year >= d.开始年份 && year <= d.结束);
  return found ?? null;
}

function buildTenGodDetail(dayMaster: HeavenStem, ganzhi: string): TenGodDetail {
  const gan = ganzhi.slice(0, 1);
  const zhi = ganzhi.slice(1, 2);
  const heavenStem = HeavenStem.fromName(gan);
  const earthBranch = EarthBranch.fromName(zhi);
  const hideHeavenStems = earthBranch.getHideHeavenStems().map((item) => item.getHeavenStem());
  return {
    天干十神: dayMaster.getTenStar(heavenStem).getName(),
    地支十神: hideHeavenStems.map((stem) => dayMaster.getTenStar(stem).getName()),
    地支藏干: hideHeavenStems.map((stem) => stem.toString()),
  };
}

function buildGanzhiExtraDetail(dayMaster: HeavenStem, ganzhi: string): GanzhiExtraDetail {
  const sixtyCycle = SixtyCycle.fromName(ganzhi);
  const gan = sixtyCycle.getHeavenStem();
  const zhi = sixtyCycle.getEarthBranch();
  return {
    纳音: sixtyCycle.getSound().toString(),
    星运: dayMaster.getTerrain(zhi).toString(),
    自坐: gan.getTerrain(zhi).toString(),
    空亡: sixtyCycle.getExtraEarthBranches().join(''),
  };
}

function buildFlowShen(baziText: string, ganzhi: string): string[] {
  const gan = ganzhi.slice(0, 1);
  const zhi = ganzhi.slice(1, 2);
  return getShenFromDayun(baziText, gan, zhi);
}

function buildFlowRelation(preGanzhiArray: string[], ganzhi: string): RelationDetail {
  return appendRelation(preGanzhiArray, ganzhi);
}

function buildBaseBazi(input: QueryInput) {
  const gender = input.birth.gender ?? 1;
  const sect = input.birth.sect ?? 2;

  if (input.birth.calendar === 'solar') {
    return buildBaziFromSolar({
      solarTime: input.birth.time,
      gender,
      sect,
    });
  }

  return buildBaziFromLunar({
    lunarTime: input.birth.time,
    gender,
    sect,
  });
}

function getFlowFromDate(
  date: Date,
  dayunList: DecadeFortuneItem[],
  dayMaster: HeavenStem,
  baziText: string,
  natalGanzhiArray: string[]
): BaseTimelineItem {
  const solarDay = SolarDay.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
  const scDay = SixtyCycleDay.fromSolarDay(solarDay);
  const liunianGanzhi = SixtyCycleYear.fromYear(date.getFullYear()).getSixtyCycle().toString();
  const liuyueGanzhi = scDay.getSixtyCycleMonth().getSixtyCycle().toString();
  const liuriGanzhi = scDay.getSixtyCycle().toString();

  return {
    年份: date.getFullYear(),
    时间: formatDateHour(date),
    流年干支: liunianGanzhi,
    流年十神: buildTenGodDetail(dayMaster, liunianGanzhi),
    流年扩展: buildGanzhiExtraDetail(dayMaster, liunianGanzhi),
    流年神煞: buildFlowShen(baziText, liunianGanzhi),
    流年刑冲合会: buildFlowRelation(natalGanzhiArray, liunianGanzhi),
    流月干支: liuyueGanzhi,
    流月十神: buildTenGodDetail(dayMaster, liuyueGanzhi),
    流月扩展: buildGanzhiExtraDetail(dayMaster, liuyueGanzhi),
    流月神煞: buildFlowShen(baziText, liuyueGanzhi),
    流月刑冲合会: buildFlowRelation([...natalGanzhiArray, liunianGanzhi], liuyueGanzhi),
    流日干支: liuriGanzhi,
    流日十神: buildTenGodDetail(dayMaster, liuriGanzhi),
    流日扩展: buildGanzhiExtraDetail(dayMaster, liuriGanzhi),
    流日神煞: buildFlowShen(baziText, liuriGanzhi),
    流日刑冲合会: buildFlowRelation([...natalGanzhiArray, liunianGanzhi], liuriGanzhi),
    当前大运: getCurrentDayun(dayunList, date.getFullYear()),
  };
}

function buildYearItems(
  start: Date,
  end: Date,
  dayunList: DecadeFortuneItem[],
  dayMaster: HeavenStem,
  baziText: string,
  natalGanzhiArray: string[]
) {
  const items: Array<{
    年份: number;
    流年干支: string;
    流年十神: TenGodDetail;
    流年扩展: GanzhiExtraDetail;
    流年神煞: string[];
    流年刑冲合会: RelationDetail;
    当前大运: DecadeFortuneItem | null;
  }> = [];

  for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
    const liunianGanzhi = SixtyCycleYear.fromYear(y).getSixtyCycle().toString();
    items.push({
      年份: y,
      流年干支: liunianGanzhi,
      流年十神: buildTenGodDetail(dayMaster, liunianGanzhi),
      流年扩展: buildGanzhiExtraDetail(dayMaster, liunianGanzhi),
      流年神煞: buildFlowShen(baziText, liunianGanzhi),
      流年刑冲合会: buildFlowRelation(natalGanzhiArray, liunianGanzhi),
      当前大运: getCurrentDayun(dayunList, y),
    });
  }
  return items;
}

function buildDayItems(
  start: Date,
  end: Date,
  dayunList: DecadeFortuneItem[],
  dayMaster: HeavenStem,
  baziText: string,
  natalGanzhiArray: string[]
) {
  const first = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 12, 0, 0);
  const last = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 12, 0, 0);
  const items: BaseTimelineItem[] = [];

  let cursor = first;
  while (cursor.getTime() <= last.getTime()) {
    items.push(getFlowFromDate(cursor, dayunList, dayMaster, baziText, natalGanzhiArray));
    cursor = addDays(cursor, 1);
  }
  return items;
}

function buildMonthPeriods(dayItems: BaseTimelineItem[]): MonthPeriod[] {
  if (dayItems.length === 0) {
    return [];
  }

  const periods: MonthPeriod[] = [];
  for (const item of dayItems) {
    const date = item.时间.slice(0, 10);
    const last = periods[periods.length - 1];
    if (last && last.流月干支 === item.流月干支) {
      last.结束日期 = date;
      continue;
    }

    periods.push({
      开始日期: date,
      结束日期: date,
      流月干支: item.流月干支,
      流月十神: item.流月十神,
      流月扩展: item.流月扩展,
      流月神煞: item.流月神煞,
      流月刑冲合会: item.流月刑冲合会,
      流年干支: item.流年干支,
      流年十神: item.流年十神,
      流年扩展: item.流年扩展,
      流年神煞: item.流年神煞,
      流年刑冲合会: item.流年刑冲合会,
      当前大运: item.当前大运,
    });
  }
  return periods;
}

function buildHourPeriods(
  start: Date,
  end: Date,
  dayunList: DecadeFortuneItem[],
  dayMaster: HeavenStem,
  baziText: string,
  natalGanzhiArray: string[]
): HourPeriod[] {
  const periods: HourPeriod[] = [];
  let cursor = new Date(
    start.getFullYear(),
    start.getMonth(),
    start.getDate(),
    start.getHours(),
    0,
    0
  );

  while (cursor.getTime() <= end.getTime()) {
    const solarTime = SolarTime.fromYmdHms(
      cursor.getFullYear(),
      cursor.getMonth() + 1,
      cursor.getDate(),
      cursor.getHours(),
      0,
      0
    );
    const scHour = SixtyCycleHour.fromSolarTime(solarTime);
    const scDay = scHour.getSixtyCycleDay();
    const hourGanzhi = scHour.getSixtyCycle().toString();
    const liunianGanzhi = SixtyCycleYear.fromYear(cursor.getFullYear()).getSixtyCycle().toString();
    const liuyueGanzhi = scDay.getSixtyCycleMonth().getSixtyCycle().toString();
    const liuriGanzhi = scDay.getSixtyCycle().toString();

    const current: HourPeriod = {
      年份: cursor.getFullYear(),
      时间: formatDateHour(cursor),
      开始时间: formatDateHour(cursor),
      结束时间: formatDateHour(cursor),
      流时干支: hourGanzhi,
      流时十神: buildTenGodDetail(dayMaster, hourGanzhi),
      流时扩展: buildGanzhiExtraDetail(dayMaster, hourGanzhi),
      流时神煞: buildFlowShen(baziText, hourGanzhi),
      流时刑冲合会: buildFlowRelation([...natalGanzhiArray, liunianGanzhi], hourGanzhi),
      流年干支: liunianGanzhi,
      流年十神: buildTenGodDetail(dayMaster, liunianGanzhi),
      流年扩展: buildGanzhiExtraDetail(dayMaster, liunianGanzhi),
      流年神煞: buildFlowShen(baziText, liunianGanzhi),
      流年刑冲合会: buildFlowRelation(natalGanzhiArray, liunianGanzhi),
      流月干支: liuyueGanzhi,
      流月十神: buildTenGodDetail(dayMaster, liuyueGanzhi),
      流月扩展: buildGanzhiExtraDetail(dayMaster, liuyueGanzhi),
      流月神煞: buildFlowShen(baziText, liuyueGanzhi),
      流月刑冲合会: buildFlowRelation([...natalGanzhiArray, liunianGanzhi], liuyueGanzhi),
      流日干支: liuriGanzhi,
      流日十神: buildTenGodDetail(dayMaster, liuriGanzhi),
      流日扩展: buildGanzhiExtraDetail(dayMaster, liuriGanzhi),
      流日神煞: buildFlowShen(baziText, liuriGanzhi),
      流日刑冲合会: buildFlowRelation([...natalGanzhiArray, liunianGanzhi], liuriGanzhi),
      当前大运: getCurrentDayun(dayunList, cursor.getFullYear()),
    };

    const last = periods[periods.length - 1];
    if (
      last &&
      last.流时干支 === current.流时干支 &&
      last.流日干支 === current.流日干支 &&
      last.流月干支 === current.流月干支 &&
      last.流年干支 === current.流年干支
    ) {
      last.结束时间 = current.结束时间;
    } else {
      periods.push(current);
    }

    cursor = addHours(cursor, 1);
  }

  return periods;
}

const input = parseInput(process.argv);
const startParsed = normalizeInputDateTime(input.query.startDateTime, false);
const endParsed = normalizeInputDateTime(input.query.endDateTime, true);

if (startParsed.date.getTime() > endParsed.date.getTime()) {
  fail('时间范围无效：startDateTime 不能晚于 endDateTime。');
}

const level = input.query.level ?? 'year';
const levelRank: Record<IncludeUnit, number> = { year: 1, month: 2, day: 3, hour: 4 };
const rank = levelRank[level];
const hasYear = rank >= levelRank.year;
const hasMonth = rank >= levelRank.month;
const hasDay = rank >= levelRank.day;
const hasHour = rank >= levelRank.hour;

if (hasHour) {
  const maxHourSpan = 24 * 62;
  const hours = Math.floor((endParsed.date.getTime() - startParsed.date.getTime()) / 3600000) + 1;
  if (hours > maxHourSpan) {
    fail('hour 查询区间过大。为避免输出过长，包含 hour 时最多支持 62 天。');
  }
}

const bazi = buildBaseBazi(input);
const dayunList = (bazi.大运?.大运 ?? []) as DecadeFortuneItem[];
const dayMaster = HeavenStem.fromName(bazi.日主);
const natalGanzhiArray = bazi.八字.trim().split(/\s+/);
if (natalGanzhiArray.length < 4) {
  fail(`八字格式无效：${bazi.八字}`);
}

let yearItems: ReturnType<typeof buildYearItems> = [];
if (hasYear) {
  yearItems = buildYearItems(
    startParsed.date,
    endParsed.date,
    dayunList,
    dayMaster,
    bazi.八字,
    natalGanzhiArray
  );
}

let dayItems: ReturnType<typeof buildDayItems> = [];
if (hasDay || hasMonth) {
  dayItems = buildDayItems(
    startParsed.date,
    endParsed.date,
    dayunList,
    dayMaster,
    bazi.八字,
    natalGanzhiArray
  );
}

let monthPeriods: ReturnType<typeof buildMonthPeriods> = [];
if (hasMonth) {
  monthPeriods = buildMonthPeriods(dayItems);
}

let hourPeriods: ReturnType<typeof buildHourPeriods> = [];
if (hasHour) {
  hourPeriods = buildHourPeriods(
    startParsed.date,
    endParsed.date,
    dayunList,
    dayMaster,
    bazi.八字,
    natalGanzhiArray
  );
}

const output = {
  meta: {
    generatedAt: new Date().toISOString(),
    inputEcho: input,
    normalizedRange: {
      startDateTime: formatDateHour(startParsed.date),
      endDateTime: formatDateHour(endParsed.date),
      startPrecision: startParsed.precision,
      endPrecision: endParsed.precision,
    },
    hourMode: hasHour ? 'shichen-like (merged contiguous same liushi)' : undefined,
  },
  natal: {
    八字: bazi.八字,
    日主: bazi.日主,
    起运日期: bazi.大运?.起运日期,
    起运年龄: bazi.大运?.起运年龄,
    大运: dayunList,
  },
  result: {
    years: yearItems,
    months: monthPeriods,
    days: hasDay ? dayItems : [],
    hours: hourPeriods,
  },
};

console.log(buildMarkdownReport(output));


path: scripts/buildBaziFromSolar.ts
content: import { baziToMarkdown, buildBaziFromSolar } from 'cantian-tymext';
import { parseArgs } from './util.ts';

const { time: solarTime, gender, sect } = parseArgs(process.argv);

const bazi = buildBaziFromSolar({ solarTime, gender, sect });
const md = baziToMarkdown(bazi);
console.log(md);


path: scripts/buildBaziFromLunar.ts
content: import { baziToMarkdown, buildBaziFromLunar } from 'cantian-tymext';
import { parseArgs } from './util.ts';

const { time: lunarTime, gender, sect } = parseArgs(process.argv);

const bazi = buildBaziFromLunar({ lunarTime, gender, sect });
const md = baziToMarkdown(bazi);
console.log(md);


path: scripts/util.ts
content: export interface ParseArgsResult {
  time: string;
  gender: 0 | 1;
  sect: 1 | 2;
}

export interface TrueSolarTimeResult {
  trueSolarTime: Date;
  meanSolarTime: Date;
  meanSolarOffsetSeconds: number;
  equationOfTimeSeconds: number;
  totalOffsetSeconds: number;
}

const CITY_LONGITUDE_BY_NAME: Record<string, number> = {
  台北: 121.5167,
  呼和浩特: 111.6333,
  包头: 110.0,
  海拉尔: 119.7167,
  太原: 112.55,
  临汾: 111.5167,
  大同: 113.2167,
  长治: 113.2167,
  石家庄: 114.4333,
  唐山: 118.15,
  秦皇岛: 119.6167,
  承德: 117.8667,
  保定: 115.4667,
  张家口: 114.9167,
  北京: 116.4,
  沈阳: 123.3833,
  鞍山: 123.0,
  锦州: 123.15,
  大连: 121.6333,
  长春: 125.3,
  吉林: 126.6,
  哈尔滨: 126.6333,
  牡丹江: 129.6,
  齐齐哈尔: 123.9167,
  上海: 121.4333,
  南京: 118.7667,
  无锡: 120.3,
  苏州: 120.65,
  徐州: 117.2,
  合肥: 117.2667,
  芜湖: 118.3333,
  安庆: 117.0333,
  济南: 117.0333,
  烟台: 121.3667,
  青岛: 120.3167,
  天津: 117.1667,
  杭州: 120.1667,
  绍兴: 120.6667,
  宁波: 121.5667,
  金华: 119.8167,
  温州: 120.6333,
  南昌: 115.8833,
  九江: 115.9833,
  赣州: 114.9333,
  福州: 119.3167,
  厦门: 118.0667,
  泉州: 118.6167,
  长沙: 112.9167,
  湘潭: 112.85,
  常德: 111.65,
  衡阳: 112.5667,
  武汉: 114.3333,
  监利: 112.8833,
  沙市: 112.2833,
  宜昌: 111.25,
  郑州: 113.7,
  新乡: 113.9,
  许昌: 113.8,
  洛阳: 112.4333,
  开封: 114.3833,
  广州: 113.3,
  珠海: 113.3,
  澳门: 113.3,
  韶关: 113.55,
  汕头: 116.6667,
  深圳: 113.55,
  香港: 114.1667,
  海口: 110.3167,
  南宁: 108.35,
  桂林: 110.1667,
  梧州: 111.3,
  柳州: 109.3167,
  贵阳: 106.7167,
  遵义: 106.8833,
  成都: 101.0667,
  重庆: 106.55,
  万县: 108.3667,
  内江: 105.05,
  泸州: 105.45,
  昆明: 102.7,
  西安: 108.9167,
  延安: 109.4333,
  宝鸡: 107.15,
  兰州: 103.8333,
  酒泉: 98.5,
  天水: 105.55,
  银川: 106.2167,
  西宁: 101.8167,
  乌鲁木齐: 87.6,
  克拉玛依: 84.85,
  哈密: 93.4833,
  和田: 79.9167,
  拉萨: 91.0333,
  达孜: 91.3833,
  林芝: 94.4667,
  曲水: 90.7333,
  贡嘎: 90.9667,
  林周: 91.2833,
  那曲: 92.05,
};

const TRUE_SOLAR_DIFF_SECONDS_BY_MMDD: Record<string, number> = {
  '01-01': -189,
  '01-02': -218,
  '01-03': -246,
  '01-04': -273,
  '01-05': -301,
  '01-06': -327,
  '01-07': -354,
  '01-08': -380,
  '01-09': -405,
  '01-10': -430,
  '01-11': -455,
  '01-12': -479,
  '01-13': -502,
  '01-14': -525,
  '01-15': -547,
  '01-16': -568,
  '01-17': -589,
  '01-18': -609,
  '01-19': -628,
  '01-20': -647,
  '01-21': -665,
  '01-22': -682,
  '01-23': -698,
  '01-24': -714,
  '01-25': -728,
  '01-26': -742,
  '01-27': -755,
  '01-28': -779,
  '01-29': -790,
  '01-30': -799,
  '01-31': -817,
  '02-01': -824,
  '02-02': -830,
  '02-03': -836,
  '02-04': -841,
  '02-05': -845,
  '02-06': -849,
  '02-07': -851,
  '02-08': -853,
  '02-09': -854,
  '02-10': -855,
  '02-11': -854,
  '02-12': -853,
  '02-13': -851,
  '02-14': -848,
  '02-15': -845,
  '02-16': -841,
  '02-17': -836,
  '02-18': -831,
  '02-19': -824,
  '02-20': -818,
  '02-21': -810,
  '02-22': -802,
  '02-23': -793,
  '02-24': -784,
  '02-25': -774,
  '02-26': -763,
  '02-27': -752,
  '02-28': -741,
  '02-29': -728,
  '03-01': -716,
  '03-02': -703,
  '03-03': -689,
  '03-04': -675,
  '03-05': -661,
  '03-06': -647,
  '03-07': -632,
  '03-08': -616,
  '03-09': -601,
  '03-10': -585,
  '03-11': -568,
  '03-12': -552,
  '03-13': -535,
  '03-14': -518,
  '03-15': -501,
  '03-16': -484,
  '03-17': -466,
  '03-18': -449,
  '03-19': -431,
  '03-20': -413,
  '03-21': -395,
  '03-22': -377,
  '03-23': -358,
  '03-24': -340,
  '03-25': -322,
  '03-26': -304,
  '03-27': -285,
  '03-28': -267,
  '03-29': -249,
  '03-30': -231,
  '03-31': -213,
  '04-01': -196,
  '04-02': -178,
  '04-03': -161,
  '04-04': -144,
  '04-05': -127,
  '04-06': -110,
  '04-07': -93,
  '04-08': -77,
  '04-09': -61,
  '04-10': 46,
  '04-11': 30,
  '04-12': 16,
  '04-13': 1,
  '04-14': 13,
  '04-15': 27,
  '04-16': 41,
  '04-17': 54,
  '04-18': 66,
  '04-19': 79,
  '04-20': 91,
  '04-21': 102,
  '04-22': 113,
  '04-23': 124,
  '04-24': 134,
  '04-25': 143,
  '04-26': 153,
  '04-27': 161,
  '04-28': 169,
  '04-29': 177,
  '04-30': 184,
  '05-01': 190,
  '05-02': 196,
  '05-03': 201,
  '05-04': 206,
  '05-05': 210,
  '05-06': 217,
  '05-07': 216,
  '05-08': 219,
  '05-09': 220,
  '05-10': 222,
  '05-11': 222,
  '05-12': 222,
  '05-13': 222,
  '05-14': 221,
  '05-15': 219,
  '05-16': 217,
  '05-17': 214,
  '05-18': 211,
  '05-19': 207,
  '05-20': 203,
  '05-21': 198,
  '05-22': 193,
  '05-23': 187,
  '05-24': 181,
  '05-25': 174,
  '05-26': 167,
  '05-27': 159,
  '05-28': 151,
  '05-29': 142,
  '05-30': 133,
  '05-31': 124,
  '06-01': 114,
  '06-02': 104,
  '06-03': 94,
  '06-04': 83,
  '06-05': 72,
  '06-06': 60,
  '06-07': 48,
  '06-08': 36,
  '06-09': 24,
  '06-10': 12,
  '06-11': 1,
  '06-12': 14,
  '06-13': 39,
  '06-14': 52,
  '06-15': -65,
  '06-16': -78,
  '06-17': -91,
  '06-18': -105,
  '06-19': -117,
  '06-20': -130,
  '06-21': -143,
  '06-22': -156,
  '06-23': -168,
  '06-24': -181,
  '06-25': -193,
  '06-26': -205,
  '06-27': -217,
  '06-28': -229,
  '06-29': -240,
  '06-30': -251,
  '07-01': -262,
  '07-02': -273,
  '07-03': -283,
  '07-04': -293,
  '07-05': -302,
  '07-06': -311,
  '07-07': -320,
  '07-08': -328,
  '07-09': -336,
  '07-10': -343,
  '07-11': -350,
  '07-12': -356,
  '07-13': -362,
  '07-14': -368,
  '07-15': -372,
  '07-16': -376,
  '07-17': -380,
  '07-18': -383,
  '07-19': -385,
  '07-20': -387,
  '07-21': -389,
  '07-22': -389,
  '07-23': -389,
  '07-24': -389,
  '07-25': -388,
  '07-26': -386,
  '07-27': -384,
  '07-28': -381,
  '07-29': -377,
  '07-30': -373,
  '07-31': -368,
  '08-01': -363,
  '08-02': -357,
  '08-03': -351,
  '08-04': -344,
  '08-05': -336,
  '08-06': -328,
  '08-07': -319,
  '08-08': -310,
  '08-09': -300,
  '08-10': -290,
  '08-11': -279,
  '08-12': -267,
  '08-13': -255,
  '08-14': -242,
  '08-15': -229,
  '08-16': -216,
  '08-17': -201,
  '08-18': -187,
  '08-19': -171,
  '08-20': -156,
  '08-21': -140,
  '08-22': -123,
  '08-23': -107,
  '08-24': -89,
  '08-25': -72,
  '08-26': 54,
  '08-27': 35,
  '08-28': 17,
  '08-29': 2,
  '08-30': 21,
  '08-31': 41,
  '09-01': 60,
  '09-02': 80,
  '09-03': 100,
  '09-04': 121,
  '09-05': 141,
  '09-06': 162,
  '09-07': 183,
  '09-08': 183,
  '09-09': 204,
  '09-10': 225,
  '09-11': 246,
  '09-12': 267,
  '09-13': 288,
  '09-14': 310,
  '09-15': 331,
  '09-16': 353,
  '09-17': 374,
  '09-18': 395,
  '09-19': 417,
  '09-20': 438,
  '09-21': 459,
  '09-22': 480,
  '09-23': 501,
  '09-24': 522,
  '09-25': 542,
  '09-26': 562,
  '09-27': 582,
  '09-28': 602,
  '09-29': 621,
  '09-30': 640,
  '10-01': 659,
  '10-02': 678,
  '10-03': 696,
  '10-04': 696,
  '10-05': 713,
  '10-06': 731,
  '10-07': 748,
  '10-08': 764,
  '10-09': 780,
  '10-10': 796,
  '10-11': 796,
  '10-12': 811,
  '10-13': 825,
  '10-14': 839,
  '10-15': 853,
  '10-16': 866,
  '10-17': 878,
  '10-18': 890,
  '10-19': 901,
  '10-20': 912,
  '10-21': 921,
  '10-22': 931,
  '10-23': 940,
  '10-24': 948,
  '10-25': 955,
  '10-26': 961,
  '10-27': 967,
  '10-28': 972,
  '10-29': 976,
  '10-30': 980,
  '10-31': 982,
  '11-01': 984,
  '11-02': 985,
  '11-03': 985,
  '11-04': 984,
  '11-05': 983,
  '11-06': 981,
  '11-07': 977,
  '11-08': 973,
  '11-09': 969,
  '11-10': 963,
  '11-11': 956,
  '11-12': 949,
  '11-13': 941,
  '11-14': 932,
  '11-15': 922,
  '11-16': 911,
  '11-17': 900,
  '11-18': 887,
  '11-19': 874,
  '11-20': 860,
  '11-21': 846,
  '11-22': 830,
  '11-23': 814,
  '11-24': 797,
  '11-25': 779,
  '11-26': 760,
  '11-27': 741,
  '11-28': 721,
  '11-29': 700,
  '11-30': 678,
  '12-01': 656,
  '12-02': 633,
  '12-03': 609,
  '12-04': 585,
  '12-05': 561,
  '12-06': 535,
  '12-07': 509,
  '12-08': 483,
  '12-09': 456,
  '12-10': 429,
  '12-11': 402,
  '12-12': 374,
  '12-13': 346,
  '12-14': 317,
  '12-15': 288,
  '12-16': 259,
  '12-17': 230,
  '12-18': 201,
  '12-19': 171,
  '12-20': 142,
  '12-21': 112,
  '12-22': 82,
  '12-23': 52,
  '12-24': 23,
  '12-25': 7,
  '12-26': 37,
  '12-27': -66,
  '12-28': -96,
  '12-29': -125,
  '12-30': -154,
  '12-31': -183,
};

export function parseArgs(args: string[]): ParseArgsResult {
  const [, , time, genderString, sectString] = args;

  let gender: 0 | 1 = 1;
  if (genderString) {
    gender = Number.parseInt(genderString, 10) as 0 | 1;
    if (isNaN(gender) || ![0, 1].includes(gender)) {
      throw new Error('性别参数无效。男性传 1，女性传 0。');
    }
  }

  let sect: 1 | 2 = 2;
  if (sectString) {
    sect = Number.parseInt(sectString, 10) as 1 | 2;
    if (isNaN(sect) || ![1, 2].includes(sect)) {
      throw new Error(
        '早晚子时配置参数无效。传 1 表示 23:00-23:59 日干支为明天，传 2 表示 23:00-23:59 日干支为当天。'
      );
    }
  }

  return { time, gender, sect };
}

function buildMonthDayKey(date: Date): string {
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  return `${month}-${day}`;
}

export function getEquationOfTimeSeconds(date: Date): number {
  const key = buildMonthDayKey(date);
  const value = TRUE_SOLAR_DIFF_SECONDS_BY_MMDD[key];
  if (value === undefined) {
    throw new Error(`无法找到真平太阳时差：${key}`);
  }
  return value;
}

export function getMeanSolarOffsetSeconds(longitude: number, standardLongitude = 120): number {
  if (!Number.isFinite(longitude)) {
    throw new Error('经度无效：longitude 必须是有限数字。');
  }

  if (longitude < -180 || longitude > 180) {
    throw new Error('经度超出范围：longitude 必须在 -180 到 180 之间。');
  }

  if (!Number.isFinite(standardLongitude)) {
    throw new Error('标准经度无效：standardLongitude 必须是有限数字。');
  }

  return Math.round((longitude - standardLongitude) * 4 * 60);
}

/**
 * 参考 http://www.zyqmdj.com/1450.html 中的换算说明：
 * 真太阳时 = 当地平太阳时 + 真平太阳时差
 * 当地平太阳时 = 标准时 + (当地经度 - 标准经度) * 4 分钟
 *
 * 注：源表中存在极少数明显录入错误，本实现做了最小纠偏：
 * - 秒数写成 60 的条目归一化（如 +12分60秒 => +13分0秒）
 * - 02-24、05-01、10-21 按上下文连续性修正
 */
export function convertBeijingTimeToTrueSolarTime(
  beijingTime: Date,
  longitude: number,
  standardLongitude = 120
): TrueSolarTimeResult {
  if (!(beijingTime instanceof Date) || Number.isNaN(beijingTime.getTime())) {
    throw new Error('时间参数无效：beijingTime 必须是有效的 Date。');
  }

  const meanSolarOffsetSeconds = getMeanSolarOffsetSeconds(longitude, standardLongitude);
  const meanSolarTime = new Date(beijingTime.getTime() + meanSolarOffsetSeconds * 1000);

  const equationOfTimeSeconds = getEquationOfTimeSeconds(meanSolarTime);
  const totalOffsetSeconds = meanSolarOffsetSeconds + equationOfTimeSeconds;
  const trueSolarTime = new Date(beijingTime.getTime() + totalOffsetSeconds * 1000);

  return {
    trueSolarTime,
    meanSolarTime,
    meanSolarOffsetSeconds,
    equationOfTimeSeconds,
    totalOffsetSeconds,
  };
}

const DATE_TIME_WITHOUT_TIMEZONE =
  /^(\d{4})-(\d{1,2})-(\d{1,2})T(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/;

function parseDateTimeWithoutTimezone(value: string): Date {
  const normalized = value.trim();
  const match = DATE_TIME_WITHOUT_TIMEZONE.exec(normalized);
  if (!match) {
    throw new Error(
      `时间格式无效：${value}。请使用不带时区的 YYYY-MM-DDTHH:mm[:ss]，例如 1990-05-15T14:30:00。`
    );
  }

  const year = Number.parseInt(match[1], 10);
  const month = Number.parseInt(match[2], 10);
  const day = Number.parseInt(match[3], 10);
  const hour = Number.parseInt(match[4], 10);
  const minute = Number.parseInt(match[5], 10);
  const second = match[6] ? Number.parseInt(match[6], 10) : 0;

  if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
    throw new Error(`时间值无效：${value}。请确认时分秒在有效范围内。`);
  }

  const date = new Date(year, month - 1, day, hour, minute, second);
  if (
    date.getFullYear() !== year ||
    date.getMonth() + 1 !== month ||
    date.getDate() !== day ||
    date.getHours() !== hour ||
    date.getMinutes() !== minute ||
    date.getSeconds() !== second
  ) {
    throw new Error(`时间值无效：${value}。请确认年月日和时分秒是实际存在的时间。`);
  }

  return date;
}

function pad2(value: number): string {
  return String(value).padStart(2, '0');
}

function formatDateTimeWithoutTimezone(date: Date): string {
  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(
    date.getMinutes()
  )}:${pad2(date.getSeconds())}`;
}

export function resolveCityLongitude(cityName: string): number | undefined {
  const normalized = cityName.trim();
  if (!normalized) {
    return undefined;
  }

  const direct = CITY_LONGITUDE_BY_NAME[normalized];
  if (direct !== undefined) {
    return direct;
  }

  const withoutSuffix = normalized.replace(/(市|地区|盟|自治州)$/u, '');
  return CITY_LONGITUDE_BY_NAME[withoutSuffix];
}

export function listSupportedCityNames(): string[] {
  return Object.keys(CITY_LONGITUDE_BY_NAME);
}

export function convertBeijingDateTimeStringToTrueSolarTime(
  beijingTimeString: string,
  longitude: number,
  standardLongitude = 120
): string {
  const beijingTime = parseDateTimeWithoutTimezone(beijingTimeString);
  const { trueSolarTime } = convertBeijingTimeToTrueSolarTime(beijingTime, longitude, standardLongitude);
  return formatDateTimeWithoutTimezone(trueSolarTime);
}


path: scripts/getChineseCalendar.ts
content: import { getChineseCalendar, getChineseCalendarMarkdown } from 'cantian-tymext';

function parseInputDate(input?: string): Date {
  if (!input) {
    return new Date();
  }

  const normalized = input.trim().replace(/\//g, '-');
  const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(normalized);
  if (!match) {
    throw new Error('日期格式无效。请传入 YYYY-MM-DD（也兼容 YYYY/MM/DD）。');
  }

  const year = Number.parseInt(match[1], 10);
  const month = Number.parseInt(match[2], 10);
  const day = Number.parseInt(match[3], 10);
  const date = new Date(year, month - 1, day);

  if (
    date.getFullYear() !== year ||
    date.getMonth() + 1 !== month ||
    date.getDate() !== day
  ) {
    throw new Error('日期值无效。请确认年月日是实际存在的日期。');
  }

  return date;
}

const dateArg = process.argv[2];
const date = parseInputDate(dateArg);
const chineseCalendar = getChineseCalendar({
  year: date.getFullYear(),
  month: date.getMonth() + 1,
  day: date.getDate(),
});
const markdown = getChineseCalendarMarkdown(chineseCalendar);
console.log(markdown);


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)