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() } })()