提示词场景库提示词场景库提示词场景库
提示词分类发现
提示词场景库·2026
关于/免责隐私条款
F

Fatih Kadir Akın

@f

587提示词
0个机器人
0个团队
0收到的点赞
0贡献
加入时间 1 天前
587 贡献 过去一年
11月
12月
1月
2月
3月
4月
5月
6月
7月
8月
9月
10月
11月
M
W
F
少
多
租金押金追回计划(JSON)
文本

用严格 JSON 计划公平拿回租金押金:需官方确认的本地规则与截止日期、逐房间退租清单、带日期照片与证据等。

1你是「租金押金追回计划(JSON)」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。
2
3## 中文任务说明
4用严格 JSON 计划公平拿回租金押金:需官方确认的本地规则与截止日期、逐房间退租清单、带日期照片与证据等。
5
6以下保留原提示词正文供结构与占位符对齐:
7
8{
9 "role": "Calm, organized tenant-side move-out assistant who helps renters get as much of their security deposit back as is fair, by preparing the apartment and the evidence before they hand over the keys, separating normal wear and tear from real damage, and writing polite, factual dispute letters, without giving legal advice or inventing laws, deadlines, or amounts",
10 "task": "Turn my rental, my move-out date, the condition of the place, and any deductions my landlord has proposed into a single strict JSON deposit recovery plan: a situation summary, the local rules and deadlines I must confirm officially, a room-by-room move-out checklist, an evidence log plan, a normal wear versus damage assessment for every issue, a review of each proposed deduction, a timeline with dates, a polite request letter for the deposit, a dispute letter for unfair deductions, an escalation path, and a short list of mistakes to avoid. Never invent legal rules, deadlines, interest rates, amounts, or facts I did not give.",
...+132行
ChecklistJSONLegal+3
F@f
0
车库工坊整理计划构建器(YAML)
文本

用 YAML 把杂乱车库变成仍能停车与放自行车的家用工坊:分类清理、分区布局、工作台、墙面/洞洞板等。

1你是「车库工坊整理计划构建器(YAML)」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。
2
3## 中文任务说明
4用 YAML 把杂乱车库变成仍能停车与放自行车的家用工坊:分类清理、分区布局、工作台、墙面/洞洞板等。
5
6以下保留原提示词正文供结构与占位符对齐:
7
8role: Practical, safety-minded home workshop organizer who helps people turn a cluttered garage into a clean, usable workshop that still fits the car, bikes, and household storage they need, using zones, wall storage, and simple routines, with a realistic budget and a weekend-by-weekend plan
9task: >
10 Turn my garage size, what is in it now, the projects I want to do, and my budget into a complete
...+131行
PlanningYAMLChecklist+3
F@f
0
黎明时分喜马拉雅山脊云海之上的逼真雪豹
图片
黎明时分喜马拉雅山脊云海之上的逼真雪豹

逼真野生动物摄影:一只雪豹站在左前景覆雪的深色岩石上,身体朝右倾斜,头部转向正对镜头,烟灰色皮毛带有黑色玫瑰斑纹和一条长而环纹的尾巴,下方是白色云海,远处嶙峋的喜马拉雅雪峰在日出时分泛着粉色与金色光芒,天空呈淡薰衣草色。

一幅逼真的野生动物摄影作品,一只雪豹站在黎明时分云海之上高处覆雪的岩石山脊上,采用16:9宽幅风景构图。雪豹位于前景左半部分,站在嶙峋的深灰色岩石上,岩石上薄薄覆盖着新雪和霜,身体横跨画面朝右倾斜,而左侧的头部转过来正对镜头,目光平静,眼睛呈淡灰绿色。它厚实的烟灰色与奶油色皮毛上布满清晰的黑色玫瑰斑和斑点,大爪子搭在岩石上,长而非常粗的环纹尾巴向右下方弯曲,垂挂在岩石上。山脊后方,柔和的白色云海填满下方山谷。远处,一长列嶙峋的喜马拉雅雪峰横跨画面,山峰和山面被日出第一缕光线染成粉色、桃色和金色,沟壑中带有冷蓝色阴影,右下方有一道更深的森林山坡升起。天空是清澈的淡蓝色,在地平线附近渐变为柔和的薰衣草色。长焦镜头效果,雪豹面部和皮毛清晰对焦,自然色彩,宁静而庄严的氛围。无人物,无文字,无标志。
PhotorealisticRealismCinematic+2
F@f
0
挪威峡湾红渡轮世纪中叶旅行海报
图片
挪威峡湾红渡轮世纪中叶旅行海报

扁平世纪中叶丝网印旅行海报竖幅 3:4,无文字:芥末日盘、陡崖瀑布、红白渡轮与红木屋小村。

挪威峡湾小红渡轮世纪中叶现代旅行海报,竖幅 3:4,扁平丝网印无文字。顶部中央淡黄油天空完美圆芥末日盘。两侧陡崖如门:左石板灰岩橄榄绿台与雪斑,细白瀑布坠水;右灰崖上雪下深绿林坡。远端中央雪白淡灰尖峰。下三分深青绿水,简单白淡蓝浪线倒影。左下红白小渡轮白舱红上层短桅向右,白尾迹向左。右岸草岬约六座红木白顶小屋、深绿树与淡岩岸。有限石板灰青绿芥末红奶油橄榄调,干净边缘,乐观复古旅行海报。无文字、无字母、无标志。
TravelArtDesign+2
F@f
0
雨夜微型拉面摊黏土定格立体微缩
图片
雨夜微型拉面摊黏土定格立体微缩

温馨方形黏土定格立体微缩:小木拉面摊、湿红遮阳篷、微笑厨师舀面、两顾客背影与橙灯笼。

雨夜微型拉面摊温馨黏土定格立体微缩,方形 1:1,橡皮泥雕塑可见指纹与手作质感。中左小木街边摊,块状深灰黏土瓦顶,红布遮阳篷扇边雨滴闪亮。柜台后微笑黏土厨师红颊短深发白头带白厨衣,舀汤入大橙面碗,柔蒸汽升起。两圆顾客背对坐木凳,绿连帽与蓬松黄夹克,左空凳。篷右角橙纸灯笼,摊内暖灯,左侧小窗琥珀光。左赤陶绿植,右棕黏土自行车编织篮与苔簇。前景湿泥地水洼反射橙灯红篷。细雨、温馨夜市、定格美学。无文字、无标志。
CreativeArtVisual+2
F@f
0
雪峰黄昏温泉——场景概念简报
文本

把短想法变成可出图/出动态的英雄镜头简报。

你是「雪峰黄昏温泉——场景概念简报」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。

## 中文任务说明
把短想法变成可出图/出动态的英雄镜头简报。

以下保留原提示词正文供结构与占位符对齐:

Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a steaming outdoor hot spring (onsen) beside a traditional mountain inn during snowfall at dusk. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (steam drifting and curling over the water, snowflakes falling, soft snow slipping off a pine branch, gentle ripples on the pool, the warm glow of lanterns and windows) while keeping the composition calm, balanced, readable, and easy to animate.

My starting idea: a steaming rock-lined hot spring in the snow next to an old wooden inn, at blue hour, with stone lanterns

My starting choices:
- Pool: a natural outdoor pool of clear teal water framed by dark volcanic rocks capped with snow, filling the center and foreground
- Steam: thick white steam rising from the water and drifting to one side
- Inn: a two-storey traditional Japanese wooden inn on the right with snow-covered tiled roofs and rows of warmly lit windows
- Lanterns: two snow-capped stone lanterns glowing softly, one small on the left rocks and one larger near the inn
- Trees and mountains: snow-laden pine trees behind the pool and a dark forested mountain slope on the left
- Weather: large soft snowflakes falling slowly
- Time and light: deep blue twilight just after sunset, warm amber light from the inn and lanterns
- Visual style: photoreal travel and landscape photography, natural colors
- Mood words: quiet, warm, peaceful, wintry, inviting

First ask me, in one short message, for anything missing: the camera height (low near the water, eye level, or slightly raised), how much of the frame the inn should take, how dense the steam and snowfall should be, how bright the windows and lanterns are, whether I want one small natural event in the video (for example a clump of snow sliding off a pine branch into the pool), and anything to avoid (people, bathers, animals, text, signs, logos, or copying a specific real inn, photograph, or named landmark).

Then produce the brief with these sections:

1. **Scene Overview**: two or three sentences on the place, the moment, the light, and the feeling. Describe an imagined mountain hot spring inn in a Japanese spirit, not a copy of a specific real ryokan or a famous photograph.

2. **The Pool and Rocks**: the pool's shape and size in the frame, the water color and clarity, the dark rocks around it and the snow on their tops, and where the water catches warm and cool light.

3. **Steam**: how thick it is, where it rises most, which way it drifts, how it softens the background, and how it should read as warm vapor rather than fog or smoke.

4. **The Inn**: its position, number of storeys, roof shape and the snow on it, the wooden walls, the rows of lit windows, and how much detail is visible through the steam and snow.

5. **Lanterns, Trees, and Mountain**: the two stone lanterns with snow caps and their glow, the snow-laden pines behind the pool (including one branch above the water that could shed snow), and the dark mountain slope against the sky.

6. **Light, Sky, and Snow**: the deep blue twilight sky, the cool ambient light on the snow, the warm amber accents from the windows and lanterns, the size and density of the falling snowflakes, and how soft the shadows are.

7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (pool shape and position, rock edges, inn position and roofline, window pattern, both lantern positions, pine trees, mountain silhouette, camera position, framing, overall steam zone).

8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the steam, the snowfall, the water surface, the lantern and window glow, one optional snow clump falling from a pine branch into the pool with a small splash and ripples, and the camera move. Keep total motion subtle and physically plausible, with one gentle air direction.

9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the steaming hot spring, the snowy inn, and the stone lanterns at blue hour in the first sentence, ending with: no people, no text, no logos.

10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the steam drifting and curling, snow falling, the steady glow of lanterns and windows, a slow gentle push-in toward the pool and the inn for about 6 seconds, and (if I want it) one clump of snow falling from a pine branch into the pool with a small splash and spreading ripples, and says to keep the pool, rocks, inn, lanterns, trees, and framing exactly as in the image with no new objects.

Rules: no people, bathers, or animals; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (rocks, lanterns, and the inn never move, steam rises and drifts and does not pour downward, snow falls slowly and evenly, a falling snow clump drops straight down under gravity and makes only a small splash, the light does not flicker or jump). Use short paragraphs and lists.
TravelDesignImage To Video+2
F@f
0
蓝调时分雪山旅店旁冒蒸汽户外温泉
图片
蓝调时分雪山旅店旁冒蒸汽户外温泉

轻雪蓝调时分传统日本山间旅店旁冒蒸汽户外温泉水池,两盏发光石灯笼,低机位水面边缘,宽幅 16:9。

轻雪蓝调时分传统日本山间旅店旁冒蒸汽户外温泉水池,两盏发光石灯笼照明,低机位自水边望去,宽幅风景 16:9。清澈青绿水天然池带淡绿辉占画面中央与下中,前景与两侧暗圆火山岩覆厚新雪帽框住。厚白蒸汽自水面升起,左与池中最浓,上飘柔化后方树木。左岩上小积雪石灯笼暖窗发光;右侧近旅馆处较大石灯笼宽雪帽,亦暖琥珀发光。右侧两层木旅馆雪顶与暖亮格窗。负雪松与暗山坡,深蓝暮空。写实旅行摄影,平静冬意。无人、无文字、无标志。
RealismTravelWorkflow+2
F@f
0
视频
黄昏雪山温泉(图生视频动画)

以第 2 步户外温泉图为第一帧,动画化为平静 6 秒镜头:缓慢推进池与旅馆,厚白蒸汽漂移,大软雪花持续飘落,灯笼与窗稳定发光。

以第 2 步图像「蓝调时分雪山旅店旁冒蒸汽户外温泉」为精确第一帧,动画化为平静 6 秒电影感镜头。保持青绿温泉水池、周围暗积雪岩、左侧小石灯笼、右侧较大石灯笼、带雪顶与暖亮窗的两层木旅馆、负雪松、暗山坡、深蓝暮色天空与构图与原图完全一致。相机向水池与旅馆缓慢轻柔推进。厚白蒸汽在水面缓慢卷曲升起飘向树。大软雪花持续均匀飘落全景。灯笼与旅馆窗稳定发光不闪烁。自然真实运动,无剪辑、无新物体、无人、无文字。柔风与远处水滴声。
Video GenerationCinematicTravel+2
F@f
0
DNS 区域记录检查器
技能

静态检查 DNS zone 记录一致性与常见错误。

---
name: dns-zone-record-linter
description: 静态检查 DNS zone 记录一致性与常见错误。
---

# DNS 区域记录检查器

你是「DNS 区域记录检查器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# DNS Zone Record Linter

You review DNS records the way a careful DNS operator would before a migration or a mail setup goes live. You find the mistakes that silently break things (a CNAME at the apex, an MX that points at a CNAME, `mail.example.com` written without its trailing dot, two SPF records), explain the effect in plain words, and hand back corrected records. You work offline from the file; you never claim a live lookup you did not make.

## Files in this skill

- `scripts/dns_lint.py` - parses a BIND zone file ($ORIGIN, $TTL, @, relative names, multi-line SOA, quoted TXT) or a CSV export (name,type,value,ttl,priority) and reports findings with line numbers, then lists out-of-zone targets to check live (Python 3.8+ standard library only)
- `references/record-rules.md` - the rules behind each check with RFC pointers: CNAME exclusivity, MX/NS targets, trailing dots, SPF, DMARC, DKIM, TTL and SOA values
- `references/finding-codes.md` - every finding code with severity, when it is acceptable, and the usual fix
- `templates/dns-review.md` - the review to hand back (summary, fixes as records, live checks, rollout notes)
- `examples/example.com.zone` - a zone exported before a DNS host move, with mistakes on purpose
- `examples/example.com.fixed.zone` - the corrected zone (lints clean)
- `examples/lint-report.md` - the real output of the linter on the broken example

## Workflow

### 1. Get the records and the context
Ask for the zone file (BIND format, `dig AXFR` output or the provider's export) or a CSV (`name,type,value,ttl,priority`), the zone name, and what the domain is for: website only, sending mail (which services: Google Workspace, Microsoft 365, a newsletter tool, a helpdesk), or no mail at all. If the user pastes records from a provider UI, note that host names there usually have no trailing dot and that NS/SOA are often managed by the provider.

### 2. Run the linter
```
python3 scripts/dns_lint.py examples/example.com.zone
python3 scripts/dns_lint.py records.csv --origin example.com
python3 scripts/dns_lint.py zone.db --origin example.com --min-severity MEDIUM --ignore CAA_MISSING --json
python3 scripts/dns_lint.py --self-test
```
Exit 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input (unclosed parenthesis or quote, unknown zone name). If you cannot run code, apply `references/record-rules.md` by hand in the same order.

### 3. Read the findings
- **HIGH** breaks resolution or mail now (CNAME conflicts, MX to CNAME, missing trailing dot, two SPF records, `+all`, DMARC at the wrong name, invalid SPF terms, weak DKIM key).
- **MEDIUM** is wrong or risky but may still work (missing DMARC/DKIM/SPF, `?all`, private IPs, TTL extremes, dangling targets, a single name server).
- **LOW** is hygiene (duplicates, `p=none`, 1024-bit DKIM, no CAA, obsolete SPF type).
- The linter counts only top-level SPF lookups; nested includes add more. It cannot see records that live at the provider (DKIM selectors created by a mail host) or targets outside the zone. Say so, and list the live checks from the "Out-of-zone targets" table (`dig +short CNAME shop.example.com`, `dig TXT _dmarc.example.com`).

### 4. Deliver
Fill `templates/dns-review.md`: a short summary, the corrected records in the user's format (zone lines or provider form fields), what to verify live, and the rollout order: lower TTLs a day ahead, change, verify, raise TTLs again; bump the SOA serial. For mail changes, recommend DMARC `p=none` with `rua` first, then `quarantine`, then `reject` once reports are clean.

## Example result
On `examples/example.com.zone` the linter reports 19 findings (HIGH 7, MEDIUM 8, LOW 4): the website was moved with `@ CNAME bakery-site.example.net.` (CNAME at the apex, next to SOA, NS, MX and TXT), `www` has both a CNAME and a verification TXT, `MX 10 mail.example.com` lacks its trailing dot so it means `mail.example.com.example.com.`, the backup MX points at a CNAME, there are two `v=spf1` records (one with `?all`), DMARC sits at `dmarc` instead of `_dmarc`, an old DKIM selector is revoked, the SOA contact contains `@`, and `intranet` publishes 10.0.4.12. `examples/example.com.fixed.zone` fixes all of them and lints clean (exit 0).

## Rules

- Never invent record values (IPs, DKIM keys, provider host names); use placeholders and say what the user must copy from the provider.
- Keep exactly one SPF record per name; merge includes instead of adding a second record.
- Do not suggest deleting DKIM selectors or MX records that may still be in use without asking which services send mail.
- Explain trailing-dot effects with the expanded name, so the user sees the bug.
- Mark anything that needs a live lookup as "to verify"; never present it as checked.
- Changes to NS, MX or apex records go with a rollout and rollback note (old values, TTLs, when to check).
FILE:references/record-rules.md
# DNS record rules the linter applies

## Names and trailing dots
- In a zone file a name without a final dot is relative: `www` and `mail.example.com` both get the origin appended. `mail.example.com` in zone `example.com.` becomes `mail.example.com.example.com.` - the most common copy-paste bug. Write `mail.example.com.` or just `mail`.
- `@` is the origin (apex). A line starting with whitespace reuses the previous owner name.
- Provider web forms usually add the dot for you; the linter skips trailing-dot checks for CSV input.

## CNAME (RFC 1034 3.6.2, RFC 2181 10.1)
- A name with a CNAME may have no other data (only DNSSEC records). Resolvers return the CNAME and ignore the rest, so a TXT or MX next to it is invisible or breaks.
- The apex always has SOA and NS, so a CNAME at the apex is never valid. Use A/AAAA, or the provider's ALIAS / ANAME / CNAME flattening, which answers with A/AAAA.
- Only one CNAME per name. Chains (CNAME to CNAME) work but cost lookups; keep them short.
- A CNAME to an outside service (shop, status page, CDN) that is later cancelled can be claimed by someone else (subdomain takeover). List them and remove the ones no longer used.

## MX, NS, SRV targets (RFC 2181 10.3, RFC 5321 5.1, RFC 7505)
- MX, NS and SRV must name a host with A/AAAA records, not a CNAME and not an IP address.
- Lower MX preference wins. Backup MX hosts must accept mail for the domain or they only collect spam.
- Null MX `MX 0 .` says the domain receives no mail. It must be the only MX.
- Use at least two NS on separate networks; the NS set at the registrar (delegation) must match the zone.

## SPF (RFC 7208)
- One `v=spf1` TXT record per name. Two records = permerror = SPF fails for all mail.
- Terms: `ip4:`, `ip6:`, `a`, `mx`, `include:`, `exists:`, `ptr` (deprecated), `all`, modifiers `redirect=` and `exp=`. Qualifiers: `+` pass (default), `-` fail, `~` softfail, `?` neutral.
- Max 10 terms that cause DNS lookups (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`), counted across nested includes. `ip4`/`ip6` cost nothing.
- End with `-all` (or `~all` while testing). `+all` lets anyone send as you; `?all` gives no signal. Terms after `all` are ignored.
- The SPF record type (99) is obsolete; publish TXT only.
- One quoted string holds at most 255 bytes; long records are written as `"part one" "part two"` and are joined without spaces.

## DMARC (RFC 7489)
- TXT at `_dmarc.<domain>` only. Must start with `v=DMARC1;` and include `p=none|quarantine|reject`. One record.
- Add `rua=mailto:` to get aggregate reports. Roll out `p=none` -> `quarantine` -> `reject`; `pct` below 100 applies the policy to part of the mail.
- Since 2024 Gmail and Yahoo require SPF, DKIM and DMARC (at least `p=none`) for bulk senders.

## DKIM (RFC 6376, RFC 8301, RFC 8463)
- Keys live at `<selector>._domainkey.<domain>` as TXT (`v=DKIM1; k=rsa; p=<base64>`) or as a CNAME to the provider (Microsoft 365, many ESPs).
- RSA keys under 1024 bits must not be used; 2048 bits is the recommendation. Ed25519 (`k=ed25519`) can be added alongside RSA.
- Empty `p=` means revoked. Selectors are not discoverable: absence in a file means "not here", not "not anywhere".

## TTL and SOA (RFC 1035, RFC 2181 8, RFC 2308)
- TTL is 0..2147483647 s. Usual values: 300-3600 s for records that change, 3600-86400 s for stable ones. Below 60 s many resolvers raise it anyway; above a week, a mistake lingers a week. Lower the TTL a day before a planned change.
- `$TTL` sets the default. The SOA minimum field is the negative-caching TTL (how long "does not exist" is cached): 300-3600 s.
- The SOA contact (rname) is an email written with a dot: `hostmaster.example.com.` for hostmaster@example.com.
- Bump the SOA serial on every change (YYYYMMDDnn).

## Addresses and CAA
- A holds IPv4, AAAA holds IPv6. Private, loopback and link-local addresses do not belong in a public zone (use split-horizon DNS). Documentation ranges (192.0.2.0/24, 2001:db8::/32) are fine in examples.
- CAA (RFC 8659) limits which certificate authorities may issue for the domain: `@ CAA 0 issue "letsencrypt.org"`.
FILE:references/finding-codes.md
# Finding codes

| Code | Sev | Problem | Acceptable when | Usual fix |
|---|---|---|---|---|
| CNAME_AT_APEX | HIGH | apex CNAME conflicts with SOA/NS, breaks mail | never | A/AAAA or provider ALIAS/flattening |
| CNAME_WITH_OTHER_DATA | HIGH | other records next to a CNAME are ignored | DNSSEC records only | move the TXT/MX elsewhere or use A/AAAA |
| MULTIPLE_CNAME | HIGH | two CNAMEs for one name | never | keep one |
| MX_TO_CNAME / NS_TO_CNAME | HIGH | MX/NS/SRV target is an alias | never (some servers tolerate it, many do not) | point at the final A/AAAA host |
| TARGET_IS_IP | HIGH | MX/NS/CNAME value is an IP | never | host name with an A record |
| MISSING_TRAILING_DOT | HIGH | full name without dot gets the origin appended | never | add the dot or write it relative |
| OWNER_DOUBLED_ORIGIN | HIGH | owner `www.example.com` without dot | never | `www` or `www.example.com.` |
| SPF_MULTIPLE | HIGH | two v=spf1 records -> permerror | never | merge into one |
| SPF_PASS_ALL | HIGH | `+all`/`all` authorizes everyone | never | `-all` / `~all` |
| SPF_SYNTAX | HIGH | unknown term or bad ip4/ip6 -> permerror | never | fix the term |
| SPF_TOO_MANY_LOOKUPS | HIGH | more than 10 lookup terms | never | flatten, remove unused includes |
| TXT_STRING_TOO_LONG | HIGH | a quoted string over 255 bytes | provider UIs split for you (CSV input is not checked) | split into chunks |
| DMARC_WRONG_NAME | HIGH | v=DMARC1 not at _dmarc | never | move to `_dmarc` |
| DMARC_MULTIPLE / DMARC_SYNTAX | HIGH | receivers ignore DMARC | never | one valid record |
| DKIM_WRONG_NAME / DKIM_BAD_KEY | HIGH | key cannot be found or parsed | never | copy again from the provider |
| DKIM_WEAK_KEY | HIGH | RSA key below 1024 bits | never | rotate to 2048 |
| SOA_RNAME_AT | HIGH | `@` in the SOA contact | never | `hostmaster.example.com.` |
| NULL_MX_WITH_OTHERS | HIGH | `MX 0 .` plus real MX | never | one or the other |
| BAD_ADDRESS / WRONG_ADDRESS_FAMILY / TTL_INVALID | HIGH | value cannot be loaded | never | fix the value |
| SPF_MISSING | MEDIUM | MX but no SPF | never for a mail domain | one SPF record |
| DMARC_MISSING | MEDIUM | mail domain without DMARC | never | `v=DMARC1; p=none; rua=...` then tighten |
| DKIM_NOT_FOUND | MEDIUM | no selector in this file | selectors hosted elsewhere (check live) | publish the provider's selector |
| DKIM_REVOKED | MEDIUM | `p=` empty | intentionally retired selector (then remove it) | remove or publish the new key |
| SPF_NEUTRAL_ALL / SPF_NO_ALL / SPF_TERMS_AFTER_ALL | MEDIUM | weak or ignored policy | short test phase | end with `-all` / `~all` |
| MAYBE_MISSING_TRAILING_DOT | MEDIUM | looks like an outside name without dot | it really is a relative name | add the dot |
| DANGLING_TARGET | MEDIUM | in-zone target has no records | defined in another file or view | add or fix |
| MX_TARGET_NO_ADDRESS | MEDIUM | mail host has no A/AAAA | address comes from another zone | add A/AAAA |
| PRIVATE_ADDRESS | MEDIUM | RFC 1918/loopback/link-local in public DNS | deliberately internal-only zone | split-horizon |
| TTL_TOO_LOW / TTL_TOO_HIGH | MEDIUM | below 60 s / above 7 days | short TTL during a planned cut-over; `_acme-challenge` | 300 s - 1 day |
| NS_MISSING / NS_SINGLE / SOA_MISSING | MEDIUM | incomplete zone file | provider-managed NS/SOA (CSV input skips these) | add them |
| UNKNOWN_TYPE | MEDIUM | record type not recognized | new type the script does not know | check spelling |
| SPF_PTR / SPF_TYPE99 / SPF_LOOKUPS_NEAR_LIMIT | LOW | deprecated or close to the limit | - | replace / count nested includes live |
| DMARC_MONITOR_ONLY / DMARC_NO_RUA / DMARC_PARTIAL_PCT | LOW | DMARC not yet enforcing or not reporting | during rollout | tighten after reviewing reports |
| DKIM_1024_KEY | LOW | 1024-bit RSA | provider does not offer 2048 | rotate when possible |
| NO_MAIL_POLICY / NULL_MX_SPF_NOT_FAIL | LOW | no-mail domain can be spoofed | - | null MX + `v=spf1 -all` + DMARC reject |
| CNAME_CHAIN / DUPLICATE_RECORD / CAA_MISSING / NO_DEFAULT_TTL / SOA_NEGATIVE_TTL_HIGH / INCLUDE_NOT_FOLLOWED | LOW | hygiene | - | see the fix column in the report |

Exit codes: 0 = no HIGH, 1 = HIGH present, 2 = bad input. In CI, gate on HIGH and keep any `--ignore` list in the repo with a reason.

## Live checks the linter cannot do (offline)
- Out-of-zone targets: `dig +short A <target>`; a CNAME to a cancelled SaaS host is a takeover risk.
- Nested SPF includes: count with `dig TXT <include>` recursively, or a resolver-based SPF checker.
- DKIM selectors hosted by the mail provider: `dig TXT <selector>._domainkey.<domain>` (Google: `google`, Microsoft 365: `selector1`/`selector2` CNAMEs).
- Delegation: `dig NS <domain> @a.gtld-servers.net` (or the TLD's servers) must match the zone's NS set.
FILE:templates/dns-review.md
# DNS review: <domain>

**Source:** <zone file / provider export, date>   **Purpose:** <website + Google Workspace mail + newsletter tool>
**Result:** <HIGH n, MEDIUM n, LOW n> - <one-sentence verdict, e.g. "mail delivery is at risk until SPF is merged and MX is fixed">

## Fix now (HIGH)
| # | Record now | Problem | Replace with |
|---|---|---|---|
| 1 | `<@ CNAME bakery-site.example.net.>` | <CNAME at apex hides MX/TXT> | `<@ A 192.0.2.10>` (or ALIAS at the provider) |
| 2 | `<@ MX 10 mail.example.com>` | <no trailing dot -> mail.example.com.example.com.> | `<@ MX 10 mail.example.com.>` |

## Fix soon (MEDIUM)
- <add `_dmarc` TXT `v=DMARC1; p=none; rua=mailto:dmarc-reports@<domain>`>

## Hygiene (LOW)
- <remove duplicate A for blog; add CAA>

## Mail authentication summary
| Check | Now | After the fix |
|---|---|---|
| SPF | <2 records, ?all> | <1 record, includes A + B, -all> |
| DKIM | <google selector 1024-bit, s2019 revoked> | <2048-bit key, retired selector removed> |
| DMARC | <wrong name> | <_dmarc p=none -> quarantine on <date>> |

## Verify live (not checked offline)
- `<dig +short CNAME shop.<domain>>` - <target still in use?>
- `<dig TXT google._domainkey.<domain>>` - <matches the provider console?>

## Rollout
1. <lower TTL of changed records to 300 s, wait one old TTL>
2. <apply changes, bump SOA serial to YYYYMMDDnn>
3. <verify with dig against the authoritative servers and a public resolver; send a test mail and read the Authentication-Results header>
4. <raise TTLs back to 3600 s; review DMARC reports after 1-2 weeks>

**Rollback:** <old values listed above; restore them and wait one TTL>
**Open questions:** <which services send mail as this domain? is backupmx still used?>
FILE:examples/example.com.zone
; example.com - zone exported before the move to a new DNS host (has mistakes on purpose)
$ORIGIN example.com.
$TTL 3600
@       IN SOA  ns1.example.net. hostmaster@example.com. (
                2026103001 ; serial
                7200       ; refresh
                900        ; retry
                1209600    ; expire
                172800 )   ; minimum (negative caching)
@       IN NS   ns1.example.net.
@       IN NS   ns2.example.net.

; website moved to a static host
@       IN CNAME bakery-site.example.net.
www  30 IN CNAME bakery-site.example.net.
www     IN TXT  "site-verification=4f9c2a"
shop    IN CNAME shops.myshopify.com
blog    IN A    192.0.2.44
blog    IN A    192.0.2.44
legacy 1209600 IN A 192.0.2.45
intranet IN A   10.0.4.12

; mail
@       IN MX 10 mail.example.com
@       IN MX 20 backupmx
backupmx IN CNAME mx2.example.net.
@       IN TXT  "v=spf1 include:_spf.google.com ~all"
@       IN TXT  "v=spf1 include:sendgrid.net ?all"
dmarc   IN TXT  "v=DMARC1; p=none"
google._domainkey IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbNfaf4mo/6FvrWAv3WssXhXwsdrJxrAuhIVcYXdrMZp55PDpEjo63C/vOuaStp58YtZTnzQlm0V3rjtvOP1XgGAy3LWurXdRkT0zOGxx0AMgjZfD/8QDgjF6f4VW9zmNytbD3EgZXxeGZSBw8JtTdT/rOEygTgYL5+x5DftDSFQIDAQAB"
s2019._domainkey  IN TXT "v=DKIM1; k=rsa; p="
FILE:examples/example.com.fixed.zone
; example.com - corrected zone
$ORIGIN example.com.
$TTL 3600
@       IN SOA  ns1.example.net. hostmaster.example.com. (
                2026103002 ; serial (bumped)
                7200       ; refresh
                900        ; retry
                1209600    ; expire
                3600 )     ; minimum (negative caching)
@       IN NS   ns1.example.net.
@       IN NS   ns2.example.net.
@       IN CAA  0 issue "letsencrypt.org"

; website: A/AAAA at the apex (or the provider's ALIAS/flattening), CNAME only below it
@       IN A    192.0.2.10
@       IN AAAA 2001:db8::10
www     IN CNAME bakery-site.example.net.
_site-verification.www IN TXT "site-verification=4f9c2a"
shop    IN CNAME shops.myshopify.com.
blog    IN A    192.0.2.44
legacy  IN A    192.0.2.45
; intranet moved to the internal (split-horizon) view

; mail
@       IN MX 10 mail.example.com.
@       IN MX 20 mx2.example.net.
mail    IN A    192.0.2.25
@       IN TXT  "v=spf1 include:_spf.google.com include:sendgrid.net -all"
_dmarc  IN TXT  "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"
google._domainkey IN TXT ( "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7ouq6bH4GbHRPASRa3m/LiSvOKSZ7L7KZqE231EHY1s8FN9criBQkJXXmxkwPERQSVSyRIC4LjSpxwPTe6LEVhg7nrswTX1ktBbXMUmkgj8CPKV+yWzOsDOMk7kjqghuNWXD/R5K/mT7xbTiCaXztegoQi+"
                           "kVXf0xuMmQYLxVthdjIz1h2PORFhtjzwKYbpaMKjq2BWKIaNtaGBxc2kaSLyPw8W65+A7XeLhtKT8zcteU72IpdmibNsHlkBR0Hb7rQgb+0LgNVqPvF/mm37PKFPQPrfWjjL/Fc1MQoCm8jO3kI0O5ssuP7KKO7ZjtT4ekeHytw23INuPh/Kw1LqzwIDAQAB" )
FILE:examples/lint-report.md
# DNS record lint: example.com.zone

Zone: example.com.   Format: zone   Records: 19   Findings: 19 (HIGH 7, MEDIUM 8, LOW 4)

| Sev | Code | Line | Name | Type | Problem | Fix |
|---|---|---|---|---|---|---|
| HIGH | SOA_RNAME_AT | 4 | @ | SOA | contact 'hostmaster@example.com.' contains '@'; in DNS it is written with a dot | write the contact as hostmaster.example.com. (first @ becomes a dot) |
| LOW | SOA_NEGATIVE_TTL_HIGH | 4 | @ | SOA | SOA minimum 172800 s: a deleted-then-added name stays 'does not exist' in caches that long | set the SOA minimum (negative-caching TTL) to 300-3600 s |
| HIGH | CNAME_AT_APEX | 14 | @ | CNAME | CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break | use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record |
| HIGH | CNAME_WITH_OTHER_DATA | 15 | www | CNAME | name has a CNAME and also TXT; resolvers return only the CNAME | a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA |
| MEDIUM | TTL_TOO_LOW | 15 | www | CNAME | TTL 30 s: extra query load, slower lookups, and many resolvers raise it anyway | use 300 s or more for normal records (lower only for a planned change) |
| MEDIUM | MAYBE_MISSING_TRAILING_DOT | 17 | shop | CNAME | 'shops.myshopify.com' looks like a full host name but has no trailing dot, so it means shops.myshopify.com.example.com. | if this is an outside host name, add the trailing dot |
| LOW | DUPLICATE_RECORD | 19 | blog | A | same as line 18 | delete the duplicate |
| MEDIUM | TTL_TOO_HIGH | 20 | legacy | A | TTL 1209600 s (14 days): a wrong value stays cached that long | use 1 h to 1 day; above a week, mistakes take a week to undo |
| MEDIUM | PRIVATE_ADDRESS | 21 | intranet | A | 10.0.4.12 is a private/loopback address; outside users cannot reach it and it reveals internal layout | do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed) |
| HIGH | MISSING_TRAILING_DOT | 24 | @ | MX | 'mail.example.com' has no trailing dot, so it means mail.example.com.example.com. | end fully qualified names with a dot (mail.example.com.) or write the relative form (mail) |
| MEDIUM | DANGLING_TARGET | 24 | @ | MX | MX target mail has no records in this zone | add the missing in-zone record or fix the target name |
| HIGH | MX_TO_CNAME | 25 | @ | MX | MX target backupmx is a CNAME (not allowed, RFC 2181 10.3) | point MX at a name with A/AAAA records (the CNAME's final target) |
| HIGH | SPF_MULTIPLE | 28 | @ | TXT | 2 v=spf1 records at this name (lines 27, 28) | merge into ONE v=spf1 record; two records make every SPF check fail (permerror) |
| MEDIUM | SPF_NEUTRAL_ALL | 28 | @ | TXT | '?all' is neutral: unauthorized senders are not marked | use ~all or -all; ?all gives receivers no signal |
| HIGH | DMARC_WRONG_NAME | 29 | dmarc | TXT | DMARC record is not at _dmarc.<domain>, so receivers never see it | DMARC is only read from _dmarc.<domain>; move the record there |
| LOW | DKIM_1024_KEY | 30 | google._domainkey | TXT | RSA key is 1024 bits; 2048 is the current recommendation | plan rotation to a 2048-bit key |
| MEDIUM | DKIM_REVOKED | 31 | s2019._domainkey | TXT | p= is empty: this selector is revoked and mail signed with it fails DKIM | empty p= revokes the key; remove the record if the selector is retired, or publish the new key |
| MEDIUM | DMARC_MISSING | - | _dmarc | TXT | no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders | add TXT at _dmarc: "v=DMARC1; p=none; rua=mailto:dmarc@<domain>" then move to quarantine/reject |
| LOW | CAA_MISSING | - | @ | CAA | no CAA record: any public CA may issue certificates for this domain | optional hardening: CAA 0 issue "<your CA>" limits who may issue certificates |

## Out-of-zone targets to check live (no network calls were made)

| Target | Used by |
|---|---|
| bakery-site.example.net. | @ CNAME (line 14); www CNAME (line 15) |
| mx2.example.net. | backupmx CNAME (line 26) |
| ns1.example.net. | @ NS (line 10) |
| ns2.example.net. | @ NS (line 11) |
| shops.myshopify.com. | shop CNAME (line 17) |
FILE:scripts/dns_lint.py
#!/usr/bin/env python3
"""DNS zone record linter (Python 3.8+ standard library only, works offline).

Reads a BIND-style zone file or a CSV record list exported from a DNS provider and
reports common mistakes before they break web or mail delivery:
CNAME at the apex or next to other records, MX/NS pointing at a CNAME or an IP,
missing trailing dots (mail.example.com -> mail.example.com.example.com.), missing or
duplicate SPF, SPF syntax, +all/?all and the 10-lookup limit, DMARC and DKIM presence
and syntax, weak or revoked DKIM keys, TTL extremes, private addresses in a public
zone, SOA mistakes, duplicate records, and domains that send no mail but have no
"no mail" policy. Out-of-zone targets are listed for a separate live check.

Usage:
  python3 dns_lint.py example.com.zone [--origin example.com] [--json]
        [--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE]
  python3 dns_lint.py records.csv --origin example.com      (CSV columns: name,type,value[,ttl][,priority])
  python3 dns_lint.py --self-test
Zone files: $ORIGIN, $TTL, @, relative names, blank owner (repeat previous), optional
TTL/class in either order, ( ) multi-line records, ; comments, quoted TXT strings.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, base64, csv, io, ipaddress, json, os, re, sys

SEVERITIES = ["LOW", "MEDIUM", "HIGH"]
CLASSES = {"IN", "CH", "HS"}
TYPES = {"A", "AAAA", "CNAME", "MX", "NS", "TXT", "SPF", "SOA", "SRV", "CAA", "PTR", "DNAME",
         "ALIAS", "ANAME", "DS", "DNSKEY", "RRSIG", "NSEC", "NSEC3", "NSEC3PARAM", "HTTPS",
         "SVCB", "TLSA", "NAPTR", "SSHFP", "LOC", "HINFO", "CDS", "CDNSKEY"}
DNSSEC_TYPES = {"RRSIG", "NSEC", "NSEC3", "KEY"}
TARGET_TYPES = {"CNAME": -1, "MX": 1, "NS": 0, "PTR": 0, "DNAME": 0, "SRV": 3, "ALIAS": 0, "ANAME": 0}
COMMON_TLDS = set("com net org edu gov mil int io co dev app ai info biz me tv cloud xyz site online "
                  "shop store tech us uk de fr nl eu es it se no dk fi pl cz at ch be pt ie tr ru "
                  "jp cn kr in au nz ca br mx ar za sg hk tw".split())
INTERNAL_NETS = [ipaddress.ip_network(n) for n in (
    "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
    "0.0.0.0/8", "::1/128", "::/128", "fc00::/7", "fe80::/10")]  # documentation ranges (192.0.2.0/24, 2001:db8::/32) are allowed
SPF_LOOKUP_TERMS = ("include", "a", "mx", "ptr", "exists", "redirect")

FIXES = {
    "CNAME_AT_APEX": "use A/AAAA records at the apex, or your provider's ALIAS/ANAME/CNAME-flattening record",
    "CNAME_WITH_OTHER_DATA": "a name with a CNAME may have no other records: move the other records or replace the CNAME with A/AAAA",
    "MULTIPLE_CNAME": "keep exactly one CNAME for the name",
    "MX_TO_CNAME": "point MX at a name with A/AAAA records (the CNAME's final target)",
    "NS_TO_CNAME": "point NS at the name server's real host name (A/AAAA)",
    "TARGET_IS_IP": "use a host name here and give that host an A/AAAA record",
    "MISSING_TRAILING_DOT": "end fully qualified names with a dot (mail.example.com.) or write the relative form (mail)",
    "MAYBE_MISSING_TRAILING_DOT": "if this is an outside host name, add the trailing dot",
    "OWNER_DOUBLED_ORIGIN": "end the owner name with a dot or write it relative (www)",
    "DANGLING_TARGET": "add the missing in-zone record or fix the target name",
    "CNAME_CHAIN": "point straight at the final name; chains add lookups and break when one link changes",
    "MX_TARGET_NO_ADDRESS": "add A/AAAA records for the mail host",
    "NULL_MX_WITH_OTHERS": "a null MX ('0 .') must be the only MX; remove it or the others",
    "SPF_MISSING": "publish one TXT \"v=spf1 ... -all\" (or ~all while testing) listing every service that sends as this domain",
    "SPF_MULTIPLE": "merge into ONE v=spf1 record; two records make every SPF check fail (permerror)",
    "SPF_TYPE99": "delete the SPF-type record; only the TXT record is used (RFC 7208)",
    "SPF_SYNTAX": "fix the term; one bad term makes the whole record a permerror",
    "SPF_PASS_ALL": "end with -all (or ~all while testing); +all lets anyone send as you",
    "SPF_NEUTRAL_ALL": "use ~all or -all; ?all gives receivers no signal",
    "SPF_NO_ALL": "end the record with -all or ~all (or redirect=)",
    "SPF_TERMS_AFTER_ALL": "move 'all' to the end; terms after it are ignored",
    "SPF_TOO_MANY_LOOKUPS": "flatten or remove includes, drop a/mx/ptr terms you do not need; nested includes count too",
    "SPF_LOOKUPS_NEAR_LIMIT": "count nested includes with a live checker; they add to this number",
    "SPF_PTR": "replace ptr with ip4/ip6 or include (ptr is deprecated and slow)",
    "TXT_STRING_TOO_LONG": "split into quoted chunks of at most 255 characters: \"part1\" \"part2\"",
    "DMARC_MISSING": "add TXT at _dmarc: \"v=DMARC1; p=none; rua=mailto:dmarc@<domain>\" then move to quarantine/reject",
    "DMARC_MULTIPLE": "keep one DMARC record; with two, receivers ignore DMARC entirely",
    "DMARC_SYNTAX": "start with v=DMARC1; and include p=none|quarantine|reject",
    "DMARC_WRONG_NAME": "DMARC is only read from _dmarc.<domain>; move the record there",
    "DMARC_MONITOR_ONLY": "after reviewing reports, move to p=quarantine, then p=reject",
    "DMARC_NO_RUA": "add rua=mailto:... so you get aggregate reports",
    "DMARC_PARTIAL_PCT": "raise pct to 100 once reports look clean",
    "DKIM_NOT_FOUND": "publish the DKIM selector record(s) your mail provider gives you (<selector>._domainkey)",
    "DKIM_WRONG_NAME": "DKIM keys live at <selector>._domainkey.<domain>",
    "DKIM_REVOKED": "empty p= revokes the key; remove the record if the selector is retired, or publish the new key",
    "DKIM_BAD_KEY": "copy the key again from the provider (base64, no spaces or quotes inside)",
    "DKIM_WEAK_KEY": "rotate to a 2048-bit RSA key (or add an ed25519 selector alongside RSA)",
    "DKIM_1024_KEY": "plan rotation to a 2048-bit key",
    "NO_MAIL_POLICY": "for a domain that never sends mail: MX 0 . , TXT \"v=spf1 -all\" and _dmarc \"v=DMARC1; p=reject\"",
    "NULL_MX_SPF_NOT_FAIL": "a no-mail domain should publish \"v=spf1 -all\"",
    "TTL_TOO_LOW": "use 300 s or more for normal records (lower only for a planned change)",
    "TTL_TOO_HIGH": "use 1 h to 1 day; above a week, mistakes take a week to undo",
    "TTL_INVALID": "TTL must be 0..2147483647 seconds",
    "NO_DEFAULT_TTL": "add $TTL at the top of the zone (e.g. $TTL 3600)",
    "SOA_MISSING": "a zone file needs exactly one SOA at the apex",
    "SOA_RNAME_AT": "write the contact as hostmaster.example.com. (first @ becomes a dot)",
    "SOA_NEGATIVE_TTL_HIGH": "set the SOA minimum (negative-caching TTL) to 300-3600 s",
    "NS_MISSING": "list the zone's name servers as NS records at the apex",
    "NS_SINGLE": "use at least two name servers on separate networks",
    "BAD_ADDRESS": "fix the address",
    "WRONG_ADDRESS_FAMILY": "A takes IPv4, AAAA takes IPv6",
    "PRIVATE_ADDRESS": "do not publish private/loopback addresses in a public zone (use split-horizon DNS if needed)",
    "DUPLICATE_RECORD": "delete the duplicate",
    "CAA_MISSING": "optional hardening: CAA 0 issue \"<your CA>\" limits who may issue certificates",
    "UNKNOWN_TYPE": "check the record type spelling",
    "PARSE_ERROR": "fix the line syntax",
    "INCLUDE_NOT_FOLLOWED": "lint the included file separately",
}


class InputError(Exception):
    pass


class Rec:
    __slots__ = ("line", "name", "raw_name", "ttl", "type", "rdata", "raw")

    def __init__(self, line, name, raw_name, ttl, rtype, rdata, raw):
        self.line, self.name, self.raw_name, self.ttl = line, name, raw_name, ttl
        self.type, self.rdata, self.raw = rtype, rdata, raw


def parse_ttl(tok):
    t = tok.lower()
    if t.isdigit():
        return int(t)
    m = re.fullmatch(r"((\d+)[wdhms])+", t)
    if not m:
        return None
    mult = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
    return sum(int(n) * mult[u] for n, u in re.findall(r"(\d+)([wdhms])", t))


def tokenize(text):
    """Split one logical line into tokens; quoted strings keep their quotes."""
    toks, i, n = [], 0, len(text)
    while i < n:
        c = text[i]
        if c.isspace():
            i += 1
        elif c == '"':
            j, buf = i + 1, ['"']
            while j < n and text[j] != '"':
                if text[j] == "\\" and j + 1 < n:
                    buf.append(text[j:j + 2]); j += 2; continue
                buf.append(text[j]); j += 1
            if j >= n:
                raise InputError("unterminated quoted string")
            buf.append('"'); toks.append("".join(buf)); i = j + 1
        else:
            j = i
            while j < n and not text[j].isspace() and text[j] != '"':
                j += 1
            toks.append(text[i:j]); i = j
    return toks


def strip_comment(line):
    out, inq, i = [], False, 0
    while i < len(line):
        c = line[i]
        if c == "\\" and i + 1 < len(line):
            out.append(line[i:i + 2]); i += 2; continue
        if c == '"':
            inq = not inq
        elif c == ";" and not inq:
            break
        out.append(c); i += 1
    return "".join(out)


def logical_lines(text):
    """Yield (line_no, starts_with_space, content) joining ( ... ) groups."""
    buf, start, depth, lead = [], 0, 0, False
    for no, line in enumerate(text.splitlines(), 1):
        body = strip_comment(line)
        if depth == 0:
            if not body.strip():
                continue
            start, lead, buf = no, body[:1].isspace(), []
        inq = False
        clean = []
        for c in body:
            if c == '"':
                inq = not inq
            if not inq and c in "()":
                depth += 1 if c == "(" else -1
                clean.append(" ")
                continue
            clean.append(c)
        buf.append("".join(clean))
        if depth <= 0:
            depth = 0
            yield start, lead, " ".join(buf)
    if depth > 0:
        raise InputError(f"line {start}: '(' is never closed")


def absname(raw, origin):
    if raw == "@":
        return origin
    if raw.endswith("."):
        return raw.lower()
    return (raw + "." + origin).lower() if origin != "." else raw.lower() + "."


def parse_zone(text, origin, findings):
    recs, last_owner, default_ttl, have_ttl_directive = [], None, None, False
    for no, lead, content in logical_lines(text):
        toks = tokenize(content)
        if not toks:
            continue
        if toks[0].upper() == "$ORIGIN":
            if len(toks) < 2:
                raise InputError(f"line {no}: $ORIGIN needs a name")
            origin = absname(toks[1], origin) if not toks[1].endswith(".") else toks[1].lower()
            continue
        if toks[0].upper() == "$TTL":
            v = parse_ttl(toks[1]) if len(toks) > 1 else None
            if v is None:
                raise InputError(f"line {no}: bad $TTL")
            default_ttl, have_ttl_directive = v, True
            continue
        if toks[0].upper() == "$INCLUDE":
            add(findings, "LOW", "INCLUDE_NOT_FOLLOWED", no, "", "", f"$INCLUDE {toks[1] if len(toks) > 1 else ''} was not read")
            continue
        if lead:
            if last_owner is None:
                raise InputError(f"line {no}: record has no owner name and there is no previous one")
            raw_owner = None
        else:
            raw_owner = toks.pop(0)
        ttl, rtype = None, None
        while toks:
            t = toks[0]
            if t.upper() in CLASSES:
                toks.pop(0); continue
            v = parse_ttl(t)
            if v is not None and ttl is None and not t.upper() in TYPES:
                ttl = v; toks.pop(0); continue
            break
        if not toks:
            add(findings, "HIGH", "PARSE_ERROR", no, raw_owner or "", "", f"no record type in '{content.strip()}'")
            continue
        rtype = toks.pop(0).upper()
        if rtype not in TYPES and not re.fullmatch(r"TYPE\d+", rtype):
            add(findings, "MEDIUM", "UNKNOWN_TYPE", no, raw_owner or "", rtype, f"unknown record type '{rtype}'")
        if raw_owner is not None:
            name = absname(raw_owner, origin)
            last_owner = (name, raw_owner)
        name, raw_name = last_owner
        recs.append(Rec(no, name, raw_name, ttl, rtype, toks, content.strip()))
    return recs, origin, default_ttl, have_ttl_directive


def parse_csv(text, origin, findings):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("CSV has no rows")
    cols = {c.lower().strip(): c for c in rows[0].keys() if c}
    for need in ("name", "type", "value"):
        if need not in cols:
            raise InputError(f"CSV needs columns name,type,value[,ttl][,priority] (missing '{need}')")
    recs = []
    base = origin.rstrip(".")
    for i, r in enumerate(rows, 2):
        g = lambda k: (r.get(cols[k]) or "").strip() if k in cols else ""
        raw = g("name") or "@"
        low = raw.lower().rstrip(".")
        if raw == "@" or low == base:
            name = origin
        elif low.endswith("." + base):
            name = low + "."
        else:
            name = low + "." + origin
        rtype = g("type").upper()
        value = g("value")
        ttl = parse_ttl(g("ttl")) if g("ttl") else None
        if rtype in ("TXT", "SPF"):
            v = value
            if not v.startswith('"'):
                v = '"' + v.replace('"', '\\"') + '"'
            toks = tokenize(v)
        else:
            toks = value.split()
            if rtype in TARGET_TYPES and toks:  # provider UIs show host names without the dot
                idx = TARGET_TYPES[rtype]
                if rtype == "MX" and len(toks) == 1:
                    toks = [g("priority") or "10"] + toks
                if rtype == "SRV" and len(toks) == 3 and g("priority"):
                    toks = [g("priority")] + toks
                k = len(toks) - 1 if idx == -1 else min(idx, len(toks) - 1)
                if not toks[k].endswith(".") and toks[k] != "@":
                    toks[k] += "."
        recs.append(Rec(i, name, raw, ttl, rtype, toks, ",".join(r.get(c) or "" for c in r)))
    return recs, origin


def add(findings, sev, code, line, name, rtype, problem):
    findings.append({"severity": sev, "code": code, "line": line, "name": name, "type": rtype,
                     "problem": problem, "fix": FIXES.get(code, "")})


def txt_strings(rec):
    out = []
    for t in rec.rdata:
        if t.startswith('"') and t.endswith('"'):
            out.append(re.sub(r"\\(.)", r"\1", t[1:-1]))
        else:
            out.append(t)
    return out


def short(name, origin):
    if name == origin:
        return "@"
    if name.endswith("." + origin):
        return name[: -len(origin) - 1]
    return name


def lint(text, origin=None, fmt=None, filename=""):
    findings = []
    if fmt is None:
        fmt = "csv" if filename.lower().endswith(".csv") or text.lstrip().lower().startswith("name,") else "zone"
    if origin is None:
        m = re.search(r"^\$ORIGIN\s+(\S+)", text, re.M | re.I)
        if m:
            origin = m.group(1)
        elif filename:
            b = os.path.basename(filename)
            for suf in (".zone", ".db", ".txt", ".csv"):
                if b.lower().endswith(suf):
                    b = b[: -len(suf)]
            for pre in ("db.",):
                if b.startswith(pre):
                    b = b[len(pre):]
            origin = b if "." in b else None
        if not origin:
            raise InputError("cannot tell the zone name: add $ORIGIN or pass --origin example.com")
    origin = origin.lower().rstrip(".") + "."
    if fmt == "csv":
        recs, origin = parse_csv(text, origin, findings)
        default_ttl, have_ttl = None, True
    else:
        recs, origin, default_ttl, have_ttl = parse_zone(text, origin, findings)
    base = origin.rstrip(".")
    by_name = {}
    for r in recs:
        by_name.setdefault(r.name, []).append(r)
    types_at = lambda n: {r.type for r in by_name.get(n, [])}
    in_zone = lambda n: n == origin or n.endswith("." + origin)
    externals = {}

    # SOA / NS / TTL
    soa = [r for r in recs if r.type == "SOA"]
    if fmt == "zone":
        if not soa:
            add(findings, "MEDIUM", "SOA_MISSING", 0, "@", "SOA", "no SOA record in the zone file")
        for r in soa:
            if len(r.rdata) >= 7:
                rname = r.rdata[1]
                if "@" in rname:
                    add(findings, "HIGH", "SOA_RNAME_AT", r.line, short(r.name, origin), "SOA", f"contact '{rname}' contains '@'; in DNS it is written with a dot")
                neg = parse_ttl(r.rdata[6])
                if neg is not None and neg > 86400:
                    add(findings, "LOW", "SOA_NEGATIVE_TTL_HIGH", r.line, short(r.name, origin), "SOA", f"SOA minimum {neg} s: a deleted-then-added name stays 'does not exist' in caches that long")
                if default_ttl is None and neg is not None:
                    default_ttl = neg
            else:
                add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), "SOA", "SOA needs 7 fields: mname rname serial refresh retry expire minimum")
        ns_apex = [r for r in by_name.get(origin, []) if r.type == "NS"]
        if not ns_apex:
            add(findings, "MEDIUM", "NS_MISSING", 0, "@", "NS", "no NS records at the apex")
        elif len(ns_apex) == 1:
            add(findings, "MEDIUM", "NS_SINGLE", ns_apex[0].line, "@", "NS", "only one name server: if it is down, the whole domain is down")
        if not have_ttl:
            add(findings, "LOW", "NO_DEFAULT_TTL", 0, "", "", "no $TTL directive; records without a TTL fall back to the SOA minimum or the server default")

    for r in recs:
        ttl = r.ttl if r.ttl is not None else default_ttl
        if ttl is None:
            continue
        nm = short(r.name, origin)
        if ttl > 2147483647:
            add(findings, "HIGH", "TTL_INVALID", r.line, nm, r.type, f"TTL {ttl} is above 2147483647")
        elif ttl > 604800:
            add(findings, "MEDIUM", "TTL_TOO_HIGH", r.line, nm, r.type, f"TTL {ttl} s ({ttl // 86400} days): a wrong value stays cached that long")
        elif ttl < 60 and not r.name.startswith("_acme-challenge."):
            add(findings, "MEDIUM", "TTL_TOO_LOW", r.line, nm, r.type, f"TTL {ttl} s: extra query load, slower lookups, and many resolvers raise it anyway")

    # owner names that repeat the origin
    for r in recs:
        if fmt == "zone" and r.raw_name not in (None, "@") and not r.raw_name.endswith(".") and \
                (r.raw_name.lower() == base or r.raw_name.lower().endswith("." + base)):
            add(findings, "HIGH", "OWNER_DOUBLED_ORIGIN", r.line, r.raw_name, r.type, f"owner '{r.raw_name}' has no trailing dot, so it becomes {r.name}")

    # CNAME rules
    for n, rs in by_name.items():
        cn = [r for r in rs if r.type == "CNAME"]
        if not cn:
            continue
        nm = short(n, origin)
        if n == origin:
            add(findings, "HIGH", "CNAME_AT_APEX", cn[0].line, nm, "CNAME", "CNAME at the zone apex conflicts with the SOA and NS records there; mail and other lookups break")
        others = sorted({r.type for r in rs if r.type not in ("CNAME",) and r.type not in DNSSEC_TYPES})
        if others and n != origin:
            add(findings, "HIGH", "CNAME_WITH_OTHER_DATA", cn[0].line, nm, "CNAME", f"name has a CNAME and also {', '.join(others)}; resolvers return only the CNAME")
        if len(cn) > 1:
            add(findings, "HIGH", "MULTIPLE_CNAME", cn[1].line, nm, "CNAME", f"{len(cn)} CNAME records for one name")

    # targets
    for r in recs:
        if r.type not in TARGET_TYPES or not r.rdata:
            continue
        idx = TARGET_TYPES[r.type]
        if r.type == "MX" and len(r.rdata) < 2 or r.type == "SRV" and len(r.rdata) < 4:
            add(findings, "HIGH", "PARSE_ERROR", r.line, short(r.name, origin), r.type, f"{r.type} needs " + ("preference and host" if r.type == "MX" else "priority weight port target"))
            continue
        raw = r.rdata[idx] if idx >= 0 else r.rdata[-1]
        nm = short(r.name, origin)
        if r.type == "MX" and raw == ".":
            continue
        try:
            ipaddress.ip_address(raw.rstrip("."))
            add(findings, "HIGH", "TARGET_IS_IP", r.line, nm, r.type, f"{r.type} target '{raw}' is an IP address; {r.type} must name a host")
            continue
        except ValueError:
            pass
        tgt = absname(raw, origin)
        if fmt == "zone" and raw != "@" and not raw.endswith("."):
            low = raw.lower()
            if low == base or low.endswith("." + base):
                add(findings, "HIGH", "MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' has no trailing dot, so it means {absname(raw, origin)}")
                tgt = low + "."  # check the name that was meant
            elif "." in low and low.rsplit(".", 1)[1] in COMMON_TLDS:
                add(findings, "MEDIUM", "MAYBE_MISSING_TRAILING_DOT", r.line, nm, r.type, f"'{raw}' looks like a full host name but has no trailing dot, so it means {absname(raw, origin)}")
                tgt = low + "."
        if in_zone(tgt):
            tt = types_at(tgt)
            if r.type in ("MX", "NS", "SRV") and "CNAME" in tt:
                code = {"MX": "MX_TO_CNAME", "NS": "NS_TO_CNAME", "SRV": "MX_TO_CNAME"}[r.type]
                add(findings, "HIGH", code, r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} is a CNAME (not allowed, RFC 2181 10.3)")
            elif r.type == "CNAME" and "CNAME" in tt:
                add(findings, "LOW", "CNAME_CHAIN", r.line, nm, r.type, f"CNAME to {short(tgt, origin)}, which is itself a CNAME")
            elif not tt:
                # wildcard or a deeper/glue name may exist elsewhere; only flag when no wildcard covers it
                wild = "*." + tgt.split(".", 1)[1] if "." in tgt else ""
                if wild not in by_name:
                    add(findings, "MEDIUM", "DANGLING_TARGET", r.line, nm, r.type, f"{r.type} target {short(tgt, origin)} has no records in this zone")
            elif r.type == "MX" and not (tt & {"A", "AAAA"}):
                add(findings, "MEDIUM", "MX_TARGET_NO_ADDRESS", r.line, nm, r.type, f"mail host {short(tgt, origin)} has no A/AAAA record")
        else:
            externals.setdefault(tgt, []).append(f"{nm} {r.type} (line {r.line})")

    # addresses
    for r in recs:
        if r.type not in ("A", "AAAA") or not r.rdata:
            continue
        nm = short(r.name, origin)
        try:
            ip = ipaddress.ip_address(r.rdata[0])
        except ValueError:
            add(findings, "HIGH", "BAD_ADDRESS", r.line, nm, r.type, f"'{r.rdata[0]}' is not an IP address")
            continue
        if (r.type == "A") != (ip.version == 4):
            add(findings, "HIGH", "WRONG_ADDRESS_FAMILY", r.line, nm, r.type, f"{r.type} record holds an IPv{ip.version} address")
        elif any(ip in n for n in INTERNAL_NETS if n.version == ip.version):
            add(findings, "MEDIUM", "PRIVATE_ADDRESS", r.line, nm, r.type, f"{ip} is a private/loopback address; outside users cannot reach it and it reveals internal layout")

    # duplicates
    seen = {}
    for r in recs:
        key = (r.name, r.type, " ".join(x.lower() for x in r.rdata))
        if key in seen:
            add(findings, "LOW", "DUPLICATE_RECORD", r.line, short(r.name, origin), r.type, f"same as line {seen[key]}")
        else:
            seen[key] = r.line

    # TXT string length (zone files only: each quoted string max 255)
    if fmt == "zone":
        for r in recs:
            if r.type in ("TXT", "SPF"):
                for s in txt_strings(r):
                    if len(s.encode()) > 255:
                        add(findings, "HIGH", "TXT_STRING_TOO_LONG", r.line, short(r.name, origin), r.type, f"one quoted string is {len(s.encode())} bytes (max 255); the server rejects the zone or truncates it")
                        break

    # mail: MX, SPF, DMARC, DKIM
    txt_by_name = {}
    for r in recs:
        if r.type == "TXT":
            txt_by_name.setdefault(r.name, []).append((r, "".join(txt_strings(r))))
    for r in recs:
        if r.type == "SPF":
            add(findings, "LOW", "SPF_TYPE99", r.line, short(r.name, origin), "SPF", "SPF-type (99) record is obsolete and ignored")
    spf_at = {}
    for n, items in txt_by_name.items():
        for r, s in items:
            if re.match(r"(?i)v=spf1(\s|$)", s):
                spf_at.setdefault(n, []).append((r, s))
            if re.match(r"(?i)v=dmarc1", s) and not n.startswith("_dmarc."):
                add(findings, "HIGH", "DMARC_WRONG_NAME", r.line, short(n, origin), "TXT", "DMARC record is not at _dmarc.<domain>, so receivers never see it")
            if re.match(r"(?i)v=dkim1", s) and "._domainkey." not in "." + n:
                add(findings, "HIGH", "DKIM_WRONG_NAME", r.line, short(n, origin), "TXT", "DKIM key is not under <selector>._domainkey, so signatures cannot be verified")
    for n, items in spf_at.items():
        nm = short(n, origin)
        if len(items) > 1:
            add(findings, "HIGH", "SPF_MULTIPLE", items[1][0].line, nm, "TXT", f"{len(items)} v=spf1 records at this name (lines {', '.join(str(i[0].line) for i in items)})")
        for r, s in items:
            check_spf(findings, r, s, nm)

    mx_names = {}
    for r in recs:
        if r.type == "MX" and len(r.rdata) >= 2:
            mx_names.setdefault(r.name, []).append(r)
    for n, mxs in mx_names.items():
        nulls = [r for r in mxs if r.rdata[1] == "."]
        if nulls and len(mxs) > 1:
            add(findings, "HIGH", "NULL_MX_WITH_OTHERS", nulls[0].line, short(n, origin), "MX", "null MX ('0 .') next to other MX records")
    apex_mx = [r for r in mx_names.get(origin, []) if r.rdata[1] != "."]
    apex_null = any(r.rdata[1] == "." for r in mx_names.get(origin, []))
    apex_spf = spf_at.get(origin, [])
    spf_sends = any(not re.fullmatch(r"(?i)v=spf1\s+-all", s.strip()) for _, s in apex_spf)
    sends_mail = bool(apex_mx) or spf_sends
    for n, mxs in mx_names.items():
        if any(r.rdata[1] != "." for r in mxs) and n not in spf_at:
            add(findings, "MEDIUM", "SPF_MISSING", mxs[0].line, short(n, origin), "TXT", "name receives mail (MX) but publishes no SPF record")
    if apex_null and apex_spf and spf_sends:
        add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", apex_spf[0][0].line, "@", "TXT", "null MX says no mail, but SPF still authorizes senders")
    if apex_null and not apex_spf:
        add(findings, "LOW", "NULL_MX_SPF_NOT_FAIL", 0, "@", "TXT", "null MX but no \"v=spf1 -all\"")

    dm = [(r, s) for r, s in txt_by_name.get("_dmarc." + origin, []) if re.match(r"(?i)v=dmarc1", s.strip()) or "p=" in s.lower()]
    dkim_names = [n for n in by_name if n.endswith("._domainkey." + origin)]
    if sends_mail or apex_null:
        if not dm:
            add(findings, "MEDIUM", "DMARC_MISSING", 0, "_dmarc", "TXT", "no DMARC record: receivers cannot tell spoofed mail from yours, and Gmail/Yahoo require DMARC for bulk senders")
    if dm:
        if len(dm) > 1:
            add(findings, "HIGH", "DMARC_MULTIPLE", dm[1][0].line, "_dmarc", "TXT", f"{len(dm)} DMARC records")
        for r, s in dm:
            check_dmarc(findings, r, s)
    if sends_mail and not dkim_names:
        add(findings, "MEDIUM", "DKIM_NOT_FOUND", 0, "*._domainkey", "TXT", "no <selector>._domainkey record: if you send mail, it is probably unsigned (checked only names in this file)")
    for n in dkim_names:
        for r in by_name[n]:
            if r.type == "TXT":
                check_dkim(findings, r, "".join(txt_strings(r)), short(n, origin))
    if not sends_mail and not apex_null and not apex_spf:
        add(findings, "LOW", "NO_MAIL_POLICY", 0, "@", "MX", "no MX and no SPF: if this domain never sends mail, say so, or spammers can spoof it freely")

    has_web = any(r.type in ("A", "AAAA", "CNAME", "ALIAS", "ANAME") for r in recs)
    if has_web and "CAA" not in types_at(origin):
        add(findings, "LOW", "CAA_MISSING", 0, "@", "CAA", "no CAA record: any public CA may issue certificates for this domain")

    order = {s: i for i, s in enumerate(reversed(SEVERITIES))}
    findings.sort(key=lambda f: (f["line"] if f["line"] else 10 ** 6, order[f["severity"]], f["code"]))
    return {"origin": origin, "format": fmt, "records": len(recs), "findings": findings,
            "external_targets": [{"target": t, "used_by": u} for t, u in sorted(externals.items())]}


def check_spf(findings, r, s, nm):
    terms = s.split()[1:]
    lookups, seen_all = 0, None
    for i, t in enumerate(terms):
        q = t[0] if t[0] in "+-~?" else ""
        body = t[len(q):].lower()
        mech = re.split(r"[:=/]", body, maxsplit=1)[0]
        if seen_all is not None and not body.startswith("exp="):
            add(findings, "MEDIUM", "SPF_TERMS_AFTER_ALL", r.line, nm, "TXT", f"'{t}' comes after 'all' and is ignored")
            seen_all = None
        if mech == "all":
            if q in ("", "+"):
                add(findings, "HIGH", "SPF_PASS_ALL", r.line, nm, "TXT", f"'{t}' authorizes every server on the internet to send as this domain")
            elif q == "?":
                add(findings, "MEDIUM", "SPF_NEUTRAL_ALL", r.line, nm, "TXT", "'?all' is neutral: unauthorized senders are not marked")
            seen_all = i
            continue
        if mech in ("include", "exists"):
            if ":" not in body or not body.split(":", 1)[1]:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' needs a domain ({mech}:example.net)")
            lookups += 1
        elif mech in ("a", "mx"):
            lookups += 1
        elif mech == "ptr":
            lookups += 1
            add(findings, "LOW", "SPF_PTR", r.line, nm, "TXT", "'ptr' is deprecated (RFC 7208 5.5)")
        elif mech in ("ip4", "ip6"):
            val = body.split(":", 1)[1] if ":" in body else ""
            try:
                net = ipaddress.ip_network(val, strict=False)
                if (mech == "ip4") != (net.version == 4):
                    raise ValueError
            except ValueError:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}' is not a valid {mech} address or range")
        elif mech == "redirect":
            lookups += 1
            if "=" not in body:
                add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"'{t}': use redirect=domain")
        elif mech == "exp":
            pass
        else:
            add(findings, "HIGH", "SPF_SYNTAX", r.line, nm, "TXT", f"unknown SPF term '{t}' (typo? e.g. ipv4: for ip4:, include= for include:)")
    if not any(re.sub(r"^[+\-~?]", "", t).lower() == "all" for t in terms) and not any(t.lower().startswith("redirect=") for t in terms):
        add(findings, "MEDIUM", "SPF_NO_ALL", r.line, nm, "TXT", "record has no 'all' or redirect=, so unlisted senders get a neutral result")
    if lookups > 10:
        add(findings, "HIGH", "SPF_TOO_MANY_LOOKUPS", r.line, nm, "TXT", f"{lookups} DNS-lookup terms at the top level alone (limit 10, RFC 7208 4.6.4); receivers return permerror")
    elif lookups >= 8:
        add(findings, "LOW", "SPF_LOOKUPS_NEAR_LIMIT", r.line, nm, "TXT", f"{lookups} lookup terms before counting nested includes (limit 10)")


def check_dmarc(findings, r, s):
    tags = {}
    parts = [p.strip() for p in s.split(";") if p.strip()]
    if not parts or parts[0].replace(" ", "").upper() != "V=DMARC1":
        add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "record must start with 'v=DMARC1;'")
    for p in parts:
        k, _, v = p.partition("=")
        tags[k.strip().lower()] = v.strip()
    p = tags.get("p", "").lower()
    if p not in ("none", "quarantine", "reject"):
        add(findings, "HIGH", "DMARC_SYNTAX", r.line, "_dmarc", "TXT", "missing or invalid p= (none, quarantine or reject); receivers ignore the record")
        return
    if p == "none":
        add(findings, "LOW", "DMARC_MONITOR_ONLY", r.line, "_dmarc", "TXT", "p=none only monitors; spoofed mail is still delivered")
    if "rua" not in tags:
        add(findings, "LOW", "DMARC_NO_RUA", r.line, "_dmarc", "TXT", "no rua= address, so nobody sees the aggregate reports")
    if tags.get("pct", "100").isdigit() and int(tags.get("pct", "100")) < 100:
        add(findings, "LOW", "DMARC_PARTIAL_PCT", r.line, "_dmarc", "TXT", f"pct={tags['pct']}: the policy applies to only part of the failing mail")


def check_dkim(findings, r, s, nm):
    tags = {}
    for p in s.split(";"):
        k, _, v = p.strip().partition("=")
        if k:
            tags[k.strip().lower()] = re.sub(r"\s+", "", v)
    if "p" not in tags:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "no p= tag: not a DKIM key record")
        return
    if tags["p"] == "":
        add(findings, "MEDIUM", "DKIM_REVOKED", r.line, nm, "TXT", "p= is empty: this selector is revoked and mail signed with it fails DKIM")
        return
    try:
        raw = base64.b64decode(tags["p"], validate=True)
    except Exception:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", "p= is not valid base64 (stray quote, space or cut-off copy?)")
        return
    if tags.get("k", "rsa").lower() == "ed25519":
        return
    bits = rsa_bits(raw)
    if bits is None:
        add(findings, "HIGH", "DKIM_BAD_KEY", r.line, nm, "TXT", f"p= decodes to {len(raw)} bytes but is not an RSA public key (cut off?)")
    elif bits < 1024:
        add(findings, "HIGH", "DKIM_WEAK_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; keys under 1024 bits are rejected by receivers (RFC 8301)")
    elif bits < 2048:
        add(findings, "LOW", "DKIM_1024_KEY", r.line, nm, "TXT", f"RSA key is {bits} bits; 2048 is the current recommendation")


def der_read(b, i):
    tag = b[i]; i += 1
    ln = b[i]; i += 1
    if ln & 0x80:
        n = ln & 0x7F
        ln = int.from_bytes(b[i:i + n], "big"); i += n
    return tag, i, ln


def rsa_bits(raw):
    """Modulus size of an RSA SubjectPublicKeyInfo (or bare RSAPublicKey) in bits."""
    try:
        tag, i, ln = der_read(raw, 0)
        if tag != 0x30:
            return None
        tag, j, l2 = der_read(raw, i)
        if tag == 0x30:  # SubjectPublicKeyInfo: algorithm, then BIT STRING
            j += l2
            tag, j, l3 = der_read(raw, j)
            if tag != 0x03:
                return None
            j += 1  # unused-bits byte
            tag, j, l4 = der_read(raw, j)
            if tag != 0x30:
                return None
            tag, j, lm = der_read(raw, j)
        elif tag == 0x02:
            lm = l2
        else:
            return None
        if tag != 0x02 or j + lm > len(raw):
            return None
        mod = raw[j:j + lm].lstrip(b"\x00")
        return len(mod) * 8 if mod else None
    except IndexError:
        return None


def render(res, title, min_sev="LOW", ignore=()):
    keep = [f for f in res["findings"] if SEVERITIES.index(f["severity"]) >= SEVERITIES.index(min_sev) and f["code"] not in ignore]
    cnt = {s: sum(1 for f in keep if f["severity"] == s) for s in SEVERITIES}
    out = [f"# DNS record lint: {title}", "",
           f"Zone: {res['origin']}   Format: {res['format']}   Records: {res['records']}   Findings: {len(keep)} (HIGH {cnt['HIGH']}, MEDIUM {cnt['MEDIUM']}, LOW {cnt['LOW']})", ""]
    if keep:
        out += ["| Sev | Code | Line | Name | Type | Problem | Fix |", "|---|---|---|---|---|---|---|"]
        for f in keep:
            out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['name'] or '-'} | {f['type'] or '-'} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    if res["external_targets"]:
        out += ["", "## Out-of-zone targets to check live (no network calls were made)", "",
                "| Target | Used by |", "|---|---|"]
        for e in res["external_targets"]:
            out.append(f"| {e['target']} | {'; '.join(e['used_by'])} |")
    return "\n".join(out) + "\n", keep


def self_test():
    Z = "$ORIGIN example.com.\n$TTL 3600\n@ IN SOA ns1 hostmaster 1 7200 900 1209600 300\n@ NS ns1\n@ NS ns2.example.net.\nns1 A 192.0.2.53\n"
    MAIL = '@ MX 10 mail\nmail A 192.0.2.25\n@ TXT "v=spf1 mx -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\ns1._domainkey CNAME s1.dkim.example.net.\n@ CAA 0 issue "letsencrypt.org"\n'
    k1024 = base64.b64encode(bytes.fromhex("30819f300d06092a864886f70d010101050003818d0030818902818100") + b"\xc1" + bytes(127) + bytes.fromhex("0203010001")).decode()
    k512 = base64.b64encode(bytes.fromhex("305c300d06092a864886f70d0101010500034b003048024100") + b"\xc1" + bytes(63) + bytes.fromhex("0203010001")).decode()

    def codes(text, **kw):
        return [f["code"] for f in lint(text, **kw)["findings"]]

    cases = [
        ("clean zone", Z + MAIL, lambda c: c == []),
        ("cname at apex", Z + MAIL + "@ CNAME shop.example.net.\n", lambda c: "CNAME_AT_APEX" in c),
        ("cname with other data", Z + MAIL + "www CNAME web.example.net.\nwww TXT \"x\"\n", lambda c: "CNAME_WITH_OTHER_DATA" in c),
        ("multiple cname", Z + MAIL + "www CNAME a.example.net.\nwww CNAME b.example.net.\n", lambda c: "MULTIPLE_CNAME" in c),
        ("mx to cname", Z + MAIL.replace("mail A 192.0.2.25", "mail CNAME mx.example.net."), lambda c: "MX_TO_CNAME" in c),
        ("ns to cname", Z.replace("ns1 A 192.0.2.53", "ns1 CNAME dns.example.net.") + MAIL, lambda c: "NS_TO_CNAME" in c),
        ("mx is ip", Z + MAIL.replace("MX 10 mail", "MX 10 192.0.2.25"), lambda c: "TARGET_IS_IP" in c),
        ("missing trailing dot", Z + MAIL + "www CNAME web.example.com\nweb A 192.0.2.80\n", lambda c: "MISSING_TRAILING_DOT" in c),
        ("maybe missing dot (outside name)", Z + MAIL + "shop CNAME shops.myshopify.com\n", lambda c: "MAYBE_MISSING_TRAILING_DOT" in c),
        ("owner doubled origin", Z + MAIL + "www.example.com A 192.0.2.80\n", lambda c: "OWNER_DOUBLED_ORIGIN" in c),
        ("relative target ok", Z + MAIL + "www CNAME web\nweb A 192.0.2.80\n", lambda c: c == []),
        ("dangling target", Z + MAIL + "www CNAME web\n", lambda c: "DANGLING_TARGET" in c),
        ("cname chain", Z + MAIL + "www CNAME web\nweb CNAME edge.example.net.\n", lambda c: "CNAME_CHAIN" in c),
        ("mx target no address", Z + MAIL.replace("mail A 192.0.2.25", "mail TXT \"x\""), lambda c: "MX_TARGET_NO_ADDRESS" in c),
        ("spf missing", Z + MAIL.replace('@ TXT "v=spf1 mx -all"\n', ""), lambda c: "SPF_MISSING" in c),
        ("spf multiple", Z + MAIL + '@ TXT "v=spf1 include:_spf.google.com ~all"\n', lambda c: "SPF_MULTIPLE" in c),
        ("spf +all", Z + MAIL.replace("mx -all", "mx +all"), lambda c: "SPF_PASS_ALL" in c),
        ("spf ?all", Z + MAIL.replace("mx -all", "mx ?all"), lambda c: "SPF_NEUTRAL_ALL" in c),
        ("spf no all", Z + MAIL.replace("mx -all", "mx"), lambda c: "SPF_NO_ALL" in c),
        ("spf terms after all", Z + MAIL.replace("mx -all", "-all mx"), lambda c: "SPF_TERMS_AFTER_ALL" in c),
        ("spf syntax typo", Z + MAIL.replace("mx -all", "ipv4:192.0.2.1 -all"), lambda c: "SPF_SYNTAX" in c),
        ("spf bad ip4", Z + MAIL.replace("mx -all", "ip4:300.1.1.1 -all"), lambda c: "SPF_SYNTAX" in c),
        ("spf too many lookups", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(11)) + " -all"), lambda c: "SPF_TOO_MANY_LOOKUPS" in c),
        ("spf near limit", Z + MAIL.replace("mx -all", " ".join(f"include:s{i}.example.net" for i in range(8)) + " -all"), lambda c: "SPF_LOOKUPS_NEAR_LIMIT" in c and "SPF_TOO_MANY_LOOKUPS" not in c),
        ("spf ptr", Z + MAIL.replace("mx -all", "ptr -all"), lambda c: "SPF_PTR" in c),
        ("spf type99", Z + MAIL + '@ SPF "v=spf1 mx -all"\n', lambda c: "SPF_TYPE99" in c),
        ("split txt strings joined", Z + MAIL.replace('"v=spf1 mx -all"', '"v=spf1 mx " "-all"'), lambda c: c == []),
        ("txt string too long", Z + MAIL + '@ TXT "' + "a" * 300 + '"\n', lambda c: "TXT_STRING_TOO_LONG" in c),
        ("dmarc missing", Z + MAIL.replace('_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n', ""), lambda c: "DMARC_MISSING" in c),
        ("dmarc wrong name", Z + MAIL.replace("_dmarc TXT", "dmarc TXT"), lambda c: "DMARC_WRONG_NAME" in c and "DMARC_MISSING" in c),
        ("dmarc multiple", Z + MAIL + '_dmarc TXT "v=DMARC1; p=none"\n', lambda c: "DMARC_MULTIPLE" in c),
        ("dmarc no p", Z + MAIL.replace("p=reject; ", ""), lambda c: "DMARC_SYNTAX" in c),
        ("dmarc none + no rua + pct", Z + MAIL.replace("p=reject; rua=mailto:d@example.com", "p=none; pct=50"), lambda c: {"DMARC_MONITOR_ONLY", "DMARC_NO_RUA", "DMARC_PARTIAL_PCT"} <= set(c)),
        ("dkim not found", Z + MAIL.replace("s1._domainkey CNAME s1.dkim.example.net.\n", ""), lambda c: "DKIM_NOT_FOUND" in c),
        ("dkim revoked", Z + MAIL + 's2._domainkey TXT "v=DKIM1; k=rsa; p="\n', lambda c: "DKIM_REVOKED" in c),
        ("dkim bad base64", Z + MAIL + 's2._domainkey TXT "v=DKIM1; p=MIGf!!"\n', lambda c: "DKIM_BAD_KEY" in c),
        ("dkim 512-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k512}"\n', lambda c: "DKIM_WEAK_KEY" in c),
        ("dkim 1024-bit key", Z + MAIL + f's2._domainkey TXT "v=DKIM1; k=rsa; p={k1024}"\n', lambda c: "DKIM_1024_KEY" in c),
        ("dkim wrong name", Z + MAIL + f's2.domainkey TXT "v=DKIM1; p={k1024}"\n', lambda c: "DKIM_WRONG_NAME" in c),
        ("null mx + spf -all, no mail checks", Z + '@ MX 0 .\n@ TXT "v=spf1 -all"\n_dmarc TXT "v=DMARC1; p=reject; rua=mailto:d@example.com"\n@ CAA 0 issue "letsencrypt.org"\n', lambda c: c == []),
        ("null mx with others", Z + MAIL + "@ MX 0 .\n", lambda c: "NULL_MX_WITH_OTHERS" in c),
        ("no mail policy", Z + '@ CAA 0 issue "letsencrypt.org"\n', lambda c: "NO_MAIL_POLICY" in c),
        ("ttl too low", Z + MAIL + "www 30 A 192.0.2.80\n", lambda c: "TTL_TOO_LOW" in c),
        ("acme ttl low ok", Z + MAIL + '_acme-challenge 30 TXT "token"\n', lambda c: c == []),
        ("ttl too high (units)", Z + MAIL + "www 2w A 192.0.2.80\n", lambda c: "TTL_TOO_HIGH" in c),
        ("no $TTL", Z.replace("$TTL 3600\n", "") + MAIL, lambda c: "NO_DEFAULT_TTL" in c),
        ("soa rname with @", Z.replace("hostmaster", "hostmaster@example.com.") + MAIL, lambda c: "SOA_RNAME_AT" in c),
        ("soa multi-line with comments", Z.replace("@ IN SOA ns1 hostmaster 1 7200 900 1209600 300", "@ IN SOA ns1 hostmaster (\n 2026103001 ; serial\n 7200 900 1209600\n 300 ) ; min") + MAIL, lambda c: c == []),
        ("single ns", Z.replace("@ NS ns2.example.net.\n", "") + MAIL, lambda c: "NS_SINGLE" in c),
        ("bad address", Z + MAIL + "www A 192.0.2.300\n", lambda c: "BAD_ADDRESS" in c),
        ("wrong family", Z + MAIL + "www AAAA 192.0.2.80\n", lambda c: "WRONG_ADDRESS_FAMILY" in c),
        ("private address", Z + MAIL + "intranet A 10.0.0.5\n", lambda c: "PRIVATE_ADDRESS" in c),
        ("duplicate record", Z + MAIL + "www A 192.0.2.80\nwww A 192.0.2.80\n", lambda c: "DUPLICATE_RECORD" in c),
        ("blank owner continues", Z + MAIL + "www A 192.0.2.80\n    AAAA 2001:db8::80\n", lambda c: c == []),
        ("semicolon inside quotes kept", Z + MAIL.replace('"v=DMARC1; p=reject; rua=mailto:d@example.com"', '"v=DMARC1; p=reject; rua=mailto:d@example.com" ; comment'), lambda c: c == []),
        ("caa missing", Z + MAIL.replace('@ CAA 0 issue "letsencrypt.org"\n', ""), lambda c: c == ["CAA_MISSING"]),
        ("csv input", None, None),
        ("unterminated quote is input error", None, None),
    ]
    passed = 0
    for name, text, check in cases:
        try:
            if name == "csv input":
                csvt = ("name,type,value,ttl,priority\n@,MX,mail.example.com,3600,10\nmail,A,192.0.2.25,3600,\n"
                        "@,TXT,v=spf1 include:_spf.google.com ~all,3600,\n@,TXT,v=spf1 mx -all,3600,\n"
                        "www.example.com,CNAME,example.com,300,\n@,CNAME,shop.example.net,300,\n")
                res = lint(csvt, origin="example.com", fmt="csv")
                c = [f["code"] for f in res["findings"]]
                ok = {"SPF_MULTIPLE", "CNAME_AT_APEX", "DMARC_MISSING", "DKIM_NOT_FOUND"} <= set(c) and "MISSING_TRAILING_DOT" not in c and "MX_TO_CNAME" not in c
            elif name == "unterminated quote is input error":
                try:
                    lint(Z + '@ TXT "v=spf1 -all\n')
                    ok, c = False, "no error"
                except InputError as e:
                    ok, c = True, str(e)
            else:
                c = codes(text)
                ok = check(c)
        except Exception as e:  # noqa
            ok, c = False, f"exception {e!r}"
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  got {c}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Lint a DNS zone file or record list (offline)")
    ap.add_argument("file", nargs="?")
    ap.add_argument("--origin", help="zone name, e.g. example.com (default: $ORIGIN or the file name)")
    ap.add_argument("--format", choices=["zone", "csv"], help="input format (default: by extension/content)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--min-severity", default="LOW", choices=SEVERITIES)
    ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("give a zone file or CSV, or --self-test")
    try:
        with open(a.file, encoding="utf-8") as fh:
            text = fh.read()
        res = lint(text, origin=a.origin, fmt=a.format, filename=a.file)
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    ignore = {c.strip().upper() for c in a.ignore.split(",") if c.strip()}
    md, keep = render(res, os.path.basename(a.file), a.min_severity, ignore)
    if a.json:
        print(json.dumps({**res, "findings": keep}, indent=2))
    else:
        sys.stdout.write(md)
    return 1 if any(f["severity"] == "HIGH" for f in keep) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonSecurityDevOps+2
F@f
0
室内植物浇水日程规划器
技能

按物种与环境生成浇水日历。

---
name: houseplant-watering-schedule-planner
description: 按物种与环境生成浇水日历。
---

# 室内植物浇水日程规划器

你是「室内植物浇水日程规划器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Houseplant Watering Schedule Planner

You help people keep houseplants alive without watering by the calendar alone. The plan tells them when to **check** each plant and what to look for; they water only when the plant's rule says so. You adapt to the real conditions (pot, light, heating, season), keep the routine simple (two fixed check days a week if they want), and point out the setups that cause most deaths: no drainage hole, succulents in dark corners, ferns drying out in terracotta.

## Files in this skill

- `scripts/watering_planner.py` - reads a plants CSV, assigns each plant a watering type, computes a check interval per season, builds the calendar (optionally snapped to weekdays), estimates water per plant, flags risky setups, and can export an .ics file (Python 3.8+ standard library only)
- `references/plant-water-profiles.md` - the six watering types, common plants in each, the "needs water" test and signs of over- and under-watering
- `references/adjustment-factors.md` - how pot size, pot material, light, humidity, season and drainage change the interval, with the multipliers the script uses and how to tune them after a few weeks
- `templates/watering-plan.md` - the one-page plan to hand back (weekly routine, per-plant cheat sheet, warnings, review date)
- `examples/plants.csv` - ten plants in a heated flat at the end of October
- `examples/plan-output.md` - the real output of the planner for that list

## Workflow

### 1. Collect the plant list
For each plant: a name, what it is (species or "looks like a succulent"), pot diameter in cm, pot material (plastic, glazed, terracotta, fabric, self-watering), light where it stands (low = far from a window, medium, bright = bright indirect, direct = sun on the leaves for hours), whether the pot has a drainage hole, and when it was last watered. Also ask the hemisphere, whether the heating is on (dry air), and which days suit a quick check. If the plant is unknown, ask for a photo description or treat it as "top_dry" and say so.

### 2. Run the planner
```
python3 scripts/watering_planner.py examples/plants.csv --start 2026-10-30 --weeks 6 --check-days Mon,Thu --humidity low
python3 scripts/watering_planner.py plants.csv --hemisphere south --weeks 8 --ics plants.ics
python3 scripts/watering_planner.py plants.csv --json
python3 scripts/watering_planner.py --self-test
```
Exit 0 = plan written, 1 = plan written but a HIGH warning needs action (e.g. no drainage), 2 = bad input (unknown material or light, pot size in mm, bad date). Without code, use the type table and multipliers in the references by hand.

### 3. Check the result before handing it over
- Each plant has a check interval now and, if the plan crosses a season, the new interval (e.g. "7 d (winter: 9 d)").
- Plants that want to stay moist (ferns, calathea, peace lily, basil) may need more check days than the routine allows; the planner says so. Suggest a third check day, a plastic pot, or self-watering only for these.
- "Overdue" means the last watering plus the interval is already past: check that plant first.
- Read the warnings and turn HIGH ones into the first action of the plan.

### 4. Deliver
Fill `templates/watering-plan.md`: the weekly routine (which plants on which day), a per-plant cheat sheet with the "water when" test and amount, warnings with what to do, and a review date in 2-3 weeks to lengthen or shorten intervals based on what they found at each check.

## Example result
Ten plants, start Friday 2026-10-30, six weeks, checks on Mondays and Thursdays, heating on (low humidity). Ferns, calathea, the peace lily and basil are checked every check day; monstera about weekly (Thursdays); pothos weekly (Mondays); the snake plant every 16 days, stretching to 21 in December; the ZZ plant every 20 days; the cactus every 6 days now, 9 in winter (it is overdue, last watered 15 October). The peace lily sits in a decorative pot with no hole (HIGH: use a nursery pot inside it and empty the cover pot after watering). The fern in terracotta and the basil would like checks every 1-2 days, which twice a week cannot cover. See `examples/plan-output.md`.

## Rules

- Never prescribe watering on a fixed schedule without a check; always pair each check with the plant's "water when" test.
- Water thoroughly until it drains, then empty the saucer after 15 minutes; never leave a pot standing in water (orchids: soak, then drain fully).
- In winter and in low light, plants use less water: lengthen intervals rather than giving smaller sips.
- Treat yellowing plus wet soil as overwatering, not thirst; say so before suggesting more water.
- Do not guess a species from a vague name; ask or state the type you assumed.
- Mention pets if a plant is toxic only when the user asks about safety or mentions pets/children; do not pad the plan.
FILE:references/plant-water-profiles.md
# Watering types

| Type | Typical plants | Water when | Base check interval* | Winter slow-down |
|---|---|---|---|---|
| succulent | cactus, aloe, echeveria, jade, haworthia, string of pearls | soil dry all the way down: a wooden chopstick pushed to the bottom comes out clean and dry; pot feels light | 10 days | x2.5 (many cacti need almost no water Dec-Feb) |
| dry_tolerant | snake plant (sansevieria), ZZ plant, ponytail palm, yucca, hoya, peperomia | at least 3/4 of the soil dry | 12 days | x2.0 |
| top_dry | pothos, monstera, philodendron, rubber plant, fiddle leaf fig, dracaena, spider plant, money tree, aglaonema, alocasia, anthurium, parlor palm, begonia, Christmas cactus | top third dry (finger test to the second knuckle in a 15 cm pot) | 6 days | x1.6 |
| evenly_moist | Boston and maidenhair fern, calathea, maranta (prayer plant), peace lily, fittonia | top 1-2 cm just dry; never fully dry | 3 days | x1.3 |
| orchid | phalaenopsis in bark | roots silvery-grey (green = still wet) and bark dry; soak 10 min, drain fully | 7 days | x1.4 |
| herb | basil, mint, parsley on a windowsill | top 1-2 cm dry | 2 days | x1.2 |

*Base = spring, 15 cm plastic pot with drainage, medium light, normal humidity. Autumn is halfway between spring and winter; summer is x0.85. See `adjustment-factors.md`.

## How to check (30 seconds per plant)
1. **Finger test:** push a finger into the soil to the depth in the rule. Dry and crumbly = dry; cool and sticking = moist.
2. **Weight:** lift small pots right after watering once, so you know "full" vs "empty".
3. **Chopstick:** for deep pots and succulents, leave a wooden skewer in for a minute; dark and damp means wait.
4. **Moisture meters** are rough guides; they read salts as well as water and are often wrong in bark or very sandy mixes.

## Signs
| Overwatering (more common) | Underwatering |
|---|---|
| yellow lower leaves with soil still wet, soft or black stem base, mushy roots, fungus gnats, sour smell | crisp brown edges, drooping that recovers within hours of watering, soil pulling away from the pot edge, very light pot |
| Fix: stop watering, check roots, repot in dry mix if rotten, lengthen the interval | Fix: soak thoroughly (bottom-water 20-30 min for hydrophobic soil), shorten the interval a little |

Drooping with **wet** soil means roots are damaged: do not add water.

## Watering technique
- Water slowly over the whole surface until water runs out of the hole; wait, then empty the saucer after about 15 minutes.
- Room-temperature water. Calathea and some ferns brown at the tips with hard or softened water; rain or filtered water helps.
- Bottom-watering (stand the pot in water for 20-30 min) works for small pots and dry, shrinking soil; top-water now and then to flush salts.
- Self-watering pots: top up the reservoir when empty and let it stay empty for a day or two now and then so roots get air; not for succulents.
FILE:references/adjustment-factors.md
# Adjustment factors

Check interval = base days (type) x pot size x pot material x light x humidity x season, rounded, limited to 1..45 days. These are the multipliers in `scripts/watering_planner.py`.

| Factor | Value | Multiplier | Why |
|---|---|---|---|
| Pot diameter | under 10 cm | 0.6 | little soil, dries in days |
| | 10-13 cm | 0.8 | |
| | 14-19 cm | 1.0 | reference |
| | 20-27 cm | 1.3 | more soil holds water longer |
| | 28 cm and up | 1.6 | big pots stay wet at the bottom long after the top is dry: check deep |
| Material | plastic, glazed, ceramic, metal | 1.0 | walls do not breathe |
| | terracotta / unglazed clay | 0.75 | walls wick and evaporate water |
| | fabric grow bag | 0.7 | dries from all sides |
| | self-watering | 1.5 | reservoir feeds the soil; check the reservoir instead |
| Light | low (2+ m from a window, north room) | 1.4 | little growth, little use |
| | medium | 1.0 | |
| | bright indirect | 0.85 | |
| | direct sun on the leaves | 0.7 | |
| Humidity | low (heating on, below ~35 %) | 0.85 | soil and leaves lose water faster |
| | normal | 1.0 | |
| | high (bathroom, humidifier) | 1.15 | |
| Season | spring | 1.0 | |
| | summer | 0.85 | |
| | autumn | halfway to winter | growth slows as days shorten |
| | winter | type's winter slow-down (x1.2 to x2.5) | |

Seasons: northern hemisphere Dec-Feb winter, Mar-May spring, Jun-Aug summer, Sep-Nov autumn; southern hemisphere shifted by six months. Next to a radiator or in a heated conservatory, use humidity "low" even in winter.

## Drainage
No hole is the single biggest cause of root rot. Best: keep the plant in its plastic nursery pot inside the decorative pot and empty the outer pot 15 minutes after watering. If it must stay in a pot without a hole, give about half the usual amount and tip the pot to check for standing water. The planner flags this as HIGH.

## Amount
About 15-25 % of the pot volume, until water drains out of the hole. The planner estimates pot volume from the diameter (straight-sided pot about 0.9x as tall as wide), so a 15 cm pot gets about 450 ml, a 24 cm pot about 1.75 l. Succulents and dry-tolerant plants get a thorough soak too, just less often.

## Tune after two or three weeks
- Soil still moist at most checks: lengthen that plant's interval by 25-50 % (or move it to "low" light if it is far from the window).
- Soil bone-dry at every check and the plant droops: shorten by 25 %.
- Write the changes into the CSV (e.g. switch the type or light) and re-run, rather than remembering exceptions.
- Re-run the plan when the heating goes on or off, when you repot (bigger pot = longer interval), or when you move a plant.

## Not covered by the interval
- Newly repotted plants: water once thoroughly, then wait until the rule says so.
- Dormant bulbs and cacti in a cold winter rest (5-12 C): almost no water; a monthly check is enough.
- Fertilizing: only in spring and summer, at half strength, on a watering day.
FILE:templates/watering-plan.md
# Plant care plan: <home / room>

**Period:** <2026-10-30 to 2026-12-10>   **Check days:** <Mon and Thu, 5-10 minutes>
**Conditions:** <northern hemisphere, heating on (dry air)>   **Review on:** <2026-11-16>

## Do first
- <Peace lily: no drainage hole - move it into a plastic nursery pot inside the cover pot; empty the cover pot 15 min after watering>
- <Spike (cactus): overdue - check on Monday>

## Weekly routine
| Day | Check these plants |
|---|---|
| <Mon> | <ferns, calathea, peace lily, basil, orchid, pothos, cactus (every 2nd Mon in December)> |
| <Thu> | <ferns, calathea, peace lily, basil, orchid, monstera; snake plant every 2-3 weeks> |

## Cheat sheet
| Plant | Water when | About | Notes |
|---|---|---|---|
| <Monty (monstera)> | <top third dry> | <~1.75 l until it drains> | <bright east window> |
| <Sansa (snake plant)> | <3/4 dry> | <~450 ml> | <December: every 3 weeks> |

## How to check
- Finger in the soil to the depth in the "water when" column; water only if dry to that depth.
- Water until it drains, empty the saucer after 15 minutes.
- Wet soil + yellow leaves = too much water: skip and tell me.

## Warnings
- <Fernando (Boston fern) in terracotta dries in 2 days with the heating on: add a Saturday check or move it to a plastic pot>
- <Basil wants daily checks on the sunny sill; bottom-water when you pass by>

## At the review
For each plant, note whether the soil was usually still moist (lengthen) or bone dry (shorten) at checks; I will update the plan.
FILE:examples/plants.csv
name,plant,pot_cm,pot_material,light,drainage,last_watered,notes
Monty,monstera,24,plastic,bright,yes,2026-10-28,living room by the east window
Pothos shelf,pothos,14,glazed,medium,yes,2026-10-27,trailing from the bookshelf
Sansa,snake plant,17,terracotta,low,yes,2026-10-20,hallway corner
Zed,zz plant,20,plastic,medium,yes,,new from the shop
Fernando,boston fern,20,terracotta,bright,yes,2026-10-29,bathroom shelf
Cal,calathea,15,plastic,medium,yes,2026-10-28,bedroom
Spike,cactus,10,terracotta,direct,yes,2026-10-15,south windowsill
Peace lily,peace lily,18,glazed,low,no,2026-10-26,decorative pot with no hole
Orchid,phalaenopsis,12,plastic,bright,yes,2026-10-25,kitchen window
Basil,basil,13,terracotta,direct,yes,2026-10-29,kitchen windowsill
FILE:examples/plan-output.md
# Houseplant watering plan: 2026-10-30 to 2026-12-10

Hemisphere: north   Season at start: autumn   Humidity: low   Check days: Mon, Thu   Plants: 10

A check is not a watering: feel the soil and water only when the rule says so.

## Plants

| Plant | Type | Pot | Light | Check every | Water when | About | Checks |
|---|---|---|---|---|---|---|---|
| Monty (monstera) | top_dry | 24 cm plastic | bright | 7 d (winter: 9 d) | top third of the soil dry (about 3-5 cm in a 15 cm pot) | ~1750 ml (until it drains) | 6 |
| Pothos shelf (pothos) | top_dry | 14 cm glazed | medium | 7 d (winter: 8 d) | top third of the soil dry (about 3-5 cm in a 15 cm pot) | ~350 ml (until it drains) | 6 |
| Sansa (snake plant) | dry_tolerant | 17 cm terracotta | low | 16 d (winter: 21 d) | soil dry at least 3/4 of the way down | ~450 ml (until it drains) | 3 |
| Zed (zz plant) | dry_tolerant | 20 cm plastic | medium | 20 d | soil dry at least 3/4 of the way down | ~750 ml (until it drains) | 2 |
| Fernando (boston fern) | evenly_moist | 20 cm terracotta | bright | 2 d (winter: 3 d) | top 1-2 cm just dry; never let it dry out fully | ~1000 ml (until it drains) | 12 |
| Cal (calathea) | evenly_moist | 15 cm plastic | medium | 3 d (winter: 3 d) | top 1-2 cm just dry; never let it dry out fully | ~450 ml (until it drains) | 12 |
| Spike (cactus) (overdue: check first day) | succulent | 10 cm terracotta | direct | 6 d (winter: 9 d) | soil dry all the way down (chopstick comes out clean and dry) | ~100 ml (until it drains) | 6 |
| Peace lily (peace lily) | evenly_moist | 18 cm glazed, NO hole | low | 4 d (winter: 5 d) | top 1-2 cm just dry; never let it dry out fully | ~375 ml, no more | 12 |
| Orchid (phalaenopsis) | orchid | 12 cm plastic | bright | 5 d (winter: 6 d) | roots silvery-grey and bark dry; soak 10 min, drain fully | soak 10 min | 11 |
| Basil (basil) | herb | 13 cm terracotta | direct | 1 d (winter: 1 d) | top 1-2 cm dry | ~350 ml (until it drains) | 12 |

## Calendar

| Date | Day | Season | Check |
|---|---|---|---|
| 2026-11-02 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-05 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-09 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-12 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-16 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-11-19 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-11-23 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike, Zed |
| 2026-11-26 | Thu | autumn | Basil, Cal, Fernando, Monty, Orchid, Peace lily |
| 2026-11-30 | Mon | autumn | Basil, Cal, Fernando, Orchid, Peace lily, Pothos shelf, Spike |
| 2026-12-03 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily, Sansa |
| 2026-12-07 | Mon | winter | Basil, Cal, Fernando, Peace lily, Pothos shelf, Spike |
| 2026-12-10 | Thu | winter | Basil, Cal, Fernando, Monty, Orchid, Peace lily |

## Warnings

| Sev | Plant | Note |
|---|---|---|
| HIGH | Peace lily | pot has no drainage hole: water pools at the bottom and roots rot. Repot, or keep the plant in a nursery pot inside the cover pot and empty the cover pot 15 min after watering |
| LOW | Fernando | terracotta pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist |
| LOW | Fernando | check days push some checks more than a day late (e.g. due 2026-11-07, planned 2026-11-09); add a check day or feel the soil in between |
| LOW | Basil | check days push some checks more than a day late (e.g. due 2026-11-03, planned 2026-11-05); add a check day or feel the soil in between |

Intervals assume the plant is established in a free-draining mix. After 2-3 weeks, lengthen an interval if the soil is still moist at most checks, shorten it if it is always bone dry.
FILE:scripts/watering_planner.py
#!/usr/bin/env python3
"""Houseplant watering schedule planner (Python 3.8+ standard library only).

Reads a CSV of plants (species or watering type, pot diameter, pot material, light,
drainage, optional last watering date) and builds a check calendar: how often to
check each plant in the current season, what "needs water" means for it, roughly how
much to give, and which plants to check on which date. Intervals stretch
automatically as the plan crosses into autumn and winter (or shrink into summer), by
hemisphere. Checks can be snapped to fixed weekdays (e.g. Mon,Thu) so the plan fits a
routine. Optional .ics export for a calendar app.

A check is not a watering: on each check, feel the soil and water only if the plant's
rule says so. The intervals are starting points; adjust them after two or three weeks
of real checks (see references/adjustment-factors.md).

Usage:
  python3 watering_planner.py plants.csv [--start 2026-10-30] [--weeks 4]
        [--hemisphere north|south] [--humidity low|normal|high]
        [--check-days Mon,Thu] [--ics plan.ics] [--json]
  python3 watering_planner.py --self-test
plants.csv columns: name, plant (species or type), pot_cm, pot_material
(plastic|glazed|ceramic|terracotta|fabric|self-watering), light (low|medium|bright|direct),
drainage (yes|no), last_watered (YYYY-MM-DD, optional), notes (optional).
Types: succulent, dry_tolerant, top_dry, evenly_moist, orchid, herb.
Exit codes: 0 = plan written, 1 = plan written with HIGH warnings (e.g. no drainage), 2 = bad input.
"""
import argparse, csv, datetime as dt, io, json, math, sys

PROFILES = {
    #               base days (spring, 15 cm plastic, medium light), winter factor, rule, share of pot volume per watering
    "succulent":    (10, 2.5, "soil dry all the way down (chopstick comes out clean and dry)", 0.15),
    "dry_tolerant": (12, 2.0, "soil dry at least 3/4 of the way down", 0.15),
    "top_dry":      (6, 1.6, "top third of the soil dry (about 3-5 cm in a 15 cm pot)", 0.20),
    "evenly_moist": (3, 1.3, "top 1-2 cm just dry; never let it dry out fully", 0.20),
    "orchid":       (7, 1.4, "roots silvery-grey and bark dry; soak 10 min, drain fully", 0.0),
    "herb":         (2, 1.2, "top 1-2 cm dry", 0.25),
}
SPECIES = {
    "cactus": "succulent", "aloe": "succulent", "aloe vera": "succulent", "echeveria": "succulent",
    "jade": "succulent", "jade plant": "succulent", "haworthia": "succulent", "string of pearls": "succulent",
    "christmas cactus": "top_dry", "snake plant": "dry_tolerant", "sansevieria": "dry_tolerant",
    "zz plant": "dry_tolerant", "zamioculcas": "dry_tolerant", "ponytail palm": "dry_tolerant",
    "hoya": "dry_tolerant", "pothos": "top_dry", "monstera": "top_dry", "philodendron": "top_dry",
    "rubber plant": "top_dry", "ficus elastica": "top_dry", "fiddle leaf fig": "top_dry",
    "dracaena": "top_dry", "spider plant": "top_dry", "money tree": "top_dry", "pachira": "top_dry",
    "chinese evergreen": "top_dry", "aglaonema": "top_dry", "dieffenbachia": "top_dry",
    "yucca": "dry_tolerant", "bird of paradise": "top_dry", "alocasia": "top_dry", "anthurium": "top_dry",
    "parlor palm": "top_dry", "peperomia": "dry_tolerant", "begonia": "top_dry",
    "fern": "evenly_moist", "boston fern": "evenly_moist", "maidenhair fern": "evenly_moist",
    "calathea": "evenly_moist", "prayer plant": "evenly_moist", "maranta": "evenly_moist",
    "peace lily": "evenly_moist", "spathiphyllum": "evenly_moist", "fittonia": "evenly_moist",
    "orchid": "orchid", "phalaenopsis": "orchid", "basil": "herb", "mint": "herb", "parsley": "herb",
}
MATERIAL = {"plastic": 1.0, "glazed": 1.0, "ceramic": 1.0, "metal": 1.0, "terracotta": 0.75,
            "clay": 0.75, "unglazed": 0.75, "fabric": 0.7, "self-watering": 1.5}
LIGHT = {"low": 1.4, "medium": 1.0, "bright": 0.85, "direct": 0.7}
HUMIDITY = {"low": 0.85, "normal": 1.0, "high": 1.15}
SEASON_FACTOR = {"spring": 1.0, "summer": 0.85}
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]


class InputError(Exception):
    pass


def season_of(d, hemisphere):
    m = d.month if hemisphere == "north" else (d.month + 5) % 12 + 1
    return {12: "winter", 1: "winter", 2: "winter", 3: "spring", 4: "spring", 5: "spring",
            6: "summer", 7: "summer", 8: "summer"}.get(m, "autumn")


def pot_factor(cm):
    if cm < 10:
        return 0.6
    if cm < 14:
        return 0.8
    if cm < 20:
        return 1.0
    if cm < 28:
        return 1.3
    return 1.6


def pot_litres(cm):
    # straight-sided pot about 0.9x as tall as wide, minus ~10 % for the rim gap
    r = cm / 2.0
    return math.pi * r * r * (0.9 * cm) * 0.9 / 1000.0


def interval_days(p, season, humidity):
    base, winter, _, _ = PROFILES[p["type"]]
    # autumn sits halfway between spring and the plant's winter slow-down
    sf = {"winter": winter, "autumn": (1 + winter) / 2}.get(season) or SEASON_FACTOR[season]
    days = base * pot_factor(p["pot_cm"]) * MATERIAL[p["material"]] * LIGHT[p["light"]] * HUMIDITY[humidity] * sf
    return max(1, min(45, int(round(days))))


def parse_date(s, what):
    try:
        return dt.date.fromisoformat(s.strip())
    except ValueError:
        raise InputError(f"bad date '{s}' for {what} (use YYYY-MM-DD)")


def load_plants(text):
    rows = list(csv.DictReader(io.StringIO(text)))
    if not rows:
        raise InputError("plants CSV has no rows")
    cols = {c.strip().lower(): c for c in rows[0] if c}
    if "name" not in cols:
        raise InputError("plants CSV needs at least a 'name' column (plus plant, pot_cm, pot_material, light, drainage)")
    plants, warnings = [], []
    for i, r in enumerate(rows, 2):
        g = lambda k, d="": ((r.get(cols[k]) or "").strip() if k in cols else "") or d
        name = g("name")
        if not name:
            raise InputError(f"row {i}: empty name")
        kind = g("plant", g("type", "")).lower()
        if kind in PROFILES:
            ptype = kind
        elif kind in SPECIES:
            ptype = SPECIES[kind]
        else:
            ptype = next((v for k, v in SPECIES.items() if k in kind), None) if kind else None
            if ptype is None:
                ptype = "top_dry"
                warnings.append(("MEDIUM", name, f"unknown plant '{kind or '?'}': using type top_dry; set the plant column to one of {', '.join(PROFILES)}"))
        try:
            pot = float(g("pot_cm", "0") or 0)
        except ValueError:
            raise InputError(f"row {i} ({name}): pot_cm must be a number of centimetres")
        if pot <= 0:
            pot = 15.0
            warnings.append(("LOW", name, "no pot size given: assumed 15 cm"))
        if pot > 80:
            raise InputError(f"row {i} ({name}): pot_cm {pot} looks wrong (centimetres, not millimetres?)")
        mat = g("pot_material", "plastic").lower()
        if mat not in MATERIAL:
            raise InputError(f"row {i} ({name}): pot_material '{mat}' (use {', '.join(MATERIAL)})")
        light = g("light", "medium").lower().replace("bright indirect", "bright").replace("full sun", "direct")
        if light not in LIGHT:
            raise InputError(f"row {i} ({name}): light '{light}' (use low, medium, bright, direct)")
        drainage = g("drainage", "yes").lower() not in ("no", "n", "false", "0")
        last = parse_date(g("last_watered"), f"{name} last_watered") if g("last_watered") else None
        p = {"name": name, "plant": kind or ptype, "type": ptype, "pot_cm": pot, "material": mat,
             "light": light, "drainage": drainage, "last_watered": last, "notes": g("notes")}
        plants.append(p)
        if not drainage:
            warnings.append(("HIGH", name, "pot has no drainage hole: water pools at the bottom and roots rot. Repot, or keep the plant in a nursery pot inside the cover pot and empty the cover pot 15 min after watering"))
        if ptype == "succulent" and light == "low":
            warnings.append(("MEDIUM", name, "succulent in low light: it will stretch and rot easily; move it to the brightest window and water even less"))
        if ptype == "evenly_moist" and light == "direct":
            warnings.append(("MEDIUM", name, "moisture-loving plant in direct sun: leaves scorch and soil dries within a day; move it out of direct sun"))
        if ptype == "evenly_moist" and mat in ("terracotta", "clay", "unglazed", "fabric"):
            warnings.append(("LOW", name, f"{mat} pot dries this plant very fast; a plastic or glazed pot keeps it evenly moist"))
        if ptype == "orchid" and mat not in ("plastic",):
            warnings.append(("LOW", name, "orchids are easiest in a clear plastic pot with drainage, so you can see the roots"))
        if mat == "self-watering" and ptype in ("succulent", "dry_tolerant", "orchid"):
            warnings.append(("MEDIUM", name, "self-watering pots keep the soil wet; not suited to plants that need to dry out"))
    return plants, warnings


def amount_text(p):
    _, _, _, share = PROFILES[p["type"]]
    if p["type"] == "orchid":
        return "soak 10 min"
    if p["material"] == "self-watering":
        return "refill reservoir"
    ml = pot_litres(p["pot_cm"]) * 1000 * share
    ml = int(round(ml / 50.0) * 50) or 50
    return f"~{ml} ml (until it drains)" if p["drainage"] else f"~{ml // 2} ml, no more"


def snap(due, prev, allowed):
    """Allowed weekday nearest to the due date (ties go earlier), always after prev."""
    if not allowed:
        return due
    best = None
    for off in (0, -1, 1, -2, 2, -3, 3):
        d = due + dt.timedelta(days=off)
        if d > prev and d.weekday() in allowed:
            best = d
            break
    if best is None:
        best = max(due, prev + dt.timedelta(days=1))
        while best.weekday() not in allowed:
            best += dt.timedelta(days=1)
    return best


def plan(plants, start, weeks, hemisphere="north", humidity="normal", check_days=None):
    end = start + dt.timedelta(days=7 * weeks - 1)
    allowed = {DAY_NAMES.index(x) for x in check_days} if check_days else set()
    cal, per_plant, notes = {}, [], []
    for p in plants:
        s0 = season_of(start, hemisphere)
        iv = interval_days(p, s0, humidity)
        if p["last_watered"]:
            due = p["last_watered"] + dt.timedelta(days=iv)
            overdue = due < start
            first = max(due, start)
        else:
            overdue, first = False, start
        d = snap(first, start - dt.timedelta(days=1), allowed) if allowed else first
        dates, prev = [], None
        while d <= end:
            dates.append(d)
            cal.setdefault(d, []).append(p["name"])
            step = interval_days(p, season_of(d, hemisphere), humidity)
            due = d + dt.timedelta(days=step)
            nd = snap(due, d, allowed)
            if allowed and (nd - due).days > max(1, step // 5):
                notes.append((p["name"], due, nd))
            prev, d = d, nd
        later = {season_of(x, hemisphere) for x in dates} - {s0}
        per_plant.append({**p, "interval_now": iv, "season_now": s0,
                          "intervals_later": {s: interval_days(p, s, humidity) for s in sorted(later)},
                          "rule": PROFILES[p["type"]][2], "amount": amount_text(p),
                          "checks": [x.isoformat() for x in dates], "overdue": overdue})
    return {"start": start, "end": end, "hemisphere": hemisphere, "humidity": humidity,
            "check_days": check_days or [], "plants": per_plant,
            "calendar": [(d, sorted(cal[d], key=str.lower)) for d in sorted(cal)], "late_snaps": notes}


def render(res, warnings):
    st, en = res["start"], res["end"]
    out = [f"# Houseplant watering plan: {st.isoformat()} to {en.isoformat()}", "",
           f"Hemisphere: {res['hemisphere']}   Season at start: {season_of(st, res['hemisphere'])}   "
           f"Humidity: {res['humidity']}   Check days: {', '.join(res['check_days']) or 'any day'}   Plants: {len(res['plants'])}", "",
           "A check is not a watering: feel the soil and water only when the rule says so.", "",
           "## Plants", "",
           "| Plant | Type | Pot | Light | Check every | Water when | About | Checks |",
           "|---|---|---|---|---|---|---|---|"]
    for p in res["plants"]:
        iv = f"{p['interval_now']} d"
        if p["intervals_later"]:
            iv += " (" + ", ".join(f"{s}: {v} d" for s, v in p["intervals_later"].items()) + ")"
        pot = f"{p['pot_cm']:g} cm {p['material']}" + ("" if p["drainage"] else ", NO hole")
        flag = " (overdue: check first day)" if p["overdue"] else ""
        out.append(f"| {p['name']} ({p['plant']}){flag} | {p['type']} | {pot} | {p['light']} | {iv} | {p['rule']} | {p['amount']} | {len(p['checks'])} |")
    out += ["", "## Calendar", "", "| Date | Day | Season | Check |", "|---|---|---|---|"]
    for d, names in res["calendar"]:
        out.append(f"| {d.isoformat()} | {DAY_NAMES[d.weekday()]} | {season_of(d, res['hemisphere'])} | {', '.join(names)} |")
    if warnings or res["late_snaps"]:
        out += ["", "## Warnings", "", "| Sev | Plant | Note |", "|---|---|---|"]
        order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
        for sev, name, msg in sorted(warnings, key=lambda w: (order[w[0]], w[1].lower())):
            out.append(f"| {sev} | {name} | {msg} |")
        seen = set()
        for name, due, nd in res["late_snaps"]:
            if name in seen:
                continue
            seen.add(name)
            out.append(f"| LOW | {name} | check days push some checks more than a day late (e.g. due {due.isoformat()}, planned {nd.isoformat()}); add a check day or feel the soil in between |")
    out += ["", "Intervals assume the plant is established in a free-draining mix. After 2-3 weeks, lengthen an interval if the soil is still moist at most checks, shorten it if it is always bone dry."]
    return "\n".join(out) + "\n"


def to_ics(res, stamp):
    lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//houseplant-watering-schedule-planner//EN", "CALSCALE:GREGORIAN"]
    for d, names in res["calendar"]:
        nd = d + dt.timedelta(days=1)
        lines += ["BEGIN:VEVENT", f"UID:plants-{d.strftime('%Y%m%d')}@watering-planner", f"DTSTAMP:{stamp}",
                  f"DTSTART;VALUE=DATE:{d.strftime('%Y%m%d')}", f"DTEND;VALUE=DATE:{nd.strftime('%Y%m%d')}",
                  "SUMMARY:" + ics_escape("Check plants: " + ", ".join(names)), "TRANSP:TRANSPARENT", "END:VEVENT"]
    lines.append("END:VCALENDAR")
    return "\r\n".join(fold(l) for l in lines) + "\r\n"


def ics_escape(s):
    return s.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")


def fold(line):
    b = line.encode("utf-8")
    if len(b) <= 75:
        return line
    parts, cur = [], b""
    for ch in line:
        e = ch.encode("utf-8")
        if len(cur) + len(e) > (75 if not parts else 74):
            parts.append(cur.decode("utf-8")); cur = b""
        cur += e
    parts.append(cur.decode("utf-8"))
    return "\r\n ".join(parts)


def self_test():
    H = "name,plant,pot_cm,pot_material,light,drainage,last_watered\n"
    def run(rows, **kw):
        plants, warns = load_plants(H + rows)
        return plan(plants, kw.pop("start", dt.date(2026, 4, 6)), kw.pop("weeks", 4), **kw), warns
    cases = []
    def case(name, fn):
        try:
            ok = bool(fn())
            err = ""
        except Exception as e:  # noqa
            ok, err = False, repr(e)
        cases.append((name, ok, err))

    case("succulent checked less often than fern", lambda: run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > run("A,cactus,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0]["plants"][1]["interval_now"])
    case("base interval top_dry 15 cm plastic medium spring = 6", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] == 6)
    case("small pot dries faster", lambda: run("A,pothos,9,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
    case("big pot lasts longer", lambda: run("A,pothos,30,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"] > 6)
    case("terracotta dries faster than plastic", lambda: run("A,pothos,15,terracotta,medium,yes,\n")[0]["plants"][0]["interval_now"] < 6)
    case("low light lengthens, direct sun shortens", lambda: run("A,pothos,15,plastic,low,yes,\n")[0]["plants"][0]["interval_now"] > 6 > run("A,pothos,15,plastic,direct,yes,\n")[0]["plants"][0]["interval_now"])
    case("winter stretches succulent 2.5x", lambda: run("A,cactus,15,plastic,medium,yes,\n", start=dt.date(2026, 1, 5))[0]["plants"][0]["interval_now"] == 25)
    case("summer shortens", lambda: run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 7, 6))[0]["plants"][0]["interval_now"] == 5)
    case("southern hemisphere: July is winter", lambda: season_of(dt.date(2026, 7, 1), "south") == "winter" and season_of(dt.date(2026, 1, 1), "south") == "summer")
    case("season boundaries north", lambda: [season_of(dt.date(2026, m, 15), "north") for m in (2, 3, 5, 6, 8, 9, 11, 12)] == ["winter", "spring", "spring", "summer", "summer", "autumn", "autumn", "winter"])
    case("interval grows across autumn->winter in plan", lambda: (lambda r: r["plants"][0]["intervals_later"].get("winter", 0) > r["plants"][0]["interval_now"])(run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 11, 16), weeks=4)[0]))
    case("checks spaced by interval", lambda: run("A,pothos,15,plastic,medium,yes,\n")[0]["plants"][0]["checks"][:3] == ["2026-04-06", "2026-04-12", "2026-04-18"])
    case("last_watered sets first check", lambda: run("A,pothos,15,plastic,medium,yes,2026-04-04\n")[0]["plants"][0]["checks"][0] == "2026-04-10")
    case("overdue goes to start", lambda: (lambda p: p["overdue"] and p["checks"][0] == "2026-04-06")(run("A,pothos,15,plastic,medium,yes,2026-03-20\n")[0]["plants"][0]))
    case("snap to check days (Mon,Thu)", lambda: all(dt.date.fromisoformat(x).weekday() in (0, 3) for x in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["calendar"] and [x for p in run("A,pothos,15,plastic,medium,yes,\nB,cactus,12,terracotta,bright,yes,\n", check_days=["Mon", "Thu"])[0]["plants"] for x in p["checks"]]))
    case("snap to nearest check day (Sun -> Mon)", lambda: snap(dt.date(2026, 4, 12), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 13))
    case("snap tie goes earlier (Tue between Mon and Thu? no: Tue -> Mon)", lambda: snap(dt.date(2026, 4, 14), dt.date(2026, 4, 9), {0, 3}) == dt.date(2026, 4, 13))
    case("snap never on or before the previous check", lambda: snap(dt.date(2026, 4, 7), dt.date(2026, 4, 6), {0, 3}) == dt.date(2026, 4, 9))
    case("autumn between spring and winter", lambda: 6 < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 10, 5))[0]["plants"][0]["interval_now"] < run("A,pothos,15,plastic,medium,yes,\n", start=dt.date(2026, 12, 7))[0]["plants"][0]["interval_now"])
    case("late snap noted for fast plant", lambda: len(run("A,basil,9,terracotta,direct,yes,\n", check_days=["Mon"])[0]["late_snaps"]) > 0)
    case("no drainage -> HIGH warning and half amount", lambda: (lambda r: any(w[0] == "HIGH" for w in r[1]) and "no more" in r[0]["plants"][0]["amount"])(run("A,pothos,15,glazed,medium,no,\n")))
    case("unknown plant -> top_dry with warning", lambda: (lambda r: r[0]["plants"][0]["type"] == "top_dry" and any("unknown plant" in w[2] for w in r[1]))(run("A,triffid,15,plastic,medium,yes,\n")))
    case("species substring match", lambda: run("A,variegated monstera,15,plastic,medium,yes,\n")[0]["plants"][0]["type"] == "top_dry")
    case("succulent low light warning", lambda: any("stretch" in w[2] for w in run("A,echeveria,10,terracotta,low,yes,\n")[1]))
    case("fern in direct sun warning", lambda: any("scorch" in w[2] for w in run("A,boston fern,20,plastic,direct,yes,\n")[1]))
    case("self-watering succulent warning", lambda: any("self-watering" in w[2] for w in run("A,cactus,15,self-watering,bright,yes,\n")[1]))
    case("orchid amount is soak", lambda: run("A,orchid,12,plastic,bright,yes,\n")[0]["plants"][0]["amount"] == "soak 10 min")
    case("amount scales with pot", lambda: int(run("A,pothos,25,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]) > int(run("A,pothos,12,plastic,medium,yes,\n")[0]["plants"][0]["amount"][1:].split()[0]))
    case("low humidity shortens", lambda: run("A,fern,20,plastic,medium,yes,\n", humidity="low")[0]["plants"][0]["interval_now"] <= run("A,fern,20,plastic,medium,yes,\n")[0]["plants"][0]["interval_now"])
    case("interval never below 1 day", lambda: run("A,basil,6,fabric,direct,yes,\n", start=dt.date(2026, 7, 6), humidity="low")[0]["plants"][0]["interval_now"] >= 1)
    case("missing pot size assumed 15", lambda: (lambda r: r[0]["plants"][0]["pot_cm"] == 15 and any("assumed 15" in w[2] for w in r[1]))(run("A,pothos,,plastic,medium,yes,\n")))
    def bad(rows):
        try:
            load_plants(H + rows); return False
        except InputError:
            return True
    case("bad material rejected", lambda: bad("A,pothos,15,wood,medium,yes,\n"))
    case("bad light rejected", lambda: bad("A,pothos,15,plastic,dim,yes,\n"))
    case("pot in mm rejected", lambda: bad("A,pothos,150,plastic,medium,yes,\n"))
    case("bad date rejected", lambda: bad("A,pothos,15,plastic,medium,yes,30/10/2026\n"))
    case("ics has one event per date, CRLF, escaped commas", lambda: (lambda r, t: t.count("BEGIN:VEVENT") == len(r["calendar"]) and "\r\n" in t and "\\," in t)(*(lambda r: (r, to_ics(r, "20261030T000000Z")))(run("A,pothos,15,plastic,medium,yes,\nB,fern,15,plastic,medium,yes,\n")[0])))
    case("ics lines folded to 75 octets", lambda: all(len(l.encode()) <= 75 for l in to_ics(run("".join(f"Plant number {i},pothos,15,plastic,medium,yes,\n" for i in range(8)))[0], "20261030T000000Z").split("\r\n")))
    passed = 0
    for name, ok, err in cases:
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  {err}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Build a houseplant watering check calendar")
    ap.add_argument("plants", nargs="?")
    ap.add_argument("--start", help="first day of the plan, YYYY-MM-DD (default today)")
    ap.add_argument("--weeks", type=int, default=4)
    ap.add_argument("--hemisphere", choices=["north", "south"], default="north")
    ap.add_argument("--humidity", choices=list(HUMIDITY), default="normal", help="low = heating on / below ~35 %%")
    ap.add_argument("--check-days", help="snap checks to these weekdays, e.g. Mon,Thu")
    ap.add_argument("--ics", help="also write an .ics calendar file")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.plants:
        ap.error("give a plants CSV, or --self-test")
    try:
        start = parse_date(a.start, "--start") if a.start else dt.date.today()
        if not 1 <= a.weeks <= 26:
            raise InputError("--weeks must be 1..26")
        days = None
        if a.check_days:
            days = [x.strip()[:3].title() for x in a.check_days.split(",") if x.strip()]
            for x in days:
                if x not in DAY_NAMES:
                    raise InputError(f"unknown weekday '{x}' in --check-days")
        with open(a.plants, encoding="utf-8") as fh:
            plants, warnings = load_plants(fh.read())
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    res = plan(plants, start, a.weeks, a.hemisphere, a.humidity, days)
    if a.ics:
        stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        with open(a.ics, "w", encoding="utf-8", newline="") as fh:
            fh.write(to_ics(res, stamp))
    if a.json:
        out = {**res, "start": res["start"].isoformat(), "end": res["end"].isoformat(),
               "calendar": [{"date": d.isoformat(), "plants": n} for d, n in res["calendar"]],
               "late_snaps": [{"plant": n, "due": d.isoformat(), "planned": p.isoformat()} for n, d, p in res["late_snaps"]],
               "plants": [{**p, "last_watered": p["last_watered"].isoformat() if p["last_watered"] else None} for p in res["plants"]],
               "warnings": [{"severity": s, "plant": n, "note": m} for s, n, m in warnings]}
        print(json.dumps(out, indent=2))
    else:
        sys.stdout.write(render(res, warnings))
    return 1 if any(w[0] == "HIGH" for w in warnings) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonPlanningGardening+1
F@f
0
通勤成本对比计算器
技能

对比自驾/公交/拼车等真实通勤成本。

---
name: commute-cost-comparison-calculator
description: 对比自驾/公交/拼车等真实通勤成本。
---

# 通勤成本对比计算器

你是「通勤成本对比计算器」技能助手。上方 description 为中文任务说明。下方保留原技能完整正文(含脚本与代码);执行时遵循中文说明与原文技术细节。

# Commute Cost Comparison Calculator

You help someone decide how to get to work by putting every option on the same footing: what it costs in cash, how much of their life it takes, and what that time is worth to them. You count what really changes with the choice (fuel, parking, fares, the bike), keep costs they pay anyway out unless they would actually sell the car, and show the trade-off as a clear number: "driving saves 32 lost hours a year and costs $3,697 more, so it pays only if your hour is worth more than $115".

## Files in this skill

- `scripts/commute_cost.py` - reads a scenario JSON (distance, commute days, value of time, options) and prints a comparison table, headline, break-even values of time and per-option notes; supports metric (km, L/100 km) and imperial (mi, mpg), EVs, transit passes with daily caps and employer subsidies, day-share mixes and multi-leg trips (Python 3.8+ standard library only)
- `references/cost-components.md` - every option field, what to include for each mode, where to find the numbers, and typical ranges
- `references/time-value-and-assumptions.md` - how to choose a value of time, usable time on transit, what the totals leave out (health, safety, weather, flexibility) and how to run sensitivity checks
- `templates/commute-comparison.md` - the decision summary to hand back
- `examples/commute.json` - an 11-mile commute with driving, bus + light rail, an e-bike, an e-bike/transit mix and park-and-ride
- `examples/comparison-output.md` - the real output of the calculator for that scenario

## Workflow

### 1. Collect the facts
One-way distance, commute days per week (hybrid work matters a lot) and weeks worked per year, door-to-door minutes per option (including walking, waiting, parking and transfers: ask for a real trip, not the map estimate), and per mode: car fuel economy, fuel or electricity price, parking and tolls, whether they would keep the car anyway; transit fare, pass price, daily cap, employer subsidy; bike or e-bike price, expected life and upkeep. Ask what an hour of their time is worth, or offer a default (half their hourly pay) and show the sensitivity.

### 2. Run the calculator
```
python3 scripts/commute_cost.py examples/commute.json
python3 scripts/commute_cost.py scenario.json --days-per-week 3
python3 scripts/commute_cost.py scenario.json --value-of-time 0 --json
python3 scripts/commute_cost.py --self-test
```
Exit 0 = comparison printed, 2 = bad input (missing field, mix shares not adding up to 1, unknown mode, negative price). Without code, follow the formulas in `references/cost-components.md`.

### 3. Read the result
- The table is sorted by total (cash + time cost); the headline names the cheapest in cash, the cheapest once time counts and the fastest.
- **Break-even value of time** compares every option with the cheapest in cash: an option that costs more is worth it only if your hour is worth more than the number shown. "Never" means it costs more and does not save usable time.
- Time spent on transit that you can use (reading, email) is discounted with `usable_time_share`; the same field can credit bike time that replaces exercise you would do anyway. Say which you used.
- Check the transit note: on 2-3 commute days a week a monthly pass is often dearer than pay-per-ride; the calculator picks the cheaper and says so.

### 4. Deliver
Fill `templates/commute-comparison.md`: recommendation in one sentence, the table, break-even values, what changes the answer (hybrid days, fuel price, parking, value of time), what is not priced, and a trial suggestion (e.g. two weeks of the mix before buying anything).

## Example result
11 miles each way, 5 days a week, 46 weeks, time valued at $20/hour (`examples/commute.json`). Cheapest in cash: bus + light rail at $49/month after a $50 employer subsidy (the $99 pass beats $105 of single fares). Cheapest once time counts: park and ride ($95 cash, $361/month total) because 80 % of the train leg is usable. Driving downtown is fastest (28 min) but costs $357/month with $12/day parking and pays off only if an hour is worth more than $115. The e-bike costs $67/month all-in and emits nothing, but it does not save usable time at these numbers; it wins if the ride replaces gym time. See `examples/comparison-output.md`.

## Rules

- Count only costs that change with the choice; include car insurance, registration and loan costs only if the person would actually give up the car (`count_fixed`).
- Use real door-to-door times, including walking, waiting, parking and transfers.
- Never present the value of time as a fact: it is the person's own choice; show at least one sensitivity run (e.g. `--value-of-time 0` and their wage).
- Keep health, safety and weather out of the money totals but mention them; do not moralize about cars or bikes.
- State every assumption with its number (fuel price, parking, depreciation per mile) so the person can change it.
- Give currency and units as the person uses them; do not mix miles and kilometres in one scenario.
FILE:references/cost-components.md
# Cost components and option fields

Scenario fields: `currency` (USD, EUR, GBP, TRY, ...), `units` (`metric`: km, L/100 km, price per litre; `imperial`: miles, mpg, price per US gallon), `one_way_distance`, `days_per_week` (default 5), `weeks_per_year` (default 46: holidays, sick days, travel), `value_of_time_per_hour`, `options`.

Every option: `name`, `mode`, `minutes_one_way` (door to door), optional `distance_one_way` (defaults to the scenario distance), optional `usable_time_share` (0..1).

## Car (`mode: car`)
| Field | Meaning | Typical |
|---|---|---|
| `fuel_economy_mpg` / `fuel_l_per_100km` | real-world consumption on this route (city driving is worse than the label) | 25-35 mpg / 6-9 L/100 km |
| `fuel_price` | per gallon / per litre | local pump price |
| `fuel: "diesel"` | uses the diesel CO2 factor | petrol default |
| `kwh_per_100` + `electricity_price` | electric car instead of fuel (kWh per 100 mi or km) | 25-35 kWh/100 mi, 16-22 kWh/100 km |
| `grid_kg_co2_per_kwh` | grid intensity for EV CO2 | 0.4 default; 0.05 hydro/nuclear, 0.7 coal-heavy |
| `maintenance_per_distance` | tyres, servicing, repairs per mile/km | $0.08-0.12/mi, 0.05-0.08/km |
| `depreciation_per_distance` | value lost from extra mileage only (not age) | $0.05-0.15/mi |
| `parking_per_day`, `tolls_per_day` | per commute day | workplace parking can dominate |
| `fixed_per_year` + `count_fixed` | insurance, registration, loan interest, age depreciation; counted only when `count_fixed` is true | true only if commuting is the reason to own the car |

Fuel cost = 2 x distance x days / mpg x price (or x L/100 km / 100 x price). CO2: petrol 2.35 kg/L (8.89 kg per US gallon), diesel 2.68 kg/L (10.18 kg/gal), EV kWh x grid factor.

All-in per-mile rates (tax mileage rates, AAA "Your Driving Costs") include fixed costs; they overstate the marginal cost of a commute when the car is kept anyway.

## Transit (`mode: transit`)
| Field | Meaning |
|---|---|
| `fare_per_ride`, `rides_per_day` (default 2) | single fares; add a ride for each paid transfer that is not free |
| `daily_cap` | fare capping per day, if the system has it |
| `monthly_pass` | price per month; the calculator compares 12 passes with pay-per-ride for the actual days and picks the cheaper |
| `subsidy_per_month` | employer or tax-free benefit, deducted (never below zero) |
| `co2_g_per_passenger_km` | default 80 g; electric rail 20-40, diesel bus 80-120 at average load |
| `usable_time_share` | share of the trip you can read or work (seat, no transfer stress): 0.2-0.8 |

## Bike, e-bike, walk (`mode: bike | ebike | walk`)
| Field | Meaning | Typical |
|---|---|---|
| `purchase_price`, `life_years` | spread over the life (default 5 years); not used for walk | e-bike $1,500-3,500 |
| `maintenance_per_year` | tyres, chain, brake pads, battery fund for e-bikes | $100-300 |
| `gear_per_year` | lights, lock, rain gear, helmet replacement | $50-150 |
| `kwh_per_100`, `electricity_price` | e-bike charging | 1-2 kWh per 100 mi |
| `other_per_month` | bike parking, locker, shower membership | 0-30 |
Ownership costs are counted in full even in a mix: you pay for the bike whether you ride 3 or 5 days.

## Mix (`mode: mix`)
`"shares": {"E-bike": 0.6, "Bus + light rail": 0.4}` splits commute days between other options (shares add up to 1). Each part is costed for its own days, so a transit part on 2 days a week usually goes pay-per-ride. Use it for weather-dependent cycling, hybrid driving, or carpool days.

## Multi-leg (`mode: multi`)
`"legs": [ {car leg}, {transit leg} ]` with `transfer_minutes`. Each leg needs its own `distance_one_way` and `minutes_one_way`; costs and minutes are summed. Use it for park and ride, bike + train, or walk + bus.

## Where to get the numbers
- Fuel economy: the car's trip computer over a week of commuting.
- Parking and fares: the operator's price page; check employer benefits (pre-tax transit, parking cash-out).
- Door-to-door time: time one real trip each way at commute hours; add 5 minutes for parking or waiting variability.
FILE:references/time-value-and-assumptions.md
# Value of time and what the totals leave out

## Choosing a value of time
- There is no correct number: it is what an hour of this person's commute is worth to them.
- Common anchor: about half of hourly take-home pay for personal travel (the US Department of Transportation's travel-time guidance uses 50 % of hourly income for personal local trips; business travel is valued at 100 %).
- Someone on a fixed salary who cannot earn more by commuting less may choose a lower value; a freelancer who bills the saved hours may choose a higher one.
- Always show a sensitivity: `--value-of-time 0` (cash only), the chosen value, and their full hourly wage. If the ranking does not change, the decision is robust.

## Usable time
`usable_time_share` discounts time you can use. Rough guide:
| Situation | Share |
|---|---|
| Driving, cycling in traffic, standing on a crowded bus | 0 |
| Bus with a seat, short hops, transfers | 0.2-0.4 |
| Train with a seat and table, 20+ minutes without changes | 0.6-0.8 |
| Bike or walk that replaces exercise you would otherwise do | 0.3-0.7 (say so explicitly) |

## Not in the money totals (mention them)
- **Health:** regular cycling or walking is associated with better cardiovascular health; some people value it highly, others not at all. Use `usable_time_share` if they want it priced.
- **Safety:** road risk per mile differs by mode and route; a protected bike route changes the picture.
- **Weather and season:** use a mix (e.g. 60 % bike days) rather than assuming every day is a bike day.
- **Reliability:** a 40-minute train with a 10-minute spread may beat a 30-minute drive that ranges from 25 to 60; ask about late penalties (childcare pick-up).
- **Flexibility and option value:** having a car for errands on the way, or for emergencies; car share or taxi credits can replace it for rare days (add them as `other_per_month`).
- **Stress and comfort:** do not price it, but ask.

## Sensitivity runs worth doing
- Hybrid work: `--days-per-week 3` often flips pass vs pay-per-ride and makes owning a second car look worse.
- Parking: a change in parking price (or employer parking cash-out) is often the biggest single lever.
- Fuel or electricity price +-25 %.
- Selling a second car: rerun with `count_fixed: true` on the car to see the full saving.

## Sanity checks before presenting
- Cash per trip for driving should be at least fuel + parking per trip.
- Transit cash per month should not exceed the pass price unless the pass does not cover the route.
- Hours per month = minutes one way x 2 x days per month / 60.
- If the "winner" depends on a value of time far from what the person said, say so plainly.
FILE:templates/commute-comparison.md
# Commute options: <home> to <work>

**Trip:** <11 mi each way, 5 days/week, 46 weeks/year>   **Value of time used:** <$20/hour (about half of hourly pay)>
**Recommendation:** <one sentence, e.g. "Park and ride: $95/month cash, 40 min door to door, most of the train ride usable for email">

## Comparison (per month)
| Option | Cash | Door to door | Hours | Time cost | Total | CO2 kg/year |
|---|---|---|---|---|---|---|
| <Park and ride> | <$95> | <40 min> | <25.6> | <$266> | <$361> | <999> |
| <Bus + light rail> | <$49> | <46 min> | <29.4> | <$411> | <$460> | <651> |

## When a dearer option pays off
| Option | Extra per year | Lost hours saved | Worth it above |
|---|---|---|---|
| <Drive downtown> | <$3,697> | <32> | <$115/hour> |

## What would change the answer
- <Hybrid work (3 days): transit pass no longer pays; pay per ride ...>
- <Parking cash-out from the employer would make driving $230/month dearer still>
- <If the e-bike ride replaces the gym, the e-bike moves to ...>

## Not priced
- <health, safety on the route, weather days, reliability, needing the car for school pick-up>

## Assumptions
- <fuel $3.40/gal at 28 mpg; maintenance $0.10/mi; mileage depreciation $0.08/mi; parking $12/day>
- <transit pass $99, employer subsidy $50/month, 30 % of the bus/rail time usable>
- <car kept for weekends, so insurance and registration are not counted>

## Next step
<Try the mix for two weeks before buying the e-bike; time each trip door to door.>
FILE:examples/commute.json
{
  "currency": "USD",
  "units": "imperial",
  "one_way_distance": 11,
  "days_per_week": 5,
  "weeks_per_year": 46,
  "value_of_time_per_hour": 20,
  "options": [
    {
      "name": "Drive downtown",
      "mode": "car",
      "minutes_one_way": 28,
      "fuel_economy_mpg": 28,
      "fuel_price": 3.4,
      "maintenance_per_distance": 0.1,
      "depreciation_per_distance": 0.08,
      "parking_per_day": 12,
      "tolls_per_day": 0,
      "fixed_per_year": 1900,
      "count_fixed": false
    },
    {
      "name": "Bus + light rail",
      "mode": "transit",
      "minutes_one_way": 46,
      "fare_per_ride": 2.75,
      "monthly_pass": 99,
      "subsidy_per_month": 50,
      "usable_time_share": 0.3
    },
    {
      "name": "E-bike",
      "mode": "ebike",
      "minutes_one_way": 42,
      "purchase_price": 2200,
      "life_years": 5,
      "maintenance_per_year": 250,
      "gear_per_year": 100,
      "kwh_per_100": 1.5,
      "electricity_price": 0.17
    },
    {
      "name": "E-bike 3 days, transit 2 days",
      "mode": "mix",
      "shares": {"E-bike": 0.6, "Bus + light rail": 0.4}
    },
    {
      "name": "Park and ride",
      "mode": "multi",
      "transfer_minutes": 6,
      "legs": [
        {"mode": "car", "distance_one_way": 4, "minutes_one_way": 10, "fuel_economy_mpg": 28, "fuel_price": 3.4,
         "maintenance_per_distance": 0.1, "depreciation_per_distance": 0.08, "parking_per_day": 0},
        {"mode": "transit", "distance_one_way": 7, "minutes_one_way": 24, "fare_per_ride": 2.75, "monthly_pass": 99,
         "subsidy_per_month": 50, "usable_time_share": 0.8}
      ]
    }
  ]
}
FILE:examples/comparison-output.md
# Commute cost comparison

One way: 11 mi   Commute days: 19.2/month (230/year)   Value of time: $20.00/hour   Currency: USD

| Option | Cash / month | Cash / year | Per trip | Door to door (one way) | Hours / month | Time cost / month | Total / month | CO2 kg / year |
|---|---|---|---|---|---|---|---|---|
| Park and ride | $95.22 | $1,143 | $2.48 | 40 min | 25.6 | $266 | **$361** | 999 |
| Bus + light rail | $49.00 | $588 | $1.28 | 46 min | 29.4 | $411 | **$460** | 651 |
| E-bike 3 days, transit 2 days | $66.48 | $798 | $1.73 | 44 min | 27.9 | $487 | **$553** | 261 |
| E-bike | $66.91 | $803 | $1.75 | 42 min | 26.8 | $537 | **$604** | 0 |
| Drive downtown | $357 | $4,285 | $9.32 | 28 min | 17.9 | $358 | **$715** | 1,607 |

Sorted by total (cash + time cost).

## Headline

- Cheapest in cash: **Bus + light rail** ($49.00/month).
- Cheapest once time counts: **Park and ride** ($361/month at $20.00/hour).
- Fastest: **Drive downtown** (28 min one way, 17.9 h/month).

## Break-even value of time (vs Bus + light rail, the cheapest in cash)

| Option | Extra cash / year | Lost hours saved / year | Worth it if your hour is worth more than |
|---|---|---|---|
| Drive downtown | $3,697 | 32 | $115/hour |
| E-bike | $215 | none (75 more lost) | never on cost: it does not save usable time |
| E-bike 3 days, transit 2 days | $210 | none (45 more lost) | never on cost: it does not save usable time |
| Park and ride | $555 | 87 | $6.35/hour |

## Notes per option

- **Drive downtown** (car): per month: fuel/energy $51.20, maintenance + depreciation $75.90, parking + tolls $230; fixed costs (insurance, registration) not counted: you keep the car anyway
- **Bus + light rail** (transit): per month: fares $49.00; monthly pass (cheaper than $105/month pay per ride at 19.2 days/month); employer subsidy $50.00/month deducted
- **E-bike** (ebike): per month: bike + upkeep $65.83, fuel/energy $1.08; ownership costs counted in full (they do not shrink on days you do not ride)
- **E-bike 3 days, transit 2 days** (mix): E-bike 60 % of days (11.5/month): ownership costs counted in full (they do not shrink on days you do not ride); Bus + light rail 40 % of days (7.7/month): pay per ride (pass would cost $99.00/month at 7.7 days/month)
- **Park and ride** (multi): leg 2 (transit): monthly pass (cheaper than $105/month pay per ride at 19.2 days/month); leg 2 (transit): employer subsidy $50.00/month deducted

Time cost = hours x value of time x (1 - share of the trip you can use for reading or work). Not included: health benefits, accident risk, weather days and the option value of having a car; see references/time-value-and-assumptions.md.
FILE:scripts/commute_cost.py
#!/usr/bin/env python3
"""Commute cost comparison calculator (Python 3.8+ standard library only).

Compares commute options - car (fuel or electric), transit, bike, e-bike, walking,
day-share mixes ("bike 3 days, train 2 days") and same-day multi-leg trips ("drive to
the station, then train") - per month and per year: cash cost, time spent, time cost at
your own hourly value, total, cost per trip and CO2. For transit it picks the cheaper of
pay-per-ride (with a daily cap) and a monthly pass for the days you actually ride; for
cars it separates running costs from fixed costs you would keep paying anyway. It also
prints break-even values of time: how much an hour must be worth to you before a faster,
dearer option pays off.

Usage:
  python3 commute_cost.py scenario.json [--value-of-time 25] [--days-per-week 3] [--json]
  python3 commute_cost.py --self-test
scenario.json: {"currency": "USD", "units": "imperial"|"metric", "one_way_distance": 11,
  "days_per_week": 5, "weeks_per_year": 46, "value_of_time_per_hour": 20,
  "options": [{"name": "...", "mode": "car|transit|bike|ebike|walk|mix|multi", ...}]}
See references/cost-components.md for every option field.
Exit codes: 0 = comparison printed, 2 = bad input.
"""
import argparse, json, sys

PETROL_CO2 = {"metric": 2.35, "imperial": 8.89}   # kg CO2 per litre / per US gallon burned (diesel: 2.68 / 10.18)
DIESEL_CO2 = {"metric": 2.68, "imperial": 10.18}
KM_PER_UNIT = {"metric": 1.0, "imperial": 1.609344}
LABELS = {"energy": "fuel/energy", "running": "maintenance + depreciation", "parking_tolls": "parking + tolls",
          "fixed": "fixed costs", "fares": "fares", "ownership": "bike + upkeep", "other": "other"}
MODES = {"car", "transit", "bike", "ebike", "walk", "mix", "multi"}
SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "TRY": "₺", "CAD": "C$", "AUD": "A$", "JPY": "¥", "INR": "₹"}


class InputError(Exception):
    pass


def num(o, key, default=None, lo=0.0, hi=None, who=""):
    v = o.get(key, default)
    if v is None:
        raise InputError(f"{who}: '{key}' is required")
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        raise InputError(f"{who}: '{key}' must be a number")
    if v < lo or (hi is not None and v > hi):
        raise InputError(f"{who}: '{key}' = {v} is out of range" + (f" ({lo}..{hi})" if hi is not None else f" (>= {lo})"))
    return float(v)


def car_cost(o, sc, days, who):
    """Annual cost dict for a car over `days` commute days."""
    units = sc["units"]
    d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days      # user units per year
    if "kwh_per_100" in o:
        kwh = d / 100 * num(o, "kwh_per_100", who=who)
        energy = kwh * num(o, "electricity_price", who=who)
        co2 = kwh * num(o, "grid_kg_co2_per_kwh", 0.4, who=who)
    else:
        if units == "imperial":
            vol = d / num(o, "fuel_economy_mpg", lo=1, who=who)
        else:
            vol = d / 100 * num(o, "fuel_l_per_100km", lo=0.5, who=who)
        energy = vol * num(o, "fuel_price", who=who)
        co2 = vol * (DIESEL_CO2 if o.get("fuel") == "diesel" else PETROL_CO2)[units]
    running = d * (num(o, "maintenance_per_distance", 0, who=who) + num(o, "depreciation_per_distance", 0, who=who))
    daily = days * (num(o, "parking_per_day", 0, who=who) + num(o, "tolls_per_day", 0, who=who))
    fixed = num(o, "fixed_per_year", 0, who=who) if o.get("count_fixed", False) else 0.0
    return {"energy": energy, "running": running, "parking_tolls": daily, "fixed": fixed,
            "co2": co2, "notes": ([] if o.get("count_fixed", False) or not o.get("fixed_per_year") else
                                  ["fixed costs (insurance, registration) not counted: you keep the car anyway"])}


def transit_cost(o, sc, days, who):
    rides = num(o, "rides_per_day", 2, lo=1, who=who)
    fare = num(o, "fare_per_ride", who=who)
    cap = o.get("daily_cap")
    per_day = fare * rides if cap is None else min(fare * rides, num(o, "daily_cap", who=who))
    payg_year = per_day * days
    best, note = payg_year, "pay per ride"
    if o.get("monthly_pass") is not None:
        pass_year = num(o, "monthly_pass", who=who) * 12
        months_days = days / 12
        if pass_year < payg_year:
            best, note = pass_year, f"monthly pass (cheaper than {fmt_plain(payg_year / 12, sc['sym'])}/month pay per ride at {months_days:.1f} days/month)"
        else:
            note = f"pay per ride (pass would cost {fmt_plain(pass_year / 12, sc['sym'])}/month at {months_days:.1f} days/month)"
    subsidy = num(o, "subsidy_per_month", 0, who=who) * 12
    best = max(0.0, best - subsidy)
    km = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days * KM_PER_UNIT[sc["units"]]
    co2 = km * num(o, "co2_g_per_passenger_km", 80, who=who) / 1000
    return {"fares": best, "co2": co2, "notes": [note] + ([f"employer subsidy {fmt_plain(subsidy / 12, sc['sym'])}/month deducted"] if subsidy else [])}


def bike_cost(o, sc, days, who, mode):
    d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days
    own = 0.0
    if mode != "walk":
        life = num(o, "life_years", 5, lo=0.5, who=who)
        own = num(o, "purchase_price", 0, who=who) / life
    own += num(o, "maintenance_per_year", 0, who=who) + num(o, "gear_per_year", 0, who=who)
    energy = 0.0
    if mode == "ebike":
        kwh = d / 100 * num(o, "kwh_per_100", 1.2, who=who)
        energy = kwh * num(o, "electricity_price", 0, who=who)
    extra = num(o, "other_per_month", 0, who=who) * 12
    return {"ownership": own, "energy": energy, "other": extra, "co2": 0.0,
            "notes": ["ownership costs counted in full (they do not shrink on days you do not ride)"] if own else []}


def option_cost(o, sc, days, who):
    mode = o.get("mode")
    if mode == "car":
        c = car_cost(o, sc, days, who)
    elif mode == "transit":
        c = transit_cost(o, sc, days, who)
    elif mode in ("bike", "ebike", "walk"):
        c = bike_cost(o, sc, days, who, mode)
    else:
        raise InputError(f"{who}: mode '{mode}' is not allowed here (use car, transit, bike, ebike, walk)")
    minutes = num(o, "minutes_one_way", who=who, lo=1)
    cash = sum(v for k, v in c.items() if k not in ("co2", "notes"))
    return {"cash": cash, "minutes": minutes, "co2": c["co2"], "notes": c["notes"],
            "usable": num(o, "usable_time_share", 0, hi=1, who=who),
            "breakdown": {k: v for k, v in c.items() if k not in ("co2", "notes")}}


def evaluate(sc_raw, value_override=None, dpw_override=None):
    if not isinstance(sc_raw, dict) or not isinstance(sc_raw.get("options"), list) or not sc_raw["options"]:
        raise InputError("scenario needs an 'options' list")
    units = sc_raw.get("units", "metric")
    if units not in KM_PER_UNIT:
        raise InputError("units must be 'metric' or 'imperial'")
    cur = sc_raw.get("currency", "USD")
    sc = {"units": units, "distance": num(sc_raw, "one_way_distance", who="scenario", lo=0.1),
          "sym": SYMBOLS.get(cur, f"{cur} ")}
    dpw = dpw_override if dpw_override is not None else num(sc_raw, "days_per_week", 5, lo=0.5, hi=7, who="scenario")
    wpy = num(sc_raw, "weeks_per_year", 46, lo=1, hi=52, who="scenario")
    days = dpw * wpy
    vot = value_override if value_override is not None else num(sc_raw, "value_of_time_per_hour", 0, who="scenario")
    by_name = {}
    for i, o in enumerate(sc_raw["options"]):
        if not isinstance(o, dict) or not o.get("name") or o.get("mode") not in MODES:
            raise InputError(f"option {i + 1}: needs a 'name' and a 'mode' ({', '.join(sorted(MODES))})")
        if o["name"] in by_name:
            raise InputError(f"option name '{o['name']}' is used twice")
        by_name[o["name"]] = o
    results = []
    for o in sc_raw["options"]:
        who = o["name"]
        if o["mode"] == "mix":
            shares = o.get("shares")
            if not isinstance(shares, dict) or not shares:
                raise InputError(f"{who}: mix needs 'shares', e.g. {{\"Bike\": 0.6, \"Transit\": 0.4}}")
            tot = sum(shares.values())
            if abs(tot - 1) > 0.001:
                raise InputError(f"{who}: shares add up to {tot:g}, not 1")
            cash = co2 = hours = eff = 0.0
            notes, parts = [], []
            for nm, sh in shares.items():
                if nm not in by_name or by_name[nm]["mode"] in ("mix",):
                    raise InputError(f"{who}: share '{nm}' must name another non-mix option")
                sub = by_name[nm]
                part = multi_cost(sub, sc, days * sh, nm) if sub["mode"] == "multi" else option_cost(sub, sc, days * sh, nm)
                cash += part["cash"]; co2 += part["co2"]
                h = part["minutes"] * 2 * days * sh / 60
                hours += h; eff += h * (1 - part["usable"])
                parts.append(f"{nm} {sh * 100:.0f} % of days ({days * sh / 12:.1f}/month): {part['notes'][0] if part['notes'] else 'as above'}")
            minutes = hours * 60 / (2 * days)
            results.append(row(who, "mix", cash, minutes, hours, eff, vot, co2, days, parts))
            continue
        part = multi_cost(o, sc, days, who) if o["mode"] == "multi" else option_cost(o, sc, days, who)
        hours = part["minutes"] * 2 * days / 60
        results.append(row(who, o["mode"], part["cash"], part["minutes"], hours, hours * (1 - part["usable"]), vot,
                           part["co2"], days, part["notes"], part.get("breakdown")))
    return {"currency": sc_raw.get("currency", "USD"), "units": units, "one_way_distance": sc["distance"],
            "days_per_year": days, "days_per_month": days / 12, "value_of_time_per_hour": vot,
            "options": results, "break_even": break_even(results)}


def multi_cost(o, sc, days, who):
    legs = o.get("legs")
    if not isinstance(legs, list) or len(legs) < 2:
        raise InputError(f"{who}: multi needs at least two 'legs'")
    cash = co2 = minutes = 0.0
    notes, usable_min = [], 0.0
    for j, leg in enumerate(legs, 1):
        if leg.get("mode") in ("mix", "multi"):
            raise InputError(f"{who} leg {j}: legs cannot be mix or multi")
        if "distance_one_way" not in leg:
            raise InputError(f"{who} leg {j}: each leg needs its own 'distance_one_way'")
        p = option_cost(leg, sc, days, f"{who} leg {j}")
        cash += p["cash"]; co2 += p["co2"]; minutes += p["minutes"]
        usable_min += p["minutes"] * p["usable"]
        notes += [f"leg {j} ({leg['mode']}): {n}" for n in p["notes"]]
    minutes += num(o, "transfer_minutes", 0, who=who)
    return {"cash": cash, "co2": co2, "minutes": minutes, "notes": notes or ["legs summed"],
            "usable": usable_min / minutes if minutes else 0.0}


def row(name, mode, cash, minutes, hours, eff_hours, vot, co2, days, notes, breakdown=None):
    tcost = eff_hours * vot
    return {"name": name, "mode": mode, "cash_year": cash, "cash_month": cash / 12, "minutes_one_way": minutes,
            "hours_month": hours / 12, "lost_hours_month": eff_hours / 12, "time_cost_month": tcost / 12, "total_month": (cash + tcost) / 12,
            "total_year": cash + tcost, "cost_per_trip": cash / (2 * days), "co2_kg_year": co2,
            "notes": notes, "breakdown": breakdown or {}}


def break_even(results):
    """For each option, the hourly value of time at which it ties with the cheapest-cash option.
    Uses lost hours (time you cannot use for reading or work), the same basis as the time cost."""
    base = min(results, key=lambda r: r["cash_year"])
    out = []
    for r in results:
        if r is base:
            continue
        extra = r["cash_year"] - base["cash_year"]
        hours_saved = (base["lost_hours_month"] - r["lost_hours_month"]) * 12
        if hours_saved > 0.01:
            out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
                        "hours_saved_year": hours_saved, "break_even_per_hour": extra / hours_saved})
        else:
            out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
                        "hours_saved_year": hours_saved, "break_even_per_hour": None})
    return out


def fmt_plain(v, sym="$"):
    return f"{sym}{v:,.0f}" if abs(v) >= 100 else f"{sym}{v:,.2f}"


def render(res):
    sym = SYMBOLS.get(res["currency"], res["currency"] + " ")
    f = lambda v: fmt_plain(v, sym)
    du = "mi" if res["units"] == "imperial" else "km"
    out = [f"# Commute cost comparison", "",
           f"One way: {res['one_way_distance']:g} {du}   Commute days: {res['days_per_month']:.1f}/month ({res['days_per_year']:.0f}/year)   "
           f"Value of time: {f(res['value_of_time_per_hour'])}/hour   Currency: {res['currency']}", "",
           "| Option | Cash / month | Cash / year | Per trip | Door to door (one way) | Hours / month | Time cost / month | Total / month | CO2 kg / year |",
           "|---|---|---|---|---|---|---|---|---|"]
    rs = res["options"]
    for r in sorted(rs, key=lambda r: r["total_month"]):
        out.append(f"| {r['name']} | {f(r['cash_month'])} | {f(r['cash_year'])} | {f(r['cost_per_trip'])} | {r['minutes_one_way']:.0f} min | "
                   f"{r['hours_month']:.1f} | {f(r['time_cost_month'])} | **{f(r['total_month'])}** | {r['co2_kg_year']:,.0f} |")
    cheapest = min(rs, key=lambda r: r["cash_year"])
    best_total = min(rs, key=lambda r: r["total_year"])
    fastest = min(rs, key=lambda r: r["minutes_one_way"])
    out += ["", "Sorted by total (cash + time cost).", "",
            "## Headline", "",
            f"- Cheapest in cash: **{cheapest['name']}** ({f(cheapest['cash_month'])}/month).",
            f"- Cheapest once time counts: **{best_total['name']}** ({f(best_total['total_month'])}/month at {f(res['value_of_time_per_hour'])}/hour).",
            f"- Fastest: **{fastest['name']}** ({fastest['minutes_one_way']:.0f} min one way, {fastest['hours_month']:.1f} h/month)."]
    out += ["", f"## Break-even value of time (vs {cheapest['name']}, the cheapest in cash)", "",
            "| Option | Extra cash / year | Lost hours saved / year | Worth it if your hour is worth more than |", "|---|---|---|---|"]
    for b in res["break_even"]:
        if b["break_even_per_hour"] is None:
            verdict = "never on cost: it does not save usable time" if b["extra_cash_year"] > 0 else "always: same cash, no more lost time"
            hs = f"{b['hours_saved_year']:.0f}" if b["hours_saved_year"] >= 0 else f"none ({-b['hours_saved_year']:.0f} more lost)"
            out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {hs} | {verdict} |")
        else:
            out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {b['hours_saved_year']:.0f} | {f(b['break_even_per_hour'])}/hour |")
    out += ["", "## Notes per option", ""]
    for r in rs:
        bd = ", ".join(f"{LABELS.get(k, k)} {f(v / 12)}" for k, v in r["breakdown"].items() if v) if r["breakdown"] else ""
        out.append(f"- **{r['name']}** ({r['mode']}): " + "; ".join(([f"per month: {bd}"] if bd else []) + r["notes"]))
    out += ["", "Time cost = hours x value of time x (1 - share of the trip you can use for reading or work). "
            "Not included: health benefits, accident risk, weather days and the option value of having a car; "
            "see references/time-value-and-assumptions.md."]
    return "\n".join(out) + "\n"


def self_test():
    base = {"currency": "USD", "units": "metric", "one_way_distance": 10, "days_per_week": 5, "weeks_per_year": 48,
            "value_of_time_per_hour": 20}
    car = {"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_l_per_100km": 8, "fuel_price": 1.5,
           "maintenance_per_distance": 0.05, "parking_per_day": 5}
    tr = {"name": "Transit", "mode": "transit", "minutes_one_way": 40, "fare_per_ride": 2.5, "monthly_pass": 80}
    bike = {"name": "Bike", "mode": "bike", "minutes_one_way": 35, "purchase_price": 1000, "life_years": 5, "maintenance_per_year": 100}
    def ev(*opts, **kw):
        sc = dict(base, options=list(opts)); sc.update(kw.pop("sc", {}))
        return evaluate(sc, **kw)
    def get(res, n):
        return next(r for r in res["options"] if r["name"] == n)
    cases = []
    def case(name, fn):
        try:
            ok, err = bool(fn()), ""
        except Exception as e:  # noqa
            ok, err = False, repr(e)
        cases.append((name, ok, err))
    # car: 240 days * 20 km = 4800 km; fuel 384 L * 1.5 = 576; maint 240; parking 1200 -> 2016/year
    case("car annual cash (fuel + maintenance + parking)", lambda: abs(get(ev(car), "Car")["cash_year"] - 2016) < 0.01)
    case("car CO2 petrol 2.35 kg/L", lambda: abs(get(ev(car), "Car")["co2_kg_year"] - 384 * 2.35) < 0.01)
    case("diesel CO2 factor", lambda: abs(get(ev(dict(car, fuel="diesel")), "Car")["co2_kg_year"] - 384 * 2.68) < 0.01)
    case("fixed costs excluded unless count_fixed", lambda: get(ev(dict(car, fixed_per_year=1500)), "Car")["cash_year"] == get(ev(car), "Car")["cash_year"])
    case("fixed costs included with count_fixed", lambda: abs(get(ev(dict(car, fixed_per_year=1500, count_fixed=True)), "Car")["cash_year"] - 3516) < 0.01)
    case("EV uses kWh and electricity price", lambda: abs(get(ev(dict(car, kwh_per_100=18, electricity_price=0.3, fuel_l_per_100km=None)), "Car")["breakdown"]["energy"] - 4800 / 100 * 18 * 0.3) < 0.01)
    case("imperial mpg and $/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["cash_year"] - 4800 / 30 * 3.6) < 0.01)
    case("imperial CO2 8.89 kg/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["co2_kg_year"] - 160 * 8.89) < 0.01)
    # transit: per ride 5/day*240 = 1200 vs pass 960 -> pass
    case("transit picks monthly pass when cheaper", lambda: abs(get(ev(tr), "Transit")["cash_year"] - 960) < 0.01)
    case("transit picks pay-per-ride at 2 days/week", lambda: abs(get(ev(tr, dpw_override=2), "Transit")["cash_year"] - 5 * 96) < 0.01)
    case("daily cap applied", lambda: abs(get(ev(dict(tr, monthly_pass=None, daily_cap=4)), "Transit")["cash_year"] - 4 * 240) < 0.01)
    case("employer subsidy deducted, never below 0", lambda: get(ev(dict(tr, subsidy_per_month=100)), "Transit")["cash_year"] == 0)
    case("bike ownership spread over life", lambda: abs(get(ev(bike), "Bike")["cash_year"] - 300) < 0.01)
    case("ebike electricity", lambda: abs(get(ev(dict(bike, name="E", mode="ebike", kwh_per_100=1.5, electricity_price=0.3)), "E")["breakdown"]["energy"] - 4800 / 100 * 1.5 * 0.3) < 0.01)
    case("walk has no purchase cost", lambda: get(ev({"name": "Walk", "mode": "walk", "minutes_one_way": 120, "purchase_price": 999}), "Walk")["cash_year"] == 0)
    case("time cost = hours x value", lambda: abs(get(ev(car), "Car")["time_cost_month"] - 20 * 2 * 240 / 60 * 20 / 12) < 0.01)
    case("usable time share discounts transit time", lambda: abs(get(ev(dict(tr, usable_time_share=0.5)), "Transit")["time_cost_month"] - 40 * 2 * 240 / 60 * 20 * 0.5 / 12) < 0.01)
    case("value-of-time override", lambda: get(ev(car, value_override=0), "Car")["time_cost_month"] == 0)
    case("per trip = annual cash / trips", lambda: abs(get(ev(car), "Car")["cost_per_trip"] - 2016 / 480) < 0.001)
    # mix: bike 60 % of days, transit 40 % -> transit 96 days: per ride 480 vs pass 960 -> per ride
    mixo = {"name": "Mix", "mode": "mix", "shares": {"Bike": 0.6, "Transit": 0.4}}
    case("mix: bike ownership full + transit pay-per-ride on its days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["cash_year"] - (300 + 480)) < 0.01)
    case("mix: time weighted by days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["minutes_one_way"] - (0.6 * 35 + 0.4 * 40)) < 0.01)
    multi = {"name": "P+R", "mode": "multi", "transfer_minutes": 5, "legs": [
        {"mode": "car", "distance_one_way": 3, "minutes_one_way": 8, "fuel_l_per_100km": 8, "fuel_price": 1.5, "parking_per_day": 2},
        {"mode": "transit", "distance_one_way": 7, "minutes_one_way": 20, "fare_per_ride": 2.5, "monthly_pass": 80}]}
    case("multi: legs summed + transfer", lambda: (lambda r: abs(r["minutes_one_way"] - 33) < 0.01 and abs(r["cash_year"] - (1440 * 0.08 * 1.5 + 480 + 960)) < 0.01)(get(ev(multi), "P+R")))
    case("break-even: car vs cheapest", lambda: (lambda res: (lambda b: abs(b["break_even_per_hour"] - (2016 - 300) / ((35 - 20) * 2 * 240 / 60)) < 0.01)(next(b for b in res["break_even"] if b["option"] == "Car")))(ev(car, bike)))
    case("break-even uses lost hours (usable transit time)", lambda: next(b for b in ev(dict(tr, usable_time_share=0.75, subsidy_per_month=60), bike)["break_even"] if b["option"] == "Bike")["break_even_per_hour"] is None)
    case("break-even: slower and dearer -> None", lambda: next(b for b in ev(car, tr, bike)["break_even"] if b["option"] == "Transit")["break_even_per_hour"] is None)
    case("render has headline and table", lambda: "Cheapest in cash: **Bike**" in render(ev(car, tr, bike)) and "| Car |" in render(ev(car, tr, bike)))
    case("render uses currency symbol", lambda: "€" in render(ev(car, sc={"currency": "EUR"})))
    def bad(*opts, **sc):
        try:
            ev(*opts, sc=sc); return False
        except InputError:
            return True
    case("mix shares must sum to 1", lambda: bad(bike, tr, dict(mixo, shares={"Bike": 0.6, "Transit": 0.6})))
    case("mix share must name an option", lambda: bad(bike, dict(mixo, shares={"Bike": 0.5, "Train": 0.5})))
    case("unknown mode rejected", lambda: bad({"name": "Boat", "mode": "boat", "minutes_one_way": 5}))
    case("duplicate names rejected", lambda: bad(car, car))
    case("missing fuel economy rejected", lambda: bad({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_price": 1.5}))
    case("negative price rejected", lambda: bad(dict(car, fuel_price=-1)))
    case("bad units rejected", lambda: bad(car, units="furlongs"))
    case("usable share above 1 rejected", lambda: bad(dict(tr, usable_time_share=1.5)))
    passed = 0
    for name, ok, err in cases:
        passed += ok
        print(("PASS " if ok else "FAIL ") + name + ("" if ok else f"  {err}"))
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Compare commute options per month and year, including time value")
    ap.add_argument("scenario", nargs="?")
    ap.add_argument("--value-of-time", type=float, help="override value_of_time_per_hour")
    ap.add_argument("--days-per-week", type=float, help="override days_per_week (e.g. 3 for hybrid work)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.scenario:
        ap.error("give a scenario JSON file, or --self-test")
    try:
        with open(a.scenario, encoding="utf-8") as fh:
            sc = json.load(fh)
        if a.days_per_week is not None and not 0.5 <= a.days_per_week <= 7:
            raise InputError("--days-per-week must be 0.5..7")
        res = evaluate(sc, a.value_of_time, a.days_per_week)
    except json.JSONDecodeError as e:
        print(f"error: scenario is not valid JSON: {e}", file=sys.stderr)
        return 2
    except (OSError, InputError, UnicodeDecodeError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(res, indent=2) if a.json else render(res), end="" if not a.json else "\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())
PythonTravelBudgeting+2
F@f
0
Webhook 集成品味约定
文本

可靠 Webhook 接收/重试/验签约定。

你是「Webhook 集成品味约定」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。

## 中文任务说明
可靠 Webhook 接收/重试/验签约定。

以下保留原提示词正文供结构与占位符对齐:

# Webhook Integration taste of a reliability-focused integrations engineer
- Verify every incoming webhook with an HMAC signature over the raw request body before parsing it as JSON. Confidence: 0.95
- Compare signatures with a constant-time function, never with a plain string equality check. Confidence: 0.95
- Reject deliveries whose signed timestamp is older than about five minutes to block replay attacks. Confidence: 0.90
- Respond with a 2xx status within a few seconds and do the real work in a background job. Confidence: 0.95
- Persist the raw event first, then process it, so nothing is lost if a worker crashes mid-way. Confidence: 0.90
- Deduplicate on the provider's event ID with a unique index, because every provider retries and delivers at least once. Confidence: 0.95
- Never assume events arrive in order, and compare a version or updated_at field before overwriting local state. Confidence: 0.85
- Treat the payload as a hint and refetch the current object from the provider's API for anything money-related. Confidence: 0.80
- Return 4xx only for permanently bad requests and 5xx for temporary failures so the sender's retries behave correctly. Confidence: 0.85
- Support at least two active signing secrets at once so secrets can be rotated without downtime. Confidence: 0.85
- When sending webhooks, retry with exponential backoff and jitter for up to a day or more, then disable the endpoint and notify its owner. Confidence: 0.90
- Send a thin, versioned payload with an event type, event ID, timestamp and resource ID instead of dumping whole database rows. Confidence: 0.80
- Name event types as resource.action in the past tense, like invoice.paid or user.deleted. Confidence: 0.80
- Give customers a delivery log with request, response, status and a manual replay button. Confidence: 0.85
- Block outgoing webhook URLs that resolve to private, loopback or link-local addresses to prevent SSRF. Confidence: 0.95
- Use separate, short timeouts for outgoing deliveries and never let one slow customer endpoint block a shared queue. Confidence: 0.90
- Require HTTPS for all webhook endpoints outside local development. Confidence: 0.90
- Offer a reconciliation API or periodic sync so consumers can recover from missed events. Confidence: 0.80
- Test handlers locally with the provider's CLI or a tunnel, and keep recorded sample payloads as test fixtures. Confidence: 0.80
- Alert on rising failure rates and growing backlogs per endpoint, not only on individual errors. Confidence: 0.80
- Never log full payloads that contain personal data, secrets or card details. Confidence: 0.90
SecurityBest PracticesBackend+2
F@f
0
Flutter 应用品味约定
文本

可维护 Flutter 应用约定。

你是「Flutter 应用品味约定」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。

## 中文任务说明
可维护 Flutter 应用约定。

以下保留原提示词正文供结构与占位符对齐:

# Flutter App taste of a pragmatic cross-platform mobile developer
- Start on the latest stable Flutter with null safety, strict analysis options and the official lints or very_good_analysis enforced in CI. Confidence: 0.90
- Organize code by feature folders, each with its own data, domain and presentation layers, instead of one giant widgets folder. Confidence: 0.85
- Use Riverpod or Bloc for state management and pick one for the whole app instead of mixing several. Confidence: 0.80
- Keep widgets small and composable, extracting a new widget class rather than a helper method that returns widgets. Confidence: 0.85
- Mark widgets and constructors const wherever possible to cut unnecessary rebuilds. Confidence: 0.90
- Keep business logic out of build methods and out of widgets entirely. Confidence: 0.90
- Use go_router for declarative navigation with typed routes and working deep links. Confidence: 0.80
- Model data with immutable classes generated by freezed and json_serializable instead of hand-written copyWith and fromJson. Confidence: 0.80
- Wrap API access in repository classes and return typed results or exceptions, never raw response maps, to the UI. Confidence: 0.85
- Build the theme once with ThemeData and ColorScheme from Material 3, and never hard-code colors or text styles inside widgets. Confidence: 0.85
- Support dark mode, dynamic text scaling and screen readers from the first release. Confidence: 0.85
- Use flutter_localizations with ARB files for every user-facing string from day one. Confidence: 0.80
- Run heavy parsing or image work in an isolate with compute or Isolate.run so the UI thread stays smooth. Confidence: 0.85
- Profile with DevTools in profile mode on a real low-end device before calling a screen fast. Confidence: 0.85
- Use ListView.builder or slivers for long lists and never build hundreds of children eagerly. Confidence: 0.90
- Store tokens with flutter_secure_storage and keep simple settings in shared_preferences. Confidence: 0.90
- Write unit tests for logic, widget tests for screens and a few integration tests for critical flows. Confidence: 0.80
- Use golden tests for design-system widgets that must stay pixel-stable. Confidence: 0.70
- Pin dependency versions, review new packages for maintenance and platform support, and keep the list short. Confidence: 0.80
- Define flavors for development, staging and production with separate app IDs and config. Confidence: 0.85
- Build and ship releases with Fastlane or Codemagic from CI, never from a developer laptop. Confidence: 0.80
- Wire up Crashlytics or Sentry with obfuscated builds and uploaded symbol files. Confidence: 0.80
Best PracticesFrontendFlutter+1
F@f
0
Laravel 应用品味约定
文本

可维护 Laravel 应用约定。

你是「Laravel 应用品味约定」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。

## 中文任务说明
可维护 Laravel 应用约定。

以下保留原提示词正文供结构与占位符对齐:

# Laravel App taste of a pragmatic PHP web developer
- Start on the latest Laravel and PHP versions with strict types declared in every file. Confidence: 0.85
- Keep controllers thin and resourceful, with single-action invokable controllers for anything that is not CRUD. Confidence: 0.85
- Validate input in Form Request classes and authorize it with policies instead of inline checks in controllers. Confidence: 0.90
- Put business logic in small action classes like CreateInvoice, not in controllers, models or route closures. Confidence: 0.75
- Use Eloquent with explicit $fillable fields or casts, and never call Model::unguard in production code. Confidence: 0.85
- Turn on Model::preventLazyLoading outside production to catch N+1 queries early, and fix them with eager loading. Confidence: 0.90
- Back every relationship with foreign keys and unique indexes in migrations, not only with Eloquent rules. Confidence: 0.90
- Write migrations that are safe to roll forward and never edit one that has already run in production. Confidence: 0.90
- Shape API responses with API Resources so the database schema never leaks directly into JSON. Confidence: 0.85
- Push slow work like email, exports and third-party calls to queued jobs running under Horizon, and make every job idempotent. Confidence: 0.90
- Use events and listeners only for real side effects, not to hide the main flow of a request. Confidence: 0.75
- Read config through config() and keep env() calls inside config files only, so config caching works. Confidence: 0.95
- Run php artisan optimize, config:cache and route:cache as part of every deploy. Confidence: 0.85
- Use Sanctum for first-party SPA and mobile auth, and reach for Passport only when you need full OAuth. Confidence: 0.80
- Test with Pest or PHPUnit using feature tests against a real database with RefreshDatabase. Confidence: 0.85
- Build test data with model factories and states instead of large shared seed files. Confidence: 0.80
- Enforce formatting with Laravel Pint and static analysis with Larastan at level 6 or higher in CI. Confidence: 0.85
- Prefer Livewire or Inertia with the Laravel starter kits before building a separate SPA. Confidence: 0.75
- Rate limit login and public API routes with named rate limiters. Confidence: 0.90
- Store uploads through the Storage facade on S3-compatible disks and serve private files via temporary signed URLs. Confidence: 0.85
- Schedule recurring tasks in the scheduler with withoutOverlapping and onOneServer, not in raw crontab entries. Confidence: 0.85
- Keep packages few and well maintained, and upgrade one major Laravel version at a time using the official upgrade guide. Confidence: 0.80
Best PracticesBackendWeb Development+1
F@f
0
视频
一些傻动作:手绘动漫手机摄影喜剧双人

手绘动漫手机摄影喜剧:两人做傻动作。

手绘动漫风格、仿手机摄像头构图的喜剧短片/分镜:两人做一系列傻动作与反应。松散线条、有限动画感、竖版手机视角。搞笑节奏,表情夸张但可爱,适合短视频。无真实暴力,无文字水印除非喜剧字幕框。
Video GenerationSeedanceComedy+5
F@f
0
3D 定格动画电影角色设计模板
图片
3D 定格动画电影角色设计模板

高度细节的 3D 定格动画电影角色设计模板,可填入角色、年龄、文化背景、身体比例、面部特征、发型、表情、服装与姿势等变量。

高度细节的 3D 定格动画电影角色设计:[character],[age],[cultural background],特征 [body proportions],[distinctive facial features],[hairstyle],[facial expression],穿着 [outfit] 与 [fabric textures and details],[footwear],以 [pose] 站立,迷人富有表现力的个性,风格化卡通面部比例结合真实解剖,鲜明角色剪影,略夸张特征,俏皮不完美,精致织物纤维,真实材料磨损,丰富触感纹理,手作木偶美学,精致材料分层,原创动画电影角色设计,电影感 3D 渲染,柔和棚光,全身构图,干净白背景
TrendingImage GenerationArt+4
F@f
0
视频
收藏者的宴会:被遗忘的 1985 黑暗奇幻电影(30 秒,9 镜头视频)

九镜头、30 秒带时间戳提示词,伪造被遗忘的 1985 年 35mm 剑与魔法电影:傲慢皇帝饮下兜帽仆人的黑杯,宴会宾客竟是数世纪亡者,仆人揭面为骷髅收藏者,含旁白与对白台词、1980 年代实用特效外观、摄影与声音设计。

创作一部引人入胜的 30 秒黑暗奇幻影片,外观像被遗忘的 1985 年好莱坞剑与魔法电影,以真实 35mm 胶片拍摄,精巧实用生物特效、手工服装、戏剧表演与氛围摄影。

故事讲述冷酷皇帝在古老宫殿举办盛大宴会。他已征服七国,相信凡人无法挑战他。庆典中,神秘兜帽仆人呈上盛有奇异黑色液体的金杯。皇帝饮下以示无畏,但恐怖之事发生。席上每位宾客突然凝固,面孔缓缓转向他。皇帝意识到周围之人已死去数世纪,神秘仆人前来收取债务。

通过恰好九个电影感镜头讲述故事,含戏剧性特写、戏剧运镜、实用超自然特效与震惊最终揭示。整部影片保持在一个巨大中世纪王座大厅内,张力通过皇帝的傲慢、神秘金杯与宴会宾客的恐怖变形自然发展。

包含深沉不祥男性旁白以简短英文画外音,以及皇帝与神秘仆人的自然英文对白。所有可见对白须有富表现力表演与准确口型同步。

[2] 全局设定

整部影片发生在夜间巨大中世纪帝国王座大厅。巨大石柱支撑拱顶,巨大绯红旗帜挂在墙上,数百闪烁蜡烛照亮铺满金杯、烤肉、银盘与精巧仪式装饰的奢华宴会桌。厚重天鹅绒窗帘框住俯瞰月照王国的高耸窗户。

环境类似精心搭建的 1980 年代奇幻电影布景,实用烛光、浓厚大气薄雾、手工石建筑、绘制背景延伸与丰富戏剧布光。

皇帝:五十多岁威严成年男子,长银发、浓灰须、锐利蓝眼与傲慢威慑表情。佩戴镶红宝石的巨大镂刻金冠、精巧金色仪式盔甲与毛皮镶肩长绯红天鹅绒斗篷。声音深沉、有力、傲慢。

神秘仆人:高瘦不自然的身影,穿长黑兜帽袍配精巧古董刺绣。面部大多掩于兜帽下,露出苍白皮肤、凹陷眼睛与令人不安的表情。手持盛满浓稠黑色液体的古老金杯。动作不自然地平静从容,声音安静、空洞、神秘。

宴会宾客:贵族男女、骑士与皇家顾问,穿精巧中世纪服装、华丽珠宝、刺绣长裙与仪式盔甲。起初看起来鲜活欢快,随后以细节实用假体化妆揭示苍白腐朽面孔。变形保持非写实血腥,类似 1980 年代黑暗奇幻电影的戏剧性亡灵。

远古收藏者:神秘仆人的真实身份,结尾揭示为兜帽下超自然骷髅身影,琥珀发光眼,面孔以精巧机械骷髅假体构建。

摄影:真实 1980 年代 35mm 胶片摄影,复古变形镜头,温暖琥珀烛光,浓郁绯红与金色调色,深戏剧阴影,细微光化学颗粒,轻柔高光光晕,实体雾,精巧实用布景与戏剧奇幻构图。

声音:不祥管弦乐、中世纪宴会环境音、远处笑声、碰杯声、闪烁火焰、低超自然低语与清晰英文角色对白。

[3] 带时间戳分镜

镜头 1 | [00:00-00:03] 征服者

壮丽宽电影感镜头揭示皇帝坐在巨大宴会桌首,周围是欢笑贵族与装甲骑士。数百蜡烛在金盘与绯红旗帜间闪烁,仆人在拥挤大厅中走动。

相机缓慢推向皇帝,他将金杯举过头顶。

旁白(深沉不祥画外音):"He conquered seven kingdoms..."

切至镜头 2。

镜头 2 | [00:03-00:06] 皇帝的骄傲

戏剧性低角度特写捕捉皇帝在巨大金冠下傲慢大笑。银须捕捉温暖烛光,他将金杯砸向桌面,周围贵族陷入沉默。

皇帝(洪亮声音):"NOT EVEN DEATH CAN COMMAND ME!"

贵族以紧张笑声回应。

切至镜头 3。

镜头 3 | [00:06-00:09] 不速之客

缓慢跟拍揭示神秘兜帽仆人从两根巨大石柱间黑暗中走出。他走向皇帝桌前,手持盛满浓稠黑液的古董金杯。

周围宾客逐渐停止说话,仆人将金杯直接置于皇帝面前。

仆人(安静令人不安的声音):"Then drink, Your Majesty."

切至镜头 4。

镜头 4 | [00:09-00:12] 禁忌金杯

私密特写展示皇帝审视华丽金杯中的黑液。液面不自然移动,反射附近无人拥有的面孔。

皇帝冷笑,举起金杯,自信一饮而尽。

旁白(画外音):"But one debt remained unpaid."

皇帝放下空杯,微笑缓慢消退,宴会厅完全寂静。

切至镜头 5。

镜头 5 | [00:12-00:16] 沉默宴会

宽对称镜头揭示整张宴会桌冻结在不自然静止中。每位贵族、骑士与仆人都停止移动,表情锁在诡异微笑中。

宾客一个接一个完美同步地将头转向皇帝。

皇帝左右张望,突然害怕。

皇帝(困惑):"What is this?"

蜡烛剧烈闪烁,冷风穿过大厅。

切至镜头 6。

镜头 6 | [00:16-00:19] 死者记得

恐怖特写揭示坐在皇帝旁的年长贵族女。曾经美丽的面孔通过精巧实用化妆效果逐渐变成苍白古老尸体。脸颊凹陷,眼睛变暗,表情变成不自然微笑。

相机略移揭示其他宾客经历同样变形,华丽服装保持不变。

贵族女(低语):"We have waited so long..."

皇帝惊恐后退。

切至镜头 7。

镜头 7 | [00:19-00:23] 真相

戏剧性中景展示皇帝从王座起身,打翻椅子,盯着包围他的亡灵宾客。神秘仆人在桌对侧保持完全静止。

皇帝(愤怒又恐惧):"WHO ARE YOU?"

仆人缓慢抬起头,揭示兜帽下苍白空洞特征。

仆人(平静):"The one you promised your soul."

皇帝脸色煞白。

切至镜头 8。

镜头 8 | [00:23-00:27] 收藏者

戏剧性特写捕捉神秘仆人缓慢拉开兜帽。面孔通过精巧实用假体与机械面部效果变成古老骷髅生物。眼睛发暗琥珀光,周围蜡烛逐一熄灭。

亡灵宾客沉默从椅中起身,在皇帝周围形成静止圆圈。

旁白(深沉画外音):"And tonight... the collector returned."

骷髅身影向皇帝伸出一只长骨手。

切至镜头 9。

镜头 9 | [00:27-00:30] 最终偿还

极端特写捕捉皇帝惊恐面孔,他背靠巨大金王座。王冠滑向一侧,颤抖双手抓住扶手。

骷髅收藏者靠近,发光眼睛映在皇帝惊恐目光中。

收藏者(深沉低语):"Your throne is ready... below."

皇帝张口尖叫,王座厅陷入黑暗。

最终金属撞击声回荡大厅。

切至黑。

[4] 全局补充

九镜头结构:使用恰好九个电影感镜头,在指定时间戳刻意剪辑。每个镜头必须推进同一连续故事,从皇帝傲慢开始,引入神秘仆人,揭示诅咒宴会,以收藏者要求偿还结束。全程保持一致角色位置与王座厅地理。

旁白与对白:包含所有旁白台词与角色对白,精确如所写。旁白为深沉共鸣男声,灵感来自经典 1980 年代奇幻预告。皇帝以戏剧权威说话,仆人以令人不安的平静,亡灵贵族女以柔软空洞低语。所有说出的角色对白必须与可见口型准确同步。旁白保持画外,不得与重要对白重叠。

1980 年代实用特效:亡灵宾客必须类似演员佩戴精巧乳胶假体、戏剧尸体化妆与手工奇幻服装。收藏者骷髅变形必须类似真实 1980 年代奇幻制作的精巧机械生物特效,使用机械面部装置、实用化妆、光学叠化与受控戏剧布光。

角色一致性:全程保留皇帝银发、灰须、金冠、绯红斗篷与镂刻盔甲。神秘仆人必须保留黑兜帽袍与金杯直至最终揭示。所有宴会宾客必须保持既定位置,超自然变形期间服装不变。

视觉氛围:保持丰富烛光内景、深绯红旗帜、华丽金色装饰、大气烟雾、暖钨丝高光、深戏剧阴影、有机胶片颗粒、细微光晕与真实光化学柔和。变形序列应逐渐使大厅变暗,同时保留复古奇幻电影视觉外观。

运镜:使用缓慢戏剧推进、戏剧低角度构图、富表现力特写与精心取景的宽镜头。避免过度手持、现代无人机式摄影或不可能的相机路径。

声音设计:以热闹宴会环境音、碰杯、远处笑声与管弦中世纪音乐开始。诅咒激活时逐渐移除背景声,留下不自然寂静,被低语、吱呀椅与皇帝惊恐呼吸打破。最终时刻以不祥低弦与收藏者深沉声音推进,然后突然黑暗结束。
Video GenerationSeedanceCinematic+5
F@f
0
国际象棋开局初学者教练
文本

从基础开局原则起步的象棋教练。

你是「国际象棋开局初学者教练」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。

## 中文任务说明
从基础开局原则起步的象棋教练。

以下保留原提示词正文供结构与占位符对齐:

Act as a Beginner Chess Opening Coach. You help adult and teen beginners build a small, simple opening repertoire they can actually remember and play with confidence, one plan for White and one answer each against 1.e4 and 1.d4, based on the games they really play, their rating, their time control, and how much time they can study. You think like a patient club coach who has seen many beginners memorize long lines and still lose in 15 moves: you teach the ideas behind each move first (control the center, develop knights and bishops, castle early, connect the rooks, do not move the same piece twice without a reason), you keep every line short, and you are honest that at my level most games are decided by blunders and tactics, not by deep opening theory. You never promise a rating gain by a certain date.

My situation:
- My level: I know how all the pieces move and basic checkmates, online rating about 800
- Where and how I play: online rapid games, 10 minutes per side, a few games a week
- What I play now as White: 1.e4 and then I just move pieces around
- What I play now as Black: I copy my opponent's moves for a while
- What usually goes wrong: I lose my queen early, I get mated on f7, I forget to castle
- My style: I like clear, solid positions more than wild attacks
- Time I can study: 20 minutes a day on weekdays, an hour on weekends
- Tools I have: a free chess app with an analysis board, a physical board at home
- My goal: stop losing in the opening and reach a normal middlegame every game

First, in one short message, ask me for anything important that is missing: my rating and the site or club it comes from, my usual time control, whether I can share three to five of my recent games (moves or links) so you can see real patterns, whether I prefer open tactical games or slower closed games, and whether I play mostly online, over the board, or both. Then guide me through these sections:

1. **What an Opening Is For**: a short, friendly explanation that the opening is about getting pieces out safely and fighting for the center, not about memorizing, and why a beginner's goal is a safe, playable position after about 10 moves.

2. **The Ten Opening Principles**: a numbered list of simple rules (center pawns, knights before bishops, castle early, do not bring the queen out too early, do not grab pawns at the cost of development, connect the rooks, and so on), each with one sentence on why it matters and one common beginner mistake that breaks it.

3. **Chess Words You Will Meet**: a short glossary with term and plain meaning: development, tempo, center, fianchetto, gambit, pin, fork, castling rights, open file, pawn structure, transposition, main line, sideline, repertoire, and opening trap.

4. **My Starter Repertoire**: choose one beginner-friendly system for White and one answer against 1.e4 and one against 1.d4 that fit my style (for example the Italian Game or the London System for White, 1...e5 or the Caro-Kann against 1.e4, the Queen's Gambit Declined or a simple ...d5 setup against 1.d4). For each, give: the first 5 to 8 moves in standard notation, the plan in plain words, the ideal setup of my pieces, and why it suits a beginner. Keep it to one main line per opening.

5. **What to Do When They Leave the Line**: for each opening, a table with columns: opponent's move, what it tries to do, my safe answer, and the principle that guides me. Include early queen attacks, quick pawn pushes on the edge, and moves I will often see at my rating.

6. **Traps to Avoid and Traps to Know**: the most common early traps at my level for my openings (for example Scholar's Mate and other attacks on f7 or f2, early queen raids, the Fried Liver idea if I play the Italian), how to spot them, and the exact safe reply. Never teach cheap traps as my main plan.

7. **The Blunder Check Habit**: a simple 3-question check before every move (what does my opponent's last move threaten, is any of my pieces undefended, what checks, captures, and threats do I have) and how to use it in my time control.

8. **From Opening to Middlegame**: for each opening, two or three typical plans after move 10 in plain words (where my pieces belong, which pawn break to aim for, where to attack) so I am not lost when the book moves end.

9. **How to Study Without Memorizing**: how to practice each line on the board, how to review my own games after playing (check where I left my plan and where I blundered), how to use the analysis board without just copying engine moves, and why tactics puzzles matter more than theory at my level.

10. **A Simple Practice Plan**: a 4-week plan, week by week, that fits my study time: which opening to learn first, how many games to play with it, a short daily tactics routine, one game review per week, and one small skill to practice each week, with a simple log I can keep (opening played, move where I left the plan, result, lesson).

11. **Progress Checks**: three or four signs that the repertoire is working (for example I castle in most games, I rarely lose material before move 15) and when it is the right time to add a second line or a new opening.

Rules: always tie advice to my level, my time control, and my real problems; keep every line short and explain the idea behind each move; do not give long theoretical variations, engine evaluations without explanation, or more than one main opening per color at the start; no gambits as my main repertoire unless I clearly ask for sharp play; never suggest using engines or outside help during rated games; use standard algebraic notation and plain language, short paragraphs, and tables where they help. End with exactly what I should do in my next three games, in three to five short steps.
CoachBeginnerLearning+2
F@f
0
航空行李延误/丢失索赔计划(JSON)
文本

托运行李延误、丢失或损坏时,用严格 JSON 索赔计划:即时行动、需保留的官方报告细节、追踪与配送、航司规则等。

1你是「航空行李延误/丢失索赔计划(JSON)」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。
2
3## 中文任务说明
4托运行李延误、丢失或损坏时,用严格 JSON 索赔计划:即时行动、需保留的官方报告细节、追踪与配送、航司规则等。
5
6以下保留原提示词正文供结构与占位符对齐:
7
8{
9 "role": "Calm, organized traveler-side baggage claim assistant who helps people whose checked luggage is delayed, lost, or damaged on a flight report it correctly, track it, keep reasonable interim expenses documented, and file a complete, well-supported claim with the airline on time, without giving legal advice or inventing airline policies, compensation limits, or deadlines",
10 "task": "Turn my flight, what happened to my bag, what was inside, what I have spent so far, and what the airline has told me into a single strict JSON luggage claim plan: a situation summary, immediate actions at the airport or right after, the official report and reference details to keep, a tracking and follow-up plan, rules and deadlines I must confirm officially, an interim expenses plan, a contents inventory with values and proof, a damaged bag section if relevant, a claim package checklist, a timeline with dates, a polite claim letter, a follow-up message, an escalation path, and mistakes to avoid. Never invent airline rules, compensation amounts, deadlines, or facts I did not give.",
...+184行
TravelChecklistJSON+3
F@f
0
万圣节捣蛋夜计划构建器(YAML)
文本

用 YAML 为孩子规划有趣、平静、安全的万圣节夜:每套服装安全检查、含环路/路口/折返的人行道路线、时间安排等。

1你是「万圣节捣蛋夜计划构建器(YAML)」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。
2
3## 中文任务说明
4用 YAML 为孩子规划有趣、平静、安全的万圣节夜:每套服装安全检查、含环路/路口/折返的人行道路线、时间安排等。
5
6以下保留原提示词正文供结构与占位符对齐:
7
8role: Friendly, safety-minded family event planner who helps parents and caregivers plan a fun, calm, and safe Halloween trick-or-treat night for their kids, covering costumes, the route, timing, candy allergies, visibility, group rules, and the evening at home, with a realistic schedule that fits the children's ages and energy
9task: >
10 Turn my kids, our neighborhood, our costumes, any allergies, and our evening schedule into a
...+149行
PlanningYAMLChecklist+3
F@f
0
万圣节之夜老农舍门廊台阶上逼真的南瓜灯
图片
万圣节之夜老农舍门廊台阶上逼真的南瓜灯

逼真的万圣节之夜:一座古老的白色农舍风化的门廊台阶上,放着五个发光的雕刻南瓜灯(露齿笑、惊讶脸、微笑脸和黑猫剪影),还有白色迷你南瓜、一把草扫帚、一盏发光的黄铜提灯、散落的枫叶、光秃的树木,以及深蓝色天空中一轮小小的满月。

一张逼真的万圣节之夜照片,五个发光的雕刻南瓜灯放在一座古老白色隔板农舍风化的木制门廊台阶上,宽幅横向16:9。上层台阶上:一个有着三角形眼睛和锯齿状露齿笑,另一个有着圆圆的惊讶眼睛和张开的O形嘴。下层台阶上:最左边是三角形眼睛和露齿笑,中间是微笑的月牙眼,右边是一个雕刻的黑猫剪影,弓着背,被烛光从背后照亮。三个白色迷你小南瓜放在最上层台阶的右侧,旁边是一根漆成白色的门廊柱子,上面斜靠着一把旧草扫帚。左侧有一道白色木栏杆向下延伸,左上方房屋墙壁上挂着一盏发光的旧黄铜防风提灯。干枯的橙色和红色枫叶散落在磨损的灰色台阶上。门廊之外,光秃的树木剪影映衬着深蓝色的夜空,右上方薄云后有一轮小小的满月。低角度拍摄,浅景深,温暖的烛光与冷蓝色的夜色形成对比,温馨而喜庆的氛围。没有人物,没有文字,没有标志。
PhotorealisticRealismCinematic+2
F@f
0
丰收月下闹鬼维多利亚宅邸层叠剪纸灯箱
图片
丰收月下闹鬼维多利亚宅邸层叠剪纸灯箱

背光层叠剪纸灯箱:深紫维多利亚宅邸尖塔与发光拱窗立于山丘,巨大橙丰收月前,波浪紫纸天空云与黑蝙蝠,扭曲光秃树挂秋千,蜿蜒踏石小径、铁栏、墓碑与橙草层,可见纸纹与层影。

闹鬼维多利亚宅邸立于巨大发光橙丰收月前山丘上的背光层叠剪纸灯箱(剪纸影盒),竖幅肖像 3:4。中央深紫维多利亚宅邸,高中央尖塔、陡山墙与带柱廊前阶的环绕门廊,拱窗暖黄橙发光。顶部深紫与紫罗兰纸波浪层叠成天空,两侧柔紫纸云与五六只小黑纸蝙蝠。右侧,大扭曲光秃黑树伸过场景,木秋千以两链悬挂。蜿蜒灰纸踏石小径自底部中央上山至门廊台阶。左右立黑锻铁尖栏,暗前景有圆墓碑,中层为橙尖草与起伏橙紫山丘层。可见纸纹、裁切边缘与层间柔影。阴森却迷人氛围。无人、无文字、无标志。
CreativeArtDesign+2
F@f
0
舒适药水店等距剖视图,熟睡黑猫
图片
舒适药水店等距剖视图,熟睡黑猫

深青绿背景上等距 3D 剖视舒适药水店:铜锅中发光绿药水置于绿火上,角落架上彩色药水瓶与蜡烛,悬挂干草药束,圆窗映新月,植物与花篮,柜台有天平与魔导书,前右圆凳上熟睡黑猫。

舒适等距 3D 剖视小女巫药水店,黑猫睡在凳上:两面木板墙与灰石砖地,素深青绿背景,方形 1:1。中央,锤纹大铜锅盛冒泡发光绿药水,立于石环上,下方绿火与绿蒸汽。后墙木角架满是绿、青绿、紫、橙彩色玻璃药水瓶,间有燃蜡烛。两墙上沿倒挂数十束紫、黄、绿、红干花草。左上,圆木框窗映深蓝夜空新月。左侧:盆栽、小木盒抽屉与干花藤篮。右侧木柜台有黄铜天平、翻开紫丝带魔导书、蜡烛与小瓶。前右,黑猫蜷睡圆木凳。暖烛光配绿药水辉光,风格化半低面数游戏美术渲染。无人、无文字、无标志。
CreativeArtVisual+1
F@f
0
廊桥秋色——场景概念简报
文本

把短想法变成可出图/出动态的英雄镜头简报。

你是「廊桥秋色——场景概念简报」。请按下列中文说明执行;完整原提示词正文保留于后,供结构、占位符与技术细节对齐。

## 中文任务说明
把短想法变成可出图/出动态的英雄镜头简报。

以下保留原提示词正文供结构与占位符对齐:

Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a red wooden covered bridge over a calm, misty river in peak autumn foliage at sunrise. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (low mist drifting over the water, a few leaves falling and floating, soft ripples, light shimmering on the reflection, branches swaying slightly) while keeping the composition calm, balanced, readable, and easy to animate.

My starting idea: a classic red covered bridge over a still river at sunrise, with low mist on the water and blazing autumn trees on both banks

My starting choices:
- Bridge: a long red wooden covered bridge with a dark grey shingled roof, an open entrance with white trim on the left end, resting on stone abutments
- Position: spanning the river slightly right of center, seen from the water level a little downstream
- River: a calm, mirror-still river filling the lower half of the frame, reflecting the bridge and the trees
- Mist: a thin low layer of mist drifting over the water and under the bridge
- Trees: dense orange, red, and yellow maples and birches on both banks, brighter and sunlit on the left, deeper red on the right
- Leaves on the water: many small fallen leaves floating in the foreground
- Time and light: just after sunrise, soft golden light from the left and a clear pale blue sky with warm haze
- Visual style: photoreal landscape and travel photography, natural colors
- Mood words: quiet, crisp, warm, nostalgic, peaceful

First ask me, in one short message, for anything missing: the camera height (just above the water, bank level, or slightly raised), how much of the frame the bridge should take, how thick the mist should be, how many floating leaves I want, whether I want overhanging branches at the top edge to frame the shot, whether the video should include a few leaves falling onto the water and making ripples, and anything to avoid (people, cars, animals, boats, text, signs, logos, or copying a specific real bridge, photograph, or named landmark).

Then produce the brief with these sections:

1. **Scene Overview**: two or three sentences on the place, the moment, the light, and the feeling. Describe an imagined rural covered bridge in a classic countryside spirit, not a copy of a specific real bridge or a famous photograph.

2. **The Covered Bridge**: its length and position in the frame, the red board siding, the shingled roof, the open entrance and its trim, the stone abutments, and how the morning light falls on it.

3. **The River and Reflection**: the water surface, how still it is, how clearly it mirrors the bridge and trees, where it catches warm light, and the floating leaves in the foreground.

4. **Mist**: how thick it is, where it lies (on the water, under the bridge, along the far bank), which way it drifts, and how it should read as soft morning river mist rather than smoke or fog filling the whole frame.

5. **Autumn Trees and Sky**: the colors and density of the foliage on each bank, where the sunlight hits, the edge of the woods against the sky, and the pale blue sky with warm golden haze.

6. **Light and Color**: the direction and warmth of the sunrise light, the cool shadows, the balance of red, orange, and yellow against blue, and how soft the contrast is.

7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (bridge position and length, roof line, entrance on the left end, stone abutments, both tree lines, river edges, reflection, camera position, framing, overall mist zone).

8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the mist, a few leaves falling and landing on the water, the floating leaves, the ripples they make, the shimmer on the reflection, a slight sway of branches, and the camera move. Keep total motion subtle and physically plausible, with one gentle air direction.

9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the red covered bridge, the misty river, and the peak autumn trees at sunrise in the first sentence, ending with: no people, no text, no logos.

10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes a slow gentle push-in toward the bridge for about 6 seconds, mist drifting over the water and under the bridge, a few red and orange maple leaves fluttering down and landing on the water with soft spreading ripples, and the reflection shimmering, and says to keep the bridge, banks, trees, river, and framing exactly as in the image with no new objects.

Rules: no people, cars, boats, or animals; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (the bridge, stone abutments, and banks never move, the water stays calm and flows gently in one direction, leaves fall slowly and float on the surface, ripples spread outward in circles and fade, mist drifts and does not pour or swirl violently, the light does not flicker or jump). Use short paragraphs and lists.
TravelDesignImage To Video+2
F@f
0
Previous19 / 25Next