2025年4月,芝加哥软件开发者John Kyle Cronan 想查一份文件。政府正准备把一批人送往萨尔瓦多的CECOT监狱,案子是W.M.M. v. Trump。他打开PACER,点开卷宗,屏幕跳出一句话:"你没有权限查看该文件"。法院工作人员告诉他,这份名义上公开的记录只能到法院的实体终端查阅——那个终端在北德克萨斯,他人在芝加哥。
这不是系统故障,是一条写进联邦民事诉讼规则的条款,专门限制移民和社保案件的远程访问权限。Cronan没有法律背景,只是一个没事就登录PACER翻卷宗的人。他后来发起的项目Habeas Dockets ,靠几百名志愿者的手工搬运,把这条规则撕开了一个口子。
一条2007年的隐私条款,为什么卡住了今天的移民报道
Habeas corpus 是一项可以追溯几百年的法律救济手段,被拘留者可以据此要求法院审查拘留是否合法。在移民执法大规模扩张的当下,它成了对抗拘留最关键的法律工具之一,全美已有近5万份相关诉状进入联邦法院系统。
问题出在规则5.2(c) 。这条2007年制定的条款原本是好意——移民和社保案件容易涉及贩卒、家暴等敏感信息,所以规定非当事人只能远程看到docket、裁决和命令,想看完整卷宗必须亲自去法院终端。放在互联网早期,这个限制不算离谱;放在今天,一个记者要核实全国范围内的拘留案件,等于要飞遍几十个联邦地区法院。
Reporters Committee for Freedom of the Press 的律师Renee Griffin说得直接:这是"一个全国性问题"。它拖慢了单个案件的信息获取,也让批量核查政府在habeas案件里的说辞几乎不可能。Cronan的说法更冷:"这对他们(政府)很方便。"
400人怎么把"只能到现场看"的文件搬上网
Cronan的做法不复杂,复杂的是规模。志愿者跑到各地联邦法院的公共终端,把限制访问的文件打印或扫描出来,项目在可能的情况下报销打印费用。收上来的文件不会直接上线,要先经过法学院学生、律师助理和志愿律师的审核,把可能暴露当事人处境的敏感信息涂黑——即便法律并不强制他要求这么做。
这套流程后来变成了一个更完整的四步方法论:用PACER案件检索接口识别出"人身保护令—外国人拘留"类案件,和公开数据库RECAP做比对;把缺失的文件分派给离得最近的志愿者去取;按案件信息核实真实性并做敏感信息审查;审核通过后上线,并链接回RECAP或PACER原件,数据库每天更新。
Habeas Dockets 的四步搬运法
识别案件
PACER接口+RECAP比对
就近分派
志愿者跑法院终端
审核涂黑
法学生/律师核查敏感信息
发布上线
链接原件,每日更新
两个月,文件量翻了四倍
真正让这个项目变得不一样的,是增速。今年6月被媒体报道时,Habeas Dockets收录的文件约为2.9万份 ;到8月下旬,这个数字已经涨到超过12万份 ,追踪的案件数接近7.3万个 。今年3月,它正式挂靠一家新成立的非营利组织Immigration Justice Transparency Initiative ,从Cronan一个人的副业变成了有机构名分的旗舰项目。
两个月的规模变化
6月收录文件
2.9万
8月收录文件
12万+
累计参与志愿者
400人
{
const threshold = Math.max(88, Math.min(180, Math.round(window.innerHeight * 0.16)))
quicknav.classList.toggle('is-visible', (window.scrollY || window.pageYOffset || 0) > threshold)
}
sync_quicknav()
window.addEventListener('scroll', sync_quicknav, { passive: true })
window.addEventListener('resize', sync_quicknav)
const share_btn = quicknav.querySelector('[data-article-share]')
if (share_btn) {
share_btn.addEventListener('click', async () => {
const url = window.location.href
const title = document.title
try {
if (navigator.share) { await navigator.share({ title, url }); return }
if (navigator.clipboard) {
await navigator.clipboard.writeText(url)
share_btn.classList.add('is-copied')
setTimeout(() => share_btn.classList.remove('is-copied'), 1200)
}
} catch (err) { /* 用户取消或不支持,忽略 */ }
})
}
}
const maps = document.querySelectorAll('[data-article-mindmap]')
if (!maps.length) return
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
for (const viewport of maps) {
const frame = viewport.closest('.article-mindmap-frame')
const stage = viewport.querySelector('[data-mindmap-stage]')
const canvas = viewport.querySelector('[data-mindmap-canvas]')
const layout = viewport.querySelector('[data-mindmap-layout]')
const toolbar = frame?.querySelector('.article-mindmap-toolbar')
if (!stage || !canvas || !layout || !toolbar) continue
let scale = 1
let fit_scale_value = 1
let min_scale = 0.28
let max_scale = 1.8
let natural_width = 0
let natural_height = 0
let pinch_start_distance = 0
let pinch_start_scale = 1
let drag_start_x = 0
let drag_start_y = 0
let drag_start_left = 0
let drag_start_top = 0
let drag_enabled = false
const active_pointers = new Map()
function is_mobile() {
return window.matchMedia('(max-width: 640px)').matches
}
function get_screen_height() {
const visual_height = window.visualViewport?.height
if (Number.isFinite(visual_height) && visual_height > 0) return visual_height
return window.innerHeight || document.documentElement.clientHeight || 0
}
function sync_viewport_max_height() {
viewport.style.maxHeight = 'none'
}
function sync_buttons() {
const reset_button = toolbar.querySelector('[data-mindmap-action="reset"]')
if (reset_button) {
reset_button.textContent = Math.round(scale * 100) + '%'
}
}
function measure() {
sync_viewport_max_height()
canvas.style.transform = 'scale(1)'
canvas.style.width = ''
canvas.style.height = ''
stage.style.width = ''
stage.style.height = ''
natural_width = Math.ceil(layout.offsetWidth)
natural_height = Math.ceil(layout.offsetHeight)
}
function apply_scale(next_scale, { preserve_center = false } = {}) {
if (!natural_width || !natural_height) measure()
const previous_scale = scale
scale = clamp(next_scale, min_scale, max_scale)
const viewport_width = viewport.clientWidth || 1
const viewport_height = viewport.clientHeight || 1
const center_x = preserve_center ? (viewport.scrollLeft + viewport_width / 2) / Math.max(previous_scale, 0.001) : 0
const center_y = preserve_center ? (viewport.scrollTop + viewport_height / 2) / Math.max(previous_scale, 0.001) : 0
const scaled_width = Math.ceil(natural_width * scale)
const scaled_height = Math.ceil(natural_height * scale)
const fits_width = scaled_width <= viewport_width + 1
const fits_height = scaled_height <= viewport_height + 1
canvas.style.transform = 'scale(' + scale + ')'
canvas.style.width = scaled_width + 'px'
canvas.style.height = scaled_height + 'px'
stage.style.width = (fits_width ? viewport_width : scaled_width) + 'px'
stage.style.height = scaled_height + 'px'
viewport.classList.toggle('is-contained', fits_width && fits_height)
if (fits_width) {
viewport.scrollLeft = 0
}
if (fits_height) {
viewport.scrollTop = 0
}
if (preserve_center && !(fits_width && fits_height)) {
viewport.scrollLeft = Math.max(0, center_x * scale - viewport_width / 2)
viewport.scrollTop = Math.max(0, center_y * scale - viewport_height / 2)
} else if (scale <= min_scale + 0.02) {
viewport.scrollLeft = 0
viewport.scrollTop = 0
}
sync_buttons()
}
function fit_scale() {
if (!natural_width) measure()
const viewport_width = Math.max(viewport.clientWidth - 20, 1)
const width_ratio = viewport_width / Math.max(natural_width, 1)
fit_scale_value = clamp(width_ratio, 0.26, 1)
min_scale = is_mobile()
? clamp(fit_scale_value * 0.7, 0.22, 0.46)
: clamp(fit_scale_value * 0.68, 0.18, 0.92)
max_scale = is_mobile() ? 1.6 : 1.9
const initial_scale = is_mobile()
? fit_scale_value
: clamp(fit_scale_value * 0.97, min_scale, 1)
apply_scale(initial_scale)
}
toolbar.addEventListener('click', (event) => {
const button = event.target.closest('[data-mindmap-action]')
if (!button) return
const action = button.getAttribute('data-mindmap-action')
if (action === 'zoom-in') apply_scale(scale + 0.12, { preserve_center: true })
if (action === 'zoom-out') apply_scale(scale - 0.12, { preserve_center: true })
if (action === 'reset') fit_scale()
})
viewport.addEventListener('pointerdown', (event) => {
if (is_mobile()) return
if (event.pointerType === 'touch') event.preventDefault()
viewport.setPointerCapture?.(event.pointerId)
active_pointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (active_pointers.size === 1) {
drag_start_x = event.clientX
drag_start_y = event.clientY
drag_start_left = viewport.scrollLeft
drag_start_top = viewport.scrollTop
viewport.classList.add('is-dragging')
} else if (active_pointers.size === 2) {
const points = [...active_pointers.values()]
pinch_start_distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y)
pinch_start_scale = scale
viewport.classList.add('is-dragging')
}
})
viewport.addEventListener('pointermove', (event) => {
if (is_mobile()) return
if (!active_pointers.has(event.pointerId)) return
if (event.pointerType === 'touch' && drag_enabled) event.preventDefault()
active_pointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (active_pointers.size === 1) {
if (!drag_enabled) return
viewport.scrollLeft = drag_start_left - (event.clientX - drag_start_x)
viewport.scrollTop = drag_start_top - (event.clientY - drag_start_y)
return
}
if (active_pointers.size !== 2) return
const points = [...active_pointers.values()]
const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y)
if (!pinch_start_distance) return
apply_scale(pinch_start_scale * (distance / pinch_start_distance), { preserve_center: true })
})
function clear_pointer(event) {
if (is_mobile()) return
active_pointers.delete(event.pointerId)
if (active_pointers.size < 2) {
pinch_start_distance = 0
}
if (active_pointers.size === 0) {
drag_enabled = false
viewport.classList.remove('is-dragging')
} else if (active_pointers.size === 1) {
const remaining = [...active_pointers.values()][0]
drag_start_x = remaining.x
drag_start_y = remaining.y
drag_start_left = viewport.scrollLeft
drag_start_top = viewport.scrollTop
}
}
viewport.addEventListener('pointerup', clear_pointer)
viewport.addEventListener('pointercancel', clear_pointer)
viewport.addEventListener('pointerleave', clear_pointer)
viewport.addEventListener('gesturestart', (event) => {
if (!is_mobile()) event.preventDefault()
})
viewport.addEventListener('gesturechange', (event) => {
if (!is_mobile()) event.preventDefault()
})
viewport.addEventListener('gestureend', (event) => {
if (!is_mobile()) event.preventDefault()
})
let resize_timer = null
window.addEventListener('resize', () => {
window.clearTimeout(resize_timer)
resize_timer = window.setTimeout(() => {
measure()
fit_scale()
}, 60)
})
window.visualViewport?.addEventListener('resize', () => {
window.clearTimeout(resize_timer)
resize_timer = window.setTimeout(() => {
measure()
fit_scale()
}, 60)
})
measure()
fit_scale()
}
})()