法院文书下载与归档
何时使用
当收到来自人民法院电子送达平台(zxfw.court.gov.cn)的文书送达时使用。支持两种触发方式:
方式一:粘贴短信原文
收到法院短信,内容如下:
【xx市人民法院】某某,您好!您有(2025)苏0981民初1234号案件文书送达,请点击链接查收:https://zxfw.court.gov.cn/zxfw/#/pagesAjkj/app/wssd/index?qdbh=DEMO1&sdbh=DEMO2&sdsin=DEMO3
方式二:直接发送送达链接
用户直接发送以 https://zxfw.court.gov.cn/zxfw/#/pagesAjkj/app/wssd/index?qdbh=... 开头的链接。此时跳过短信文本解析,直接从 URL 提取参数进入下载流程。
自动触发规则(无需手动加载): 当用户发送包含以下关键词时,自动加载本 skill:
zxfw.court.gov.cn- 人民法院电子送达
- 法院 + 传票/文书/送达/判决书/开庭提醒 等
- 传票链接 + 帮我下载/归档/存档/存起来 等
工作流程
前置步骤:确认工作目录(⚠️ 必须,每次执行前检查)
此步骤在每次执行前都必须检查。配置保存在用户本地
~/.config/court-document-downloader/config.json,不随 skill 文件分发。
执行检查:
CONFIG_FILE="$HOME/.config/court-document-downloader/config.json"
python3 -c "
import json, os, sys
config_path = os.path.expanduser('~/.config/court-document-downloader/config.json')
# 情况一:配置文件不存在
if not os.path.exists(config_path):
print('NOT_CONFIGURED')
sys.exit(0)
# 情况二:配置文件存在但解析失败
try:
with open(config_path) as f:
cfg = json.load(f)
except Exception:
print('CONFIG_BROKEN')
sys.exit(0)
# 情况三:缺少必需字段(旧版残留的不完整配置)
required_fields = ['work_directory', 'archive_mode', 'calendar_name', 'default_reminders']
missing = [f for f in required_fields if f not in cfg]
if missing:
print('CONFIG_INCOMPLETE')
print('缺失字段: ' + ', '.join(missing))
sys.exit(0)
# 情况四:配置完整,输出工作目录
print(os.path.expanduser(cfg['work_directory']))
" 2>/dev/null
输出含义:
NOT_CONFIGURED— 配置文件不存在,进入首次配置引导CONFIG_BROKEN— 配置文件存在但损坏(JSON 解析失败),提示用户后进入配置引导CONFIG_INCOMPLETE— 配置文件存在但不完整(旧版残留),提示用户后进入配置引导- 其他输出(路径)— 配置完整,使用该路径继续执行
如果输出 NOT_CONFIGURED、CONFIG_BROKEN 或 CONFIG_INCOMPLETE:必须立即停止后续流程,进入首次配置引导。
⚠️ 对于
CONFIG_BROKEN和CONFIG_INCOMPLETE,先告知用户"检测到旧版不完整配置,需要重新配置",然后执行rm "$HOME/.config/court-document-downloader/config.json"删除旧文件,再进入下方配置引导。
⚠️ 关键:不要替用户做决定。每一轮交互都必须等待用户明确回复后才能继续。绝对不能自动选择默认值跳过交互。
第一轮交互:询问保存路径
向用户展示以下内容,然后停止,等待用户回复:
⚠️ 执行到此步骤时必须停下来,把下面的内容展示给用户,然后等待用户的实际回复。不允许自行假设用户的选择,不允许自动填入默认值。
⚠️ 首次使用,需要设置诉讼文书的保存位置。
文书将保存到这个文件夹中,建议选择一个方便查找的持久化位置。
请选择保存路径:
1. 保存到桌面(默认)— ~/Desktop/诉讼案件/
2. 自定义路径 — 回复一个本地文件夹路径
示例:
回复 "1" 或 "默认" → 保存到 ~/Desktop/诉讼案件/
回复 "~/Documents/诉讼案件" → 保存到文稿目录
回复 "/Volumes/移动硬盘/诉讼案件" → 保存到外接硬盘
同时请选择归档方式:
A. 按年份分层(默认)— 自动创建 2026/、2027/ 等年份子文件夹
B. 不分年份 — 所有案件文件夹直接放在工作目录下
回复格式:路径选择 + 归档方式,如 "1 A" 或 "~/Documents/诉讼案件 B"
仅回复路径则默认使用按年份分层(A)。
在用户回复之前,不得执行任何后续步骤。
第二轮交互:确认配置
收到用户回复后,解析出路径和归档方式。先不要保存,向用户展示确认信息,然后停止,等待用户确认:
📋 请确认你的配置:
📁 保存路径:{解析出的完整路径}
📂 归档方式:{按年份分层 / 不分年份}
📅 日历提醒:Apple Calendar「个人」日历
⏰ 提醒时间:开庭前 7 天 + 开庭前 2 天
📂 文件夹结构预览:
{路径}/
├── 2026/ ← 按年份分层时才有此层
│ └── {原告}诉{被告}{案由}/
│ └── {文书名}_20260711收.pdf
└── .archive/ ← 归档记录
└── 20260711_143025_1234.json
确认无误请回复「确认」或「Y」
如需修改路径请直接回复新路径
如需修改日历名称或提醒设置请回复「高级配置」
在用户确认之前,不得保存配置。
高级配置(仅当用户回复"高级配置"时触发)
向用户展示:
⚙️ 高级配置(可直接回复修改项,格式:设置名=值,多项用逗号分隔):
日历名称 当前:个人 例:日历名称=工作
提醒1 当前:提前7天 例:提醒1=10(天)
提醒2 当前:提前2天 例:提醒2=1(天)
回复示例:日历名称=工作,提醒1=10,提醒2=3
回复「确认」则使用当前设置完成配置
收到用户的高级配置修改后,更新对应值,重新展示第二轮确认信息。
保存配置(仅在用户明确确认后执行)
mkdir -p ~/.config/court-document-downloader
# 以下变量已从前述交互中解析获得:
# WORK_DIR_INPUT — 用户确认的路径(如 ~/Desktop/诉讼案件)
# ARCHIVE_MODE — "by_year" 或 "flat"
# CALENDAR_NAME — 日历名称(默认 "个人")
# REMINDER_1_DAYS — 第一个提醒提前天数(默认 7)
# REMINDER_2_DAYS — 第二个提醒提前天数(默认 2)
# 展开路径中的 ~ 为 $HOME
WORK_DIR_EXPANDED=$(python3 -c "import os; print(os.path.expanduser('$WORK_DIR_INPUT'))")
# 创建工作目录(包括 .archive 子目录)
mkdir -p "$WORK_DIR_EXPANDED/.archive"
# 写入完整配置
cat > ~/.config/court-document-downloader/config.json << EOF
{
"work_directory": "$WORK_DIR_INPUT",
"archive_mode": "$ARCHIVE_MODE",
"archive_subdirectory": ".archive",
"calendar_name": "$CALENDAR_NAME",
"default_reminders": [
{"days_before": $REMINDER_1_DAYS, "description": "提前${REMINDER_1_DAYS}天"},
{"days_before": $REMINDER_2_DAYS, "description": "提前${REMINDER_2_DAYS}天"}
]
}
EOF
echo "✅ 配置已保存"
echo "✅ 工作目录已创建: $WORK_DIR_EXPANDED"
展示配置摘要并继续
✅ 配置完成!
📁 保存路径:{完整路径}
📂 归档方式:{按年份分层 / 不分年份}
📅 日历名称:{日历名}
⏰ 开庭提醒:提前 {N} 天 + 提前 {M} 天
📝 配置文件:~/.config/court-document-downloader/config.json
如日后需要修改配置,可直接编辑该文件,或删除它重新触发配置引导。
配置完成后,重新读取配置并继续执行下方 Step 1-12。
CONFIG_FILE="$HOME/.config/court-document-downloader/config.json"
WORK_DIR=$(python3 -c "
import json, os
with open('$CONFIG_FILE') as f:
path = json.load(f)['work_directory']
print(os.path.expanduser(path))
")
echo "✅ 工作目录: $WORK_DIR"
Step 1:判断输入类型
完整短信文本:包含法院签名(如 【xx法院】)+ 正文 + 链接 → 进入 Step 2 短信解析
纯链接:用户直接发送送达 URL → 跳过 Step 2 的短信解析部分,直接从 URL 提取 qdbh、sdbh、sdsin 参数,进入 Step 3 下载。案号、当事人等信息在下载文书后从文书内容中提取。
Step 2:短信原文解析(仅短信输入时触发)
a) 短信类型分类:根据关键词判断
| 类型 | 特征 | 含下载链接 | 处理方式 |
|---|---|---|---|
| 文书送达 | 含送达平台链接 + 案号 | 是 | 下载文书并归档到案件目录 |
| 立案通知 | 含"已立案"等关键词 | 可能有 | 展示解析结果 |
| 信息通知 | 无链接,纯信息 | 否 | 展示解析结果 |
b) 案号提取:使用正则 [((〔[]\d{4}[))〕]] 匹配标准案号格式
标准案号格式示例:
(2025)苏0981民初1234号(2024)粤0604执保5678号〔2025〕京0105民初901号
c) 当事人提取:从短信文本初步识别,最终以文书内容为准
- 注意:短信中的称呼(如"某某,您好")仅为短信接收人,不作为案件当事人
- 公司名称:
xx有限责任公司、xx有限公司、xx股份有限公司 - 诉讼对峙:
A与B、A诉B、原告A 被告B - 角色前缀:
原告:xxx、被告:xxx等 - 排除关键词:法院、人民法院、书记员、法官、审判员、执行员、系统、平台、服务、通知、短信等
d) 下载链接提取:从短信中提取 zxfw.court.gov.cn 链接,提取 qdbh、sdbh、sdsin 三个参数
e) 发送时间提取:优先从后续 API 响应的 dt_cjsj 字段提取;其次从短信网关时间匹配(发送:YYYY-MM-DD HH:mm 格式)
输出格式(向用户展示):
📋 短信解析结果:
- 类型:文书送达
- 案号:(2025)苏0981民初1234号
- 当事人:某某、xx有限公司
- 法院:xx市人民法院
- 下载链接:已提取(zxfw.court.gov.cn)
Step 3:获取文书列表并下载
优先使用方案一(API 直连),失败后降级到方案二(浏览器)。严格串行,当前方案成功即停止,不并行尝试。
API 失败判断条件(满足任一即视为失败,降级到方案二):
- curl 返回非 0 退出码(网络错误)
- HTTP 状态码非 200
- 响应体无法解析为 JSON
- 响应 JSON 中
data字段为null或空数组[] - 响应 JSON 中
data字段不存在
方案一:API 直连(推荐,无需浏览器)
直接调用 zxfw 后端 API 获取文书列表和 OSS 下载链接,再用 curl 批量下载 PDF。
API 信息:
- 端点:
POST https://zxfw.court.gov.cn/yzw/yzw-zxfw-sdfw/api/v1/sdfw/getWsListBySdbhNew - Content-Type:
application/json - 请求体:
{ "qdbh": "xxx", "sdbh": "xxx", "sdsin": "xxx" }(从短信 URL 提取) - 响应字段:
data[].c_wsmc(文书名称)、data[].wjlj(OSS 签名下载链接)、data[].c_fymc(法院名称)、data[].c_wsbh(文书编号)、data[].dt_cjsj(送达时间) - 无需认证、无需浏览器
# 0. WORK_DIR 已在前置步骤读取,直接使用
# 确认变量存在
[ -z "$WORK_DIR" ] && { echo "错误:WORK_DIR 未设置"; exit 1; }
# 1. 从短信 URL 提取参数
qdbh="DEMO_qdbh_value"
sdbh="DEMO_sdbh_value"
sdsin="DEMO_sdsin_value"
# 2. 调用 API 获取文书列表
mkdir -p /tmp/court-sms-staging/
resp=$(curl -s -X POST "https://zxfw.court.gov.cn/yzw/yzw-zxfw-sdfw/api/v1/sdfw/getWsListBySdbhNew" \
-H "Content-Type: application/json" \
-d "{\"qdbh\":\"$qdbh\",\"sdbh\":\"$sdbh\",\"sdsin\":\"$sdsin\"}")
# 3. 解析文书列表,逐个下载 PDF
echo "$resp" | python3 -c "
import json, sys, urllib.parse, subprocess
data = json.load(sys.stdin)
for doc in data.get('data', []):
name = doc.get('c_wsmc', '未知文书')
url = doc.get('wjlj', '')
if url:
# URL 解码文件名
safe_name = name.replace('/', '_').replace(':', '_')
subprocess.run(['curl', '-sL', '-o', f'/tmp/court-sms-staging/{safe_name}.pdf', url,
'-H', 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'-H', 'Referer: https://zxfw.court.gov.cn/'])
"
# 4. 验证下载结果
ls -lh /tmp/court-sms-staging/*.pdf
# 5. 记录 API 响应(用于后续归档)
echo "$resp" > /tmp/court-sms-staging/_api_response.json
注意:OSS 签名 URL 有过期时间(约 1 小时),获取后应尽快下载。
dt_cjsj字段为送达记录创建时间,可用于后续上诉期限计算。
方案二:浏览器提取 OSS 直链(降级方案)
当方案一 API 不可用时,使用浏览器自动化。
⚠️ 浏览器操作需先加载
agent-browserskill。以下为操作步骤描述,具体工具调用方式以 agent-browser skill 的接口为准。
操作流程:
- 导航到送达链接 — 用浏览器打开法院送达 URL,等待页面完全加载
- 截取页面快照 — 查看完整页面结构,确认文书列表
页面有两个区域:
- 左侧/上方侧边栏 — 列出本次送达的全部文书(可点击切换)
- 右侧 PDF.js 阅读器 — 显示当前选中文书的 PDF 内容(内含"下载"按钮)
⚠️ 一个送达链接通常包含多份文书! 务必先扫描侧边栏,确认本次送达共有多少份文书。
依次点击侧边栏每份文书,获取其 OSS URL:
对侧边栏中每个文书元素,执行:
- 点击该文书元素 — 切换到该文书
- 执行 JavaScript
document.querySelector('iframe')?.src— 获取 iframe 的 src
iframe 的 src 结构:
https://zxfw.court.gov.cn/zxfw/static/pdfjs/web/viewer.html?file=https%3A%2F%2Fzxfy2-oss.oss-cn-north-2-gov-1.aliyuncs.com%2Fwssdclxz%2F{date}%2F{time}%2F{sdbh}%2F{file_hash}%2F{filename}%3FExpires%3D...%26Signature%3D...
其中 file= 后的内容(URL 解码 1 次)就是可直接下载的 OSS PDF 地址。
用 curl 批量下载到 /tmp/court-sms-staging/:
curl -L -o "/tmp/court-sms-staging/{文件名}.pdf" \
"实际PDF下载URL" \
-H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" \
-H "Referer: https://zxfw.court.gov.cn/"
为什么优先用 API? API 方案完全无头、不需要浏览器、速度快,一次性获取所有文书列表和下载链接,避免逐个点击侧边栏提取 iframe src。
Step 4:确定目标路径
先读取归档模式配置:
ARCHIVE_MODE=$(python3 -c "
import json
with open('$HOME/.config/court-document-downloader/config.json') as f:
print(json.load(f).get('archive_mode', 'by_year'))
")
模式一:archive_mode = "by_year"(默认,按年份分层)
年份按下载日期(当前日期)确定,不从案号解析。
- 基础路径:
${WORK_DIR}/ - 搜索/创建路径:
${WORK_DIR}/{年份}/{案件文件夹}/ - 例:2026 年下载 →
${WORK_DIR}/2026/
⚠️ ⚠️ ⚠️ 即使案号为 (2025)苏1002民初XX号,只要在 2026 年下载,就归入 2026 文件夹
模式二:archive_mode = "flat"(不分年份)
- 基础路径:
${WORK_DIR}/ - 搜索/创建路径:
${WORK_DIR}/{案件文件夹}/ - 不创建年份层级,所有案件文件夹直接放在工作目录下
Step 5:查找匹配的案件文件夹
⚠️ 核心原则:先搜后建。必须先在目标目录下搜索是否已有包含原被告名的文件夹,有则直接复用,无则才新建。绝不允许跳过搜索直接创建新文件夹。
5.1 确定搜索目录
if [ "$ARCHIVE_MODE" = "flat" ]; then
SEARCH_DIR="${WORK_DIR}"
else
SEARCH_DIR="${WORK_DIR}/$(date +%Y)"
fi
5.2 提取原被告名
来源一(优先):从 API 返回或 PDF 文件名提取
如果 PDF 文件名包含当事人公司名(如 民事传票(某某建设工程有限公司).pdf),或 API 返回的法院信息中包含当事人信息,直接使用。
来源二(回退):从 PDF 文件内容解码提取
当文件名仅含案号时,解码 PDF 正文提取原告和被告信息。(详见 Step 7 的 CID 字体解码章节)
5.3 执行搜索(⚠️ 必须,不可跳过)
提取到原被告名后,必须执行以下搜索代码,在目标目录下查找匹配的已有文件夹:
# PLAINTIFF 和 DEFENDANT 已从 5.2 提取获得
# SEARCH_DIR 已从 5.1 确定
MATCHED_DIR=$(python3 -c "
import os, sys, re
search_dir = '$SEARCH_DIR'
plaintiff = '''$PLAINTIFF''' # 原告名
defendant = '''$DEFENDANT''' # 被告名
if not os.path.exists(search_dir):
print('')
sys.exit(0)
# 列出所有案件文件夹(排除 .archive 等隐藏目录)
existing_folders = [d for d in os.listdir(search_dir)
if os.path.isdir(os.path.join(search_dir, d)) and not d.startswith('.')]
if not existing_folders:
print('')
sys.exit(0)
print('现有文件夹:', existing_folders, file=sys.stderr)
# 去掉公司后缀,提取核心关键词
def strip_suffix(name):
if not name:
return ''
for suffix in ['股份有限公司', '有限责任公司', '有限公司', '集团']:
if name.endswith(suffix):
return name[:-len(suffix)]
return name
# 去掉地域前缀(省份、城市等),生成变体
# 例如:「江苏天下无敌公司」→「天下无敌公司」
# 「南京市某某建设工程」→「某某建设工程」
def strip_geo_prefix(name):
if not name:
return ''
variants = [name]
# 省份前缀
provinces = ['江苏', '浙江', '广东', '北京', '上海', '天津', '重庆',
'山东', '河南', '河北', '湖北', '湖南', '四川', '福建',
'安徽', '江西', '辽宁', '吉林', '黑龙江', '山西', '陕西',
'云南', '贵州', '甘肃', '青海', '海南', '内蒙古', '新疆',
'西藏', '广西', '宁夏', '香港', '澳门', '台湾']
for prov in provinces:
if name.startswith(prov):
variants.append(name[len(prov):])
break
# 「XX市」前缀
m = re.match(r'([\u4e00-\u9fa5]{2,4}市)', name)
if m:
variants.append(name[len(m.group(1)):])
# 「XX省XX市」组合前缀
m = re.match(r'([\u4e00-\u9fa5]{2,4}省[\u4e00-\u9fa5]{2,4}市)', name)
if m:
variants.append(name[len(m.group(1)):])
# 「XX市XX区」组合前缀
m = re.match(r'([\u4e00-\u9fa5]{2,4}市[\u4e00-\u9fa5]{2,4}区)', name)
if m:
variants.append(name[len(m.group(1)):])
return variants
# 生成一个当事人名的所有匹配变体
def get_variants(full_name):
if not full_name:
return []
variants = set()
# 全名本身
variants.add(full_name)
# 去公司后缀
core = strip_suffix(full_name)
if core:
variants.add(core)
# 去地域前缀(从 core)
for v in strip_geo_prefix(core):
if len(v) >= 2:
variants.add(v)
# 去地域前缀(从 full_name)
for v in strip_geo_prefix(full_name):
if len(v) >= 2:
stripped = strip_suffix(v)
variants.add(v)
if stripped:
variants.add(stripped)
return variants
def name_matches(folder, full_name):
\"\"\"判断文件夹名是否包含当事人名的任意变体\"\"\"
if not full_name:
return False
for variant in get_variants(full_name):
# 变体长度 >= 3 才用于匹配,避免过短导致误匹配
if len(variant) >= 3 and variant in folder:
return True
return False
# 匹配规则:原被告名必须都出现在文件夹名中(全称/简称/去地域前缀均可)
# 只命中一方不算匹配
best_match = ''
best_score = 0
for folder in existing_folders:
p_hit = name_matches(folder, plaintiff)
d_hit = name_matches(folder, defendant)
# 必须原被告都命中才算匹配
if not (p_hit and d_hit):
continue
# 评分用于多个匹配时选最优(全名命中优于简称命中)
score = 0
if plaintiff and plaintiff in folder:
score += 2
elif plaintiff and strip_suffix(plaintiff) in folder:
score += 1
if defendant and defendant in folder:
score += 2
elif defendant and strip_suffix(defendant) in folder:
score += 1
if score > best_score:
best_score = score
best_match = folder
if best_match:
print(os.path.join(search_dir, best_match))
else:
print('')
" 2>&1)
# 注意:stderr 会打印现有文件夹列表,用于调试
if [ -n "$MATCHED_DIR" ] && [ -d "$MATCHED_DIR" ]; then
echo "✅ 找到匹配的案件文件夹: $MATCHED_DIR"
TARGET_DIR="$MATCHED_DIR"
else
echo "⚠️ 未找到匹配的案件文件夹,将创建新文件夹"
# 进入 5.4 新建文件夹
fi
匹配规则(原被告必须同时命中):
对每个当事人名,skill 会生成以下变体用于匹配:
- 全名:原始名称(如
江苏天下无敌建设工程有限公司) - 去公司后缀:去掉"有限公司""有限责任公司""股份有限公司""集团"等后缀(如
江苏天下无敌建设工程) - 去地域前缀:去掉省份(江苏/浙江/广东/北京...)、"XX市"、"XX省XX市"、"XX市XX区"等地域前缀(如
天下无敌建设工程有限公司、天下无敌建设工程)
只要文件夹名包含任意一个变体(变体长度 ≥ 3 字符),即视为该方命中。
| 条件 | 说明 |
|---|---|
| 原告命中 | 文件夹名包含原告的任意变体(全名 / 去后缀 / 去地域前缀) |
| 被告命中 | 同上,文件夹名包含被告的任意变体 |
| 匹配成功 | 原告命中 且 被告命中 → 复用该文件夹 |
| 匹配失败 | 只命中一方、或都未命中 → 新建文件夹 |
⚠️ 只有原被告名都出现在文件夹名中才算匹配。只命中原告或只命中被告不算。
示例:
已有文件夹
天下无敌建设工程诉某某科技合同纠纷新文书原告=
江苏天下无敌建设工程有限公司,被告=某某科技有限公司原告变体含"天下无敌建设工程" → 文件夹命中 ✓ + 被告变体含"某某科技" → 文件夹命中 ✓ → 匹配成功,复用
已有文件夹
某某建设工程诉某某科技合同纠纷新文书原告=
某某建设工程有限公司,被告=张某原告命中 ✓ 但被告"张某"未命中 ✗ → 匹配失败,新建文件夹
5.4 新建文件夹(仅当 5.3 搜索无匹配时执行)
只有当 MATCHED_DIR 为空时,才根据已提取的原告名、被告名和案由创建新文件夹。
# 仅在未匹配到已有文件夹时执行
if [ -z "$MATCHED_DIR" ] || [ ! -d "$MATCHED_DIR" ]; then
CASE_FOLDER="{原告名}诉{被告名}{案由}"
TARGET_DIR="${SEARCH_DIR}/${CASE_FOLDER}"
mkdir -p "$TARGET_DIR"
echo "✅ 已创建新案件文件夹: $TARGET_DIR"
fi
文件夹命名格式: {原告名}诉{被告名}{案由}
示例:
某某餐饮服务有限公司诉某某合同纠纷某某诉某某房屋租赁合同纠纷某某建设工程有限公司诉某某建设施工合同纠纷
案由提取优先级:
- 优先从 PDF 正文提取(如传票中"案由:×××")
- 其次从 PDF 文件名推断(如"合同纠纷""侵权纠纷")
- 最后退化为通用案由如"民事纠纷"
Step 6:移动 PDF 到案件文件夹
TARGET_DIR已在 Step 5 中确定(要么匹配到已有文件夹,要么新建)。此处直接使用,不再重新计算。
# TARGET_DIR 已在 Step 5 确定(匹配已有文件夹 或 新建文件夹)
# 确保目标目录存在(Step 5.4 新建时已 mkdir,匹配到时可能已存在)
mkdir -p "$TARGET_DIR"
# 使用 mv 移动文件(而非 cp 复制),避免临时文件残留
mv "/tmp/court-sms-staging/{文件名}.pdf" \
"${TARGET_DIR}/{文书标题}({当事人+案由})_{YYYYMMDD}收.pdf"
文件命名格式: {文书标题}({当事人+案由})_{YYYYMMDD}收.pdf
示例:
传票(某某建设工程有限公司诉某某建设施工合同纠纷)_20260610收.pdf民事判决书(某某与某某合同纠纷)_20260610收.pdf
命名规则:
文书标题:优先使用 API 返回的c_wsmc,其次从 PDF 内容提取,最后回退为原始文件名当事人+案由:从文书内容提取,取原告+被告+案由的核心信息YYYYMMDD:下载日期- 清理非法字符:
< > : " | ? * \ / - 同名文件已存在时追加
_2后缀 - 日期后缀统一加"收"表示收到日期,区别于文书本身日期
Step 7:解析文书内容并告知用户
下载并归档后,使用 pymupdf 提取 PDF 文字内容:
# 使用 pymupdf 提取 PDF 文字(如未安装则安装到用户级,不污染全局环境)
python3 -c "import pymupdf" 2>/dev/null || python3 -m pip install --user pymupdf -q
python3 -c "
import pymupdf
doc = pymupdf.open('/path/to/file.pdf')
for page in doc:
print(page.get_text())
"
如果是传票,重点提取以下信息:
- 案号、案由
- 被传唤人
- 开庭时间(关键!用于后续提醒设置)
- 开庭地点(关键!用于日历事件)
- 承办法官/书记员及联系方式
如果是判决书/裁定书,重点提取:
- 案号、案由
- 当事人信息
- 判决/裁定日期
- 文书类型(一审/二审)
CID 字体编码 PDF 的文字提取方法:
如果 pymupdf 输出为空或乱码,说明是 CID 字体编码的复杂 PDF(WPS 生成),需手动解码 ToUnicode CMap:
import re, zlib
# 1. 读取 PDF 文件
with open('/tmp/文书.pdf', 'rb') as f:
data = f.read()
# 2. 提取指定编号的 PDF 对象(FlateDecode 解压)
def extract_object(data, obj_num):
"""提取 PDF 中指定编号的对象(FlateDecode 解压)"""
pattern = rb'%d\s+0\s+obj\s*(.*?)\s*endobj' % obj_num
match = re.search(pattern, data, re.S)
if not match:
return None
stream = match.group(1).split(b'stream')[1].split(b'endstream')[0].strip()
return zlib.decompress(stream)
def find_tounicode_refs(data):
"""找到 PDF 中所有 ToUnicode CMap 的引用对象编号"""
refs = []
for m in re.finditer(rb'/ToUnicode\s+(\d+)\s+0\s+R', data):
refs.append(int(m.group(1)))
return refs
def extract_page_content_streams(data):
"""提取所有页面的 Content Stream 并解压"""
# 找到所有页面对象编号
page_refs = [int(m.group(1)) for m in re.finditer(rb'(\d+)\s+0\s+obj\s*.*?/Type\s*/Page\b', data, re.S)]
streams = []
for page_num in page_refs:
obj_data = extract_object(data, page_num)
if obj_data is None:
# 页面对象本身不含 stream,查找 /Contents 引用
pattern = rb'%d\s+0\s+obj\s*(.*?)\s*endobj' % page_num
match = re.search(pattern, data, re.S)
if match:
contents_match = re.search(rb'/Contents\s+(\d+)\s+0\s+R', match.group(1))
if contents_match:
obj_data = extract_object(data, int(contents_match.group(1)))
if obj_data:
streams.append(obj_data)
return streams
# 3. 构建 CID→Unicode 映射字典
cmap = {}
for ref in find_tounicode_refs(data):
cmap_data = extract_object(data, ref)
if cmap_data:
for m in re.finditer(r'<([0-9A-F]+)>\s+<([0-9A-F]+)>', cmap_data.decode()):
cmap[int(m.group(1), 16)] = chr(int(m.group(2), 16))
# 4. 解压各页 Content Stream 并解码 CID 引用
decoded_text = ''
for page in extract_page_content_streams(data):
text = page.decode('latin-1')
segments = []
for cid_hex in re.findall(r'<([0-9A-F]+)>Tj', text):
cid = int(cid_hex, 16)
if cid in cmap:
segments.append(cmap[cid])
else:
# 数字 0-9 特殊处理(常见于页码、日期)
if 0x13 <= cid <= 0x1c:
segments.append(chr(0x30 + cid - 0x13))
decoded_text += ''.join(segments)
# 5. 搜索"原告:"和"被告:"后的名称
plaintiff_match = re.search(r'原告[::]\s*(.+?)(?:\n|$)', decoded_text)
defendant_match = re.search(r'被告[::]\s*(.+?)(?:\n|$)', decoded_text)
plaintiff = plaintiff_match.group(1).strip() if plaintiff_match else None
defendant = defendant_match.group(1).strip() if defendant_match else None
Step 8:设置开庭提醒(仅传票自动触发)
判断逻辑: 从文书正文中提取关键词判断文书类型。
- 如果正文包含
传票→ 自动设置开庭提醒(无需询问用户) - 如果正文是判决书、裁定书等其他文书 → 仅归档,不设置提醒
提醒方式:仅 Apple Calendar(macOS)
Apple Calendar 在 macOS 上可直接用 AppleScript 操作,将开庭事件添加至 iCloud 日历,自动设置地点、备注和多重提醒。
从配置读取日历名称和提醒设置:
# 读取配置中的日历名称和提醒设置
CONFIG_FILE="$HOME/.config/court-document-downloader/config.json"
CALENDAR_NAME=$(python3 -c "
import json
with open('$CONFIG_FILE') as f:
print(json.load(f).get('calendar_name', '个人'))
")
REMINDERS_JSON=$(python3 -c "
import json
with open('$CONFIG_FILE') as f:
reminders = json.load(f).get('default_reminders', [{'days_before': 7}, {'days_before': 2}])
print(json.dumps([r['days_before'] for r in reminders]))
")
echo "日历: $CALENDAR_NAME, 提醒天数: $REMINDERS_JSON"
典型事件参数:
- 标题:
开庭 - {案号} {案由} - 开始时间:开庭日期 + 时间
- 结束时间:开始后约 2 小时(庭审通常 1-2 小时)
- 地点:法院全称 + 法庭 + 地址
- 备注:案号、案由、当事人、承办法官、联系方式等完整信息
- 提醒方式:从配置的
default_reminders读取(默认提前 7 天 + 提前 2 天)
AppleScript 实现:
⚠️ 关键坑:Apple Calendar 的
trigger interval单位是分钟,不是秒!
tell application "Calendar"
-- 日历名称从配置读取,默认 "个人"
set theCalendar to calendar "个人"
set eventTitle to "开庭 - (2026)苏1002民初31号 合同纠纷"
set eventLocation to "某某市某某区人民法院 · 第四法庭(地址)"
set eventDescription to "案号:...
案由:...
当事人:...
承办法官:..."
set startDate to date "2026-05-18 14:30:00"
set endDate to date "2026-05-18 16:30:00"
tell theCalendar
set newEvent to make new event at end with properties {¬
summary:eventTitle, start date:startDate, end date:endDate, ¬
location:eventLocation, description:eventDescription}
-- 注意:trigger interval 单位是分钟!
-- 提醒天数从配置的 default_reminders 数组动态生成
-- 提前7天 = -10080 分钟(7 × 24 × 60)
make new display alarm at end of newEvent with properties {trigger interval:-7 * 24 * 60}
-- 提前2天 = -2880 分钟(2 × 24 × 60)
make new display alarm at end of newEvent with properties {trigger interval:-2 * 24 * 60}
end tell
end tell
Step 9:写入归档记录
每次处理完成后,在用户工作目录下的归档子目录中创建一条 JSON 记录,用于追溯。归档子目录名称从配置的 archive_subdirectory 字段读取(默认 .archive)。
# 读取归档子目录名称
ARCHIVE_SUBDIR=$(python3 -c "
import json
with open('$HOME/.config/court-document-downloader/config.json') as f:
print(json.load(f).get('archive_subdirectory', '.archive'))
")
ARCHIVE_DIR="${WORK_DIR}/${ARCHIVE_SUBDIR}"
mkdir -p "$ARCHIVE_DIR"
文件路径: ${ARCHIVE_DIR}/YYYYMMDD_HHMMSS_{案号后4位}.json
${WORK_DIR}从前置步骤的配置中读取,归档记录与案件文件保存在同一根目录下,便于备份和迁移。
JSON 结构:
{
"id": "20260610_143025_1234",
"timestamp": "2026-06-10T14:30:25+08:00",
"sms_raw": "【xx市人民法院】某某,您好!...",
"parsed": {
"type": "document_delivery",
"case_number": "(2025)苏0981民初1234号",
"parties": ["xx有限公司", "某某"],
"court": "xx市人民法院",
"case_reason": "合同纠纷"
},
"download": {
"source_url": "https://zxfw.court.gov.cn/zxfw/#/...",
"params": { "qdbh": "XX", "sdbh": "XX", "sdsin": "XX" },
"method": "api",
"status": "success",
"api_response": {
"c_fymc": "xx市人民法院",
"dt_cjsj": "2026-03-18T07:44:00.000+00:00",
"documents": [
{ "c_wsmc": "传票", "c_wsbh": "ecb8fe64...", "dt_cjsj": "..." }
]
}
},
"document": {
"type": "传票",
"sent_at": "2026-03-18T15:44:00+08:00",
"appeal_deadline": null,
"appeal_days_remaining": null
},
"archive": {
"matched_case": "某某建设工程有限公司诉某某建设施工合同纠纷",
"target_path": "{WORK_DIR}/2026/某某建设工程有限公司诉某某建设施工合同纠纷/",
"files": [
"传票(某某建设工程有限公司诉某某建设施工合同纠纷)_20260610收.pdf"
]
},
"reminder": {
"calendar_event": "开庭 - (2025)苏0981民初1234号 合同纠纷",
"court_date": "2026-04-15T14:30:00+08:00",
"location": "xx市人民法院 第3法庭",
"alarms": ["-7天", "-2天"]
}
}
Step 10:上诉期限计算(判决书/裁定书自动触发)
当识别到判决书或裁定书时,自动计算上诉截止日期。
上诉期限规则:
| 案件类型 | 上诉期限 |
|---|---|
| 民事一审判决 | 送达后 15 天 |
| 民事裁定 | 送达后 10 天 |
| 行政判决 | 送达后 15 天 |
| 刑事判决 | 送达后 10 天 |
| 刑事裁定 | 送达后 5 天 |
计算公式: 上诉截止日期 = 送达日期 + 上诉期限天数
送达日期来源:
- 优先使用 API 响应中的
dt_cjsj字段(送达记录创建时间) - 其次使用短信接收时间
- 无法确定时展示"送达时间待确认",不阻塞后续流程
Step 11:向用户汇报(结构化模板)
使用以下四段式模板汇报处理结果:
✅ 文书归档完成:
- 案号:(2025)苏0981民初1234号
- 法院:xx市人民法院
- 当事人:原告 xx有限公司 / 被告 某某
- 案由:合同纠纷
- 文件数:N 份
- 归档位置:{案件目录}/
📄 文书清单:
1. 传票 传票
2. 应诉通知书 通知书
3. 起诉状(要素式) 起诉状
4. 举证通知书 通知书
...
⚠️ 已收到传票,请注意:
- 开庭时间:2026年4月15日(周三)14:30
- 开庭地点:xx市人民法院 第3法庭
- 审理程序:简易程序
- 日历提醒:已设置(提前7天 + 提前2天)
⏰ 上诉期限提醒:
- 文书类型:一审判决书
- 送达时间:2026年3月18日
- 上诉截止:2026年4月2日(周四)
- 剩余天数:xx 天
各部分触发条件:
- "📄 文书清单":始终展示
- "⚠️ 传票提醒":仅当文书清单中包含传票时展示
- "⏰ 上诉期限":仅当文书清单中包含判决书/裁定书且能提取到送达时间时展示
归档失败时的汇报:
⚠️ 文书归档部分完成:
- 案号:(2025)苏0981民初1234号
- 法院:xx市人民法院
- 成功:N 份
- 失败:N 份
失败的文书:
- xxx.pdf(原因:下载超时)
请手动访问以下链接下载失败文书:
{原始链接}
Step 11.5:同步发送到对话框(必做)
无论归档成功还是部分成功,必须在汇报的同时,把本次送达的每一份文书 PDF 通过 present_files 工具发送到当前对话框中,使用户能在对话内直接预览和下载。这一步与桌面归档相互独立、互不影响。
- 传入路径:归档后的完整文件路径(即 Step 6
mv之后的最终路径),例如${TARGET_DIR}/传票(某某诉某某合同纠纷)_20260711收.pdf - 多份文书:一次性把所有成功归档的 PDF 路径作为数组传入
present_files,按重要性排序(传票/判决书优先) - 重复送达(SHA256 与已归档文件一致):直接 present 已有的归档文件,不再生成新副本
注意:本步骤在 Step 12 清理临时目录之前执行,确保发送的是已归档到案件文件夹的正式文件,而非 /tmp 临时文件。
Step 12:清理临时文件
汇报完成后,清理临时下载目录:
rm -rf /tmp/court-sms-staging/
常见法院文书类型(同一送达链接可能包含多份)
- 民事传票 / 开庭传票
- (合)起诉状(素)
- 民事一审应诉通知书
- 民事一审举证通知书
- 小额诉讼程序告知书(告知当事人小额诉讼程序用)
- 原告举证材料
- 民事判决书
- 民事裁定书
- 合议庭组成人员通知书
- 受理案件通知书
- 诉讼费用交费通知书
- 廉政监督卡
注意事项
- 一次送达可能含多份文书:浏览器方案用页面快照扫描侧边栏确认文档总数,API 方案可直接从响应
data数组确认 - 优先使用 API 方案:完全无头、无需浏览器、一次性获取所有文书,速度和可靠性远优于浏览器方案
- 每份文书的 OSS URL 中的 file_hash 不同:即使在同一送达批次中,每份文书有独立的 hash 子目录
- PDF 是临时签名 URL:阿里云 OSS 的 URL 带有
Expires过期参数,如果下载失败可能已过期,需要重新调用 API 获取新的 URL - iframe 的 src 可能带有 URL 编码:需要先 URL 解码
file=参数值才能得到真实的 OSS 地址 - 法院链接中的 qdbh/sdbh 参数是一次性/有时效的:如果页面无法打开,让用户重新获取链接
- 案件文件夹匹配(先搜后建):Step 5 必须先搜索已有文件夹,原被告名同时命中才算匹配(支持全名、去公司后缀简称、去地域前缀变体),匹配到则复用,无匹配才新建。新建文件夹统一按
{原告名}诉{被告名}{案由}格式命名 - 文件名中包含当事人名称的编码:OSS 上的文件名是 URL 编码的中文,下载时可以重命名为中文明文
- 文件命名规范:
{文书标题}({当事人+案由})_{YYYYMMDD}收.pdf,同名文件追加_2后缀 - CID 字体编码:WPS 生成的 PDF 使用 CID 字体,文字不能直接提取,需解码 ToUnicode CMap
- OSS 链接有时效:需及时下载,不要拖延
- API 响应保留:将 API 响应保存到
/tmp/court-sms-staging/_api_response.json,供后续归档使用 - 归档记录:每次处理完成后写入
${WORK_DIR}/${ARCHIVE_SUBDIR}/(默认.archive),便于追溯。${WORK_DIR}为用户配置的工作目录 - 上诉期限:判决书/裁定书自动计算,使用 API 的
dt_cjsj作为送达日期 - 工作目录配置:首次使用时需配置工作目录,保存在
~/.config/court-document-downloader/config.json,可随时修改或删除重新触发配置引导 - 临时文件清理:Step 6 使用
mv移动文件后,Step 11 汇报完成后应执行rm -rf /tmp/court-sms-staging/清理临时目录
关键经验教训
Apple Calendar display alarm 的 trigger interval 单位是分钟,不是秒!
这是一个非常容易踩坑的点:
| 如果写成(秒) | 实际上会被解释为 | 日历显示 |
|---|---|---|
-604800 (7×86400) |
-604800 分钟 = 420 天 | 提前420天 |
-259200 (3×86400) |
-259200 分钟 = 180 天 | 提前180天 |
-172800 (2×86400) |
-172800 分钟 = 120 天 | 提前120天 |
正确的写法(用分钟计算):
| 想要的效果 | 正确值(分钟) | 公式 |
|---|---|---|
| 提前7天 | -10080 |
-(7 × 24 × 60) |
| 提前3天 | -4320 |
-(3 × 24 × 60) |
| 提前2天 | -2880 |
-(2 × 24 × 60) |
| 提前1天 | -1440 |
-(1 × 24 × 60) |
最佳实践:直接在 AppleScript 中写
-7 * 24 * 60这样的表达式,让系统去计算,避免手动算错。
-- 正确写法(分钟)
make new display alarm at end of newEvent with properties {trigger interval:-7 * 24 * 60} -- 提前7天
make new display alarm at end of newEvent with properties {trigger interval:-2 * 24 * 60} -- 提前2天
验证步骤
- 送达的全部文书已成功下载(每份文件大小 > 0)
- 在正确的年份文件夹中(按下载日期,非案号年份)
- 在正确的案件文件夹中(Step 5 先搜后建:评分≥2 则复用已有文件夹,无匹配才新建)
- 文件命名符合
{文书标题}({当事人+案由})_{YYYYMMDD}收.pdf格式 - 文书内容已解析并以结构化模板告知用户
- 如为传票 → 已自动设置 Apple Calendar 开庭提醒(提醒天数从配置的
default_reminders读取) - 如为判决书/裁定书 → 已自动计算上诉期限并提醒
- 归档记录已写入
${WORK_DIR}/${ARCHIVE_SUBDIR}/(默认.archive) /tmp/court-sms-staging/临时文件已清理(Step 12)- 已通过
present_files将每份文书 PDF 同步发送到对话框(Step 11.5)