-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapi_courtlistener.php
More file actions
184 lines (172 loc) · 8.96 KB
/
Copy pathapi_courtlistener.php
File metadata and controls
184 lines (172 loc) · 8.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
<?php
/**
* api_courtlistener.php — server-side proxy for CourtListener v4 API.
*
* Why this exists: we never want the CourtListener token in client code.
* Client calls this file with an `op` param; we validate it against a
* whitelist, forward the request to CourtListener with the token server
* side, cache the response in SQLite for 24h, and return JSON.
*
* Supported ops (all GET):
* op=search_people q=<free text> — find judges/people
* op=person id=<person_id> — full bio record
* op=positions person=<person_id> — judicial positions held
* op=educations person=<person_id> — education history
* op=political person=<person_id> — party affiliations
* op=disclosures person=<person_id> — financial-disclosure list
* op=disclosure id=<disclosure_id> — single disclosure detail
* op=investments person=<person_id> — aggregated investments
* op=gifts person=<person_id> — aggregated gifts
* op=search_opinions q=<free text> [court=<slug>] — full-text search opinions
* op=opinion id=<opinion_id> — single opinion detail
*
* Rate limit: 120 requests/hour per IP. Cache TTL: 24h.
*
* Response shape: {"ok":bool, "source":"courtlistener", "op":"...",
* "data":<upstream json>, "cached":bool, "error":string?}
*/
require_once __DIR__ . '/api_keys.php';
fca_proxy_headers();
if (!fca_rate_ok('cl', 120)) {
http_response_code(429);
echo json_encode(['ok' => false, 'error' => 'Rate limit: 120 requests per hour per IP.']);
exit;
}
$token = fca_load_key('COURTLISTENER_TOKEN');
if (!$token) {
http_response_code(503);
echo json_encode([
'ok' => false,
'error' => 'CourtListener token not configured on server.',
'setup' => 'Add COURTLISTENER_TOKEN=... to /etc/fca/api_keys.env',
]);
exit;
}
// ── INPUT ────────────────────────────────────────────────────────────
$op = preg_replace('/[^a-z_]/', '', (string)($_GET['op'] ?? ''));
$ALLOWED = [
'search_people', 'person', 'positions', 'educations', 'political',
'disclosures', 'disclosure', 'investments', 'gifts',
'search_opinions', 'opinion',
];
if (!in_array($op, $ALLOWED, true)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Unsupported op. Allowed: ' . implode(', ', $ALLOWED)]);
exit;
}
// Clean ID inputs (numeric only, at most 12 digits)
$id = preg_replace('/[^0-9]/', '', (string)($_GET['id'] ?? ''));
$person = preg_replace('/[^0-9]/', '', (string)($_GET['person'] ?? ''));
$q = substr((string)($_GET['q'] ?? ''), 0, 200);
// Keep only characters a person's name might reasonably include
$q = preg_replace('/[^a-zA-Z0-9 .,\'\-]/u', '', $q);
// ── URL BUILD ────────────────────────────────────────────────────────
// CourtListener v4 REST base. Endpoints: /people/, /positions/, /educations/,
// /political-affiliations/, /financial-disclosures/, /disclosure-positions/,
// /investments/, /gifts/.
$base = 'https://www.courtlistener.com/api/rest/v4';
$url = null;
switch ($op) {
case 'search_people':
if ($q === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'q required']); exit; }
// CourtListener v4 rejects unknown filters; `name_full` is not one of
// them. Split the query heuristically: last token = name_last, first
// token = name_first. Works for "Roberts" or "John Roberts" alike.
// Strip common honorifics first so "Hon. Jane Doe" → first=Jane, last=Doe.
$stripped = trim(preg_replace('/^(Hon\.|Judge|Justice|The Honorable)\s+/i', '', $q));
$parts = preg_split('/\s+/', $stripped, -1, PREG_SPLIT_NO_EMPTY);
$params = ['page_size' => 20];
if (count($parts) === 1) {
$params['name_last'] = $parts[0];
} else {
$params['name_first'] = $parts[0];
$params['name_last'] = end($parts);
}
$url = $base . '/people/?' . http_build_query($params);
break;
case 'person':
if ($id === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'id required']); exit; }
$url = $base . '/people/' . $id . '/';
break;
case 'positions':
if ($person === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'person required']); exit; }
$url = $base . '/positions/?' . http_build_query(['person' => $person, 'page_size' => 50]);
break;
case 'educations':
if ($person === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'person required']); exit; }
$url = $base . '/educations/?' . http_build_query(['person' => $person, 'page_size' => 20]);
break;
case 'political':
if ($person === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'person required']); exit; }
$url = $base . '/political-affiliations/?' . http_build_query(['person' => $person, 'page_size' => 20]);
break;
case 'disclosures':
if ($person === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'person required']); exit; }
$url = $base . '/financial-disclosures/?' . http_build_query(['person' => $person, 'page_size' => 20]);
break;
case 'disclosure':
if ($id === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'id required']); exit; }
$url = $base . '/financial-disclosures/' . $id . '/';
break;
case 'investments':
if ($person === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'person required']); exit; }
// v4 exposes investments under /investments/ filtered by the disclosure's person chain
$url = $base . '/investments/?' . http_build_query(['financial_disclosure__person' => $person, 'page_size' => 100]);
break;
case 'gifts':
if ($person === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'person required']); exit; }
$url = $base . '/gifts/?' . http_build_query(['financial_disclosure__person' => $person, 'page_size' => 100]);
break;
case 'search_opinions':
// Full-text search across CourtListener's opinion corpus. This surfaces
// any published appellate opinion mentioning the judge's name — often
// a reversal / remand / petition-denied, which is the actual
// accountability signal journalists and parents need. We use the
// /search/ endpoint (type=o) rather than /opinions/ because only
// /search/ supports Boolean/full-text queries.
if ($q === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'q required']); exit; }
$params = [
'type' => 'o', // opinions
'q' => $q,
'order_by' => 'dateFiled desc',
'page_size' => 20,
];
// Optional court filter — CourtListener slug, e.g. "pa", "scotus", "ca9".
$court = preg_replace('/[^a-z0-9_-]/', '', (string)($_GET['court'] ?? ''));
if ($court !== '') { $params['court'] = $court; }
$url = $base . '/search/?' . http_build_query($params);
break;
case 'opinion':
if ($id === '') { http_response_code(400); echo json_encode(['ok'=>false,'error'=>'id required']); exit; }
$url = $base . '/opinions/' . $id . '/';
break;
}
// ── CACHE LOOKUP ─────────────────────────────────────────────────────
$cache_key = $op . '|' . md5($url);
$cached = fca_cache_get('courtlistener', $cache_key, 86400);
if ($cached !== null) {
echo json_encode(['ok' => true, 'source' => 'courtlistener', 'op' => $op, 'data' => json_decode($cached, true), 'cached' => true]);
exit;
}
// ── FORWARD ──────────────────────────────────────────────────────────
$resp = fca_http_get_json($url, ['Authorization' => 'Token ' . $token], 20);
if (!$resp['ok']) {
http_response_code($resp['status'] ?: 502);
echo json_encode([
'ok' => false,
'source' => 'courtlistener',
'op' => $op,
'status' => $resp['status'],
'error' => $resp['error'] ?: 'upstream error',
]);
exit;
}
// Cache the raw upstream body as a JSON string so we can re-serve it cheaply.
fca_cache_put('courtlistener', $cache_key, json_encode($resp['body']));
echo json_encode([
'ok' => true,
'source' => 'courtlistener',
'op' => $op,
'data' => $resp['body'],
'cached' => false,
]);