Use IJ data for wins list (#157)

This commit is contained in:
stopflock authored and GitHub committed 2026-10-09 14:40:55 -05:00
1 parent 6b1b022646
commit 5e342d1302
9 files changed
+31 -6488

No files matched your search

+1 -2
View File
@@ -1,3 +1,2 @@
CF_API_KEY=your_cloudflare_api_key
CF_ZONE_ID=your_cloudflare_zone_id
OPENAI_API_KEY=your_openai_api_key
CF_ZONE_ID=your_cloudflare_zone_id
+1 -2
View File
@@ -16,5 +16,4 @@ services:
CORS_ORIGIN: true
EXTENSIONS_AUTO_RELOAD: true
CF_API_KEY: ${CF_API_KEY}
CF_ZONE_ID: ${CF_ZONE_ID}
OPENAI_API_KEY: ${OPENAI_API_KEY}
CF_ZONE_ID: ${CF_ZONE_ID}
File diff suppressed because it is too large. Load diff
-49
View File
@@ -1,49 +0,0 @@
{
"name": "directus-extension-import-win",
"description": "Directus extension to import a Flock Safety contract-loss win from a URL using OpenAI",
"icon": "file_download",
"version": "1.0.0",
"license": "UNLICENSED",
"keywords": [
"directus",
"directus-extension",
"directus-custom-bundle"
],
"type": "module",
"files": [
"dist"
],
"directus:extension": {
"type": "bundle",
"path": {
"app": "dist/app.js",
"api": "dist/api.js"
},
"entries": [
{
"type": "endpoint",
"name": "import-win",
"source": "src/api/index.js"
},
{
"type": "interface",
"name": "win-importer",
"source": "src/interface/index.js"
}
],
"host": "^10.3.4"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w --no-minify",
"link": "directus-extension link",
"add": "directus-extension add"
},
"dependencies": {
"cheerio": "^1.0.0",
"openai": "^4.0.0"
},
"devDependencies": {
"@directus/extensions-sdk": "10.3.4"
}
}
-101
View File
@@ -1,101 +0,0 @@
import { load } from 'cheerio';
import OpenAI from 'openai';
const MODEL = 'gpt-4o-mini';
const SYSTEM_PROMPT = `You are a data extraction assistant. You will be given the text of a news article about a city terminating, rejecting, or deactivating an ALPR (Automatic License Plate Reader) contract or system — typically with a vendor like Flock Safety.
Extract the following fields and return ONLY valid JSON with no additional text:
{
"year": <integer — year the article was published>,
"month": <integer — month the article was published (1–12)>,
"city": <string — name of the city that is the primary subject>,
"state": <string — two-letter US state abbreviation>,
"outcome": <one of exactly: "Contract Canceled", "Contract Rejected", or "Cameras Deactivated">,
"description": <string — 1–2 sentence summary of the outcome, including an HTML anchor tag linking to the article>
}
Outcome definitions:
- "Contract Canceled": an existing contract was terminated before its natural end
- "Contract Rejected": a proposed contract was not accepted initially, or an existing contract was not renewed
- "Cameras Deactivated": cameras were turned off or removed for any other reason
The description must include an <a> tag wrapping the key verb phrase that describes what happened — such as "canceled their contract", "voted not to renew", "terminated the agreement", etc. Format the tag exactly as:
<a href="ARTICLE_URL" target="_blank">VERB PHRASE</a>
Replace ARTICLE_URL with the actual URL provided and VERB PHRASE with the natural language action from the sentence. Do not add a separate "Read more" link.`;
const MONTH_NAMES = [
'', 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
async function fetchArticleText(url) {
const response = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' },
signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`Failed to fetch article: ${response.status} ${response.statusText}`);
const html = await response.text();
const $ = load(html);
$('script, style, nav, footer, header, aside').remove();
return $.text().replace(/\s+/g, ' ').trim();
}
async function extractFields(openai, articleText, url) {
const response = await openai.chat.completions.create({
model: MODEL,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Article URL: ${url}\n\nArticle text:\n${articleText.slice(0, 12000)}` },
],
});
return JSON.parse(response.choices[0].message.content);
}
function toCmsPayload(result) {
return {
cityState: `${result.city}, ${result.state}`,
monthYear: `${MONTH_NAMES[result.month]} ${result.year}`,
description: result.description,
outcome: result.outcome,
};
}
export default (router, { env }) => {
router.post('/', async (req, res) => {
if (!req.accountability?.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { url } = req.body ?? {};
if (!url || typeof url !== 'string') {
return res.status(400).json({ error: 'Request body must include a "url" string.' });
}
const apiKey = env['OPENAI_API_KEY'];
if (!apiKey) {
return res.status(500).json({ error: 'OPENAI_API_KEY is not configured on the server.' });
}
let articleText;
try {
articleText = await fetchArticleText(url);
} catch (err) {
return res.status(422).json({ error: `Failed to fetch article: ${err.message}` });
}
const openai = new OpenAI({ apiKey });
let extracted;
try {
extracted = await extractFields(openai, articleText, url);
} catch (err) {
return res.status(500).json({ error: `OpenAI extraction failed: ${err.message}` });
}
return res.json(toCmsPayload(extracted));
});
};
@@ -1,15 +0,0 @@
import { defineInterface } from '@directus/extensions-sdk';
import InterfaceComponent from './index.vue';
export default defineInterface({
id: 'win-importer',
name: 'Import Win',
icon: 'file_download',
description: 'Paste an article URL to auto-populate win fields via OpenAI.',
component: InterfaceComponent,
types: ['alias'],
localTypes: ['presentation'],
options: null,
hideLabel: true,
hideLoader: true,
});
@@ -1,82 +0,0 @@
<template>
<div class="import-win">
<v-input
v-model="articleUrl"
placeholder="https://example.com/article"
:disabled="loading"
type="url"
/>
<v-button
class="import-btn"
:loading="loading"
:disabled="!articleUrl"
@click="importWin"
>
Import
</v-button>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const emit = defineEmits(['setFieldValue']);
const api = useApi();
const articleUrl = ref('');
const loading = ref(false);
const errorMessage = ref('');
async function importWin() {
if (!articleUrl.value) return;
loading.value = true;
errorMessage.value = '';
try {
const { data } = await api.post('/import-win', { url: articleUrl.value });
console.log('[import-win] setting fields:', data);
const fields = [
{ field: 'cityState', value: data.cityState },
{ field: 'monthYear', value: data.monthYear },
{ field: 'description', value: data.description },
{ field: 'outcome', value: data.outcome },
];
for (const payload of fields) {
emit('setFieldValue', payload);
await nextTick();
}
articleUrl.value = '';
} catch (err) {
const message = err?.response?.data?.error ?? err?.message ?? 'An unknown error occurred.';
errorMessage.value = `Import failed: ${message}`;
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.import-win {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.import-btn {
flex-shrink: 0;
}
.error-message {
width: 100%;
color: var(--danger);
font-size: 13px;
margin: 4px 0 0;
}
</style>
+7 -7
View File
@@ -28,19 +28,19 @@ def fetch_alpr_surveillance_nodes(usOnly=True):
raise RuntimeError(f"Failed to fetch data from Overpass API. Status code: {response.status_code}")
def fetch_wins_count():
cms_url = "https://cms.deflock.me/items/flockWins?limit=-1"
ij_url = "https://ij.org/wp-json/ij/v1/alpr-cancellations"
headers = {'User-Agent': DF_USER_AGENT}
response = requests.get(cms_url, headers=headers)
response = requests.get(ij_url, headers=headers)
if response.status_code == 200:
response_json = response.json()
try:
return len(response_json['data'])
return len(response_json['incidents'])
except (KeyError, TypeError) as e:
raise RuntimeError("Could not find 'data' array in the response.")
raise RuntimeError("Could not find 'incidents' array in the response.")
else:
raise RuntimeError(f"Failed to fetch data from CMS. Status code: {response.status_code}")
raise RuntimeError(f"Failed to fetch data from IJ. Status code: {response.status_code}")
def lambda_handler(event, context):
us_alprs = None
+22 -25
View File
@@ -239,7 +239,12 @@
<p class="text-body-2 text-medium-emphasis mb-0">Communities across the country are rejecting ALPRs</p>
</div>
</div>
<p class="text-caption text-medium-emphasis mb-4">
Information sourced from the Institute for Justice's ALPR Contract Cancellation Database. Learn more at
<a href="https://ij.org" target="_blank" rel="noopener">www.ij.org</a>.
</p>
<v-data-table
:headers="headers"
:items="citiesRejectingFlock"
@@ -290,7 +295,8 @@
<template v-slot:expanded-row="{ columns, item }">
<tr>
<td :colspan="columns.length" class="pa-4">
<div v-html="item.description" class="text-body-1" style="line-height: 1.6;"></div>
<div v-html="item.description" class="text-body-1 mb-2" style="line-height: 1.6;"></div>
<a :href="item.sourceUrl" target="_blank" rel="noopener" class="text-body-2">Original source</a>
</td>
</tr>
</template>
@@ -331,10 +337,6 @@
</div>
</template>
</v-data-table>
<div v-if="lastUpdated" class="mt-4 text-caption text-medium-emphasis text-center">
Last updated: {{ formatDate(lastUpdated) }}
</div>
</v-card>
</div>
</v-col>
@@ -387,11 +389,11 @@ interface CityRejection {
monthYear: string;
description: string;
outcome: string;
sourceUrl: string;
}
// Reactive data for CMS content
// Reactive data for wins table
const citiesRejectingFlock = ref<CityRejection[]>([]);
const lastUpdated = ref<string>('');
const loading = ref<boolean>(false);
const tableRef = ref<HTMLElement | null>(null);
const hasLoaded = ref<boolean>(false);
@@ -421,16 +423,22 @@ const headers = [
}
];
// Fetch recent wins from CMS
// Fetch recent wins from the Institute for Justice's ALPR Contract Cancellation Database
const fetchRecentWins = async () => {
loading.value = true;
try {
const response = await fetch('https://cms.deflock.me/items/flockWins?limit=-1');
const result: CityRejection[] = (await response.json()).data;
const response = await fetch('https://ij.org/wp-json/ij/v1/alpr-cancellations');
const { incidents } = await response.json();
const sortedResult = result.sort((a, b) => sortMonthYearByDateDesc(a.monthYear, b.monthYear));
citiesRejectingFlock.value = sortedResult;
citiesRejectingFlock.value = incidents
.map((incident: any) => ({
cityState: `${incident.city_county}, ${incident.state.name}`,
monthYear: incident.incident_date_display,
outcome: incident.type.name,
description: incident.description,
sourceUrl: incident.source_url,
}))
.sort((a: CityRejection, b: CityRejection) => sortMonthYearByDateDesc(a.monthYear, b.monthYear));
} catch (error) {
console.error('Error fetching recent wins:', error);
// Fallback to empty array if fetch fails
@@ -483,17 +491,6 @@ onUnmounted(() => {
}
});
// Format date helper function
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const videos = [
{
location: "Flagstaff, AZ",