mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-10-19 21:27:19 +02:00
Use IJ data for wins list (#157)
This commit is contained in:
1 parent
6b1b022646
commit
5e342d1302
9 files changed
+31
-6488
No files matched your search
+1
-2
@@ -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
|
||||
@@ -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}
|
||||
-6205
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user