PRūFPRūF

Build a restaurant ingredient search.

A search for soybean oil can find an ingredient list that says “canola and/or soybean oil.” Keep that wording beside the match so readers can see the choice the source describes.

Run it on the free sample

Use Node.js 18 or later. Download the Baja Fresh JSON sample and ingredient-search.mjs into the same folder. This command searches the local file:

node ingredient-search.mjs --sample baja-fresh.json "soybean oil"

Each result carries the source ingredient text, an alternative-wording flag and the allergen disclosure status. A literal match can refer to a possible ingredient. A missing match can also come from incomplete text.

What this sample can tell you

Run the command without a search term to count the records with ingredient text, alternative wording, missing source links and null nutrition values:

node ingredient-search.mjs --sample baja-fresh.json

The denominator is 164 menu records in the September 17, 2026 sample. Portion sizes and menu variations can have separate rows, so these counts describe records. They do not estimate how much of an oil people eat or how often a restaurant uses it.

All records in this sample lack a stored source URL. That limits traceability even when ingredient text is present. Keep scraped_at, record_observed_at and nutrition_method alongside the data, and review source disclosures before using it for a decision about allergens.

The code flags alternative wording anywhere in a record; it does not assign that uncertainty to a specific oil. A parser that makes that stronger claim needs to identify which component and phrase modify the ingredient.

Use the same code with the paid API

Set PRUF_API_KEY in your server environment and choose a restaurant ID from /snapshots/latest. The script holds the snapshot ID fixed while reading every page. API keys belong on your server.

node ingredient-search.mjs --api RESTAURANT_ID "soybean oil"

Check the API documentation for limits and error responses. The free download works without an account; API requests require an active subscription.

Results from the September 17 sample

These counts come from the command above. You can reproduce them using the unchanged public download.

CheckRecords
Total records164
Missing ingredient text0
Alternative wording anywhere in the text1
Missing stored source URLs164
Null nutrition values0
Literal mentions of soybean oil146

A filled nutrition field does not prove that a restaurant disclosed that value. Check nutrition_method to distinguish a source value from a component sum. A soybean-oil text match counts a record, including menu variations; it does not identify a unique dish or its final frying oil.

The sample is available for noncommercial evaluation under the sample terms. Use an active API subscription for a commercial integration.

Source code

// Node.js 18+. Uses only the downloaded public sample unless --api is supplied.
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';

export function searchIngredients(items, term) {
  const needle = term.trim().toLowerCase();
  if (!needle) throw new Error('Supply a nonempty ingredient term');
  return items.flatMap(item => {
    if (typeof item.ingredients_text !== 'string' || !item.ingredients_text.trim()) return [];
    if (!item.ingredients_text.toLowerCase().includes(needle)) return [];
    return [{ item_id: item.item_id, item_name: item.item_name,
      match_basis: 'literal_text_mention',
      has_alternative_wording: /\band\/or\b|\bone or more\b|\bmay contain\b/i.test(item.ingredients_text),
      ingredients_text: item.ingredients_text,
      calories_kcal: item.nutrition?.calories_kcal ?? null,
      allergen_status: item.allergens?.status ?? 'source_not_disclosed' }];
  });
}

export function analyzeSample(items) {
  const disclosed = items.filter(item => typeof item.ingredients_text === 'string' && item.ingredients_text.trim());
  return {
    records: items.length,
    ingredient_text_missing: items.length - disclosed.length,
    records_with_alternative_wording: disclosed.filter(item => /\band\/or\b|\bone or more\b|\bmay contain\b/i.test(item.ingredients_text)).length,
    records_without_source_urls: items.filter(item => !item.provenance?.source_urls?.length).length,
    records_with_null_nutrition: items.filter(item => Object.values(item.nutrition ?? {}).some(value => value === null)).length,
    soybean_oil_text_mentions: searchIngredients(items, 'soybean oil').length,
  };
}

async function apiItems(restaurantId) {
  const key = process.env.PRUF_API_KEY;
  if (!key) throw new Error('Set PRUF_API_KEY in your server environment');
  async function get(path) {
    const response = await fetch('https://prufapp.com/api/data/v1' + path, {
      headers: { Authorization: `Bearer ${key}`, 'User-Agent': 'OpenAI File Downloader, XaiImageApiFetch/1.0' },
    });
    if (!response.ok) throw new Error(`API returned ${response.status}`);
    return response.json();
  }
  const release = await get('/snapshots/latest');
  if (!release.coverage.some(row => row.restaurant_id === restaurantId)) throw new Error('Restaurant is outside this release');
  const items = [];
  let cursor;
  do {
    const query = new URLSearchParams({ snapshot_id: release.snapshot_id, restaurant_id: restaurantId, limit: '100' });
    if (cursor) query.set('cursor', cursor);
    const page = await get('/items?' + query);
    items.push(...page.data);
    cursor = page.next_cursor;
  } while (cursor);
  return items;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const [mode, input, query] = process.argv.slice(2);
  if (!['--sample', '--api'].includes(mode) || !input) throw new Error('Usage: node ingredient-search.mjs --sample baja-fresh.json [term] OR --api RESTAURANT_ID [term]');
  const items = mode === '--api' ? await apiItems(input) : JSON.parse(await readFile(input, 'utf8'));
  if (!Array.isArray(items)) throw new Error('Expected an array of menu records');
  console.log(JSON.stringify(query ? searchIngredients(items, query) : analyzeSample(items), null, 2));
}