# Tender & Procurement Feed Normalizer (`ultrathink-labs/tender-feed-normalizer`) Actor

Normalize CSV/JSON tender records, flag ambiguous values and duplicates, preserve source data, and sort clean rows by deadline.

- **URL**: https://apify.com/ultrathink-labs/tender-feed-normalizer.md
- **Developed by:** [Ian G](https://apify.com/ultrathink-labs) (community)
- **Categories:** Business, Lead generation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 normalized tenders

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Tender & Procurement Feed Normalizer

Normalize tender records into a clean, deadline-sorted dataset. Each clean row preserves its complete original source fields. The `REPORT` key-value record retains every rejected row, original input, and rejection reason. Possible duplicates are retained in the report rather than the clean dataset.

Provide `records`, or a public HTTPS `sourceUrl` serving UTF-8 CSV/JSON (an `{"records": [...]}` JSON wrapper is unwrapped). URLs must use port 443 and cannot redirect, carry credentials, or resolve to private addresses. Maximum download: 5 MiB; maximum input: 10,000 rows. XML is unsupported.

```json
{"records":[{"title":"Road maintenance","deadline":"2027-01-15","value":"USD 180,000"}],"currency":"USD"}
```

Live Actor build is **0.2.2** (deployed 2026-09-21), running this source tree (`normalizerVersion` **0.2.0** in REPORT).

### Dates

Use ISO dates to avoid day/month ambiguity. Supported: ISO `YYYY-MM-DD` with optional time, fractional seconds and offset (`2027-01-15`, `2027-01-15T12:30:45.123Z`, `...+02:00`); `%d/%m/%Y`, `%m/%d/%Y`, `%d.%m.%Y`, `15 January 2027`. Ambiguous slash dates (`01/02/2027`) are rejected. Dates without times mean midnight UTC; `expired` compares against run time.

All-digit values follow an explicit grammar: exactly 8 digits is a compact `YYYYMMDD` date (`20270115`); up to 10 digits is unix seconds; exactly 13 digits is unix milliseconds. 11–12 digits is rejected as ambiguous rather than guessed.

### Money

Amounts accept unsigned decimals with up to two decimal places, comma thousands grouping, or consistently grouped European notation. Negative values, ranges, malformed grouping, and ambiguous dot grouping are rejected. Missing values are allowed; missing titles or deadlines are reported.

Currencies come from ISO codes in the value (`USD 100`, `100USD`), symbols (`$`, `€`, `R$`), or an explicit currency column (`currency`, or a `fieldMap` target). Codes are matched on letter boundaries so `USDTOKENS` is not read as USD; symbols match case-insensitively, so a lowercased `r$` is still BRL. `RMB` is canonicalized to `CNY` so the two spellings agree. Symbols `$` and `¥` assume USD/JPY — declare explicit codes for other dollar/yen currencies. A declared currency that conflicts with the value's own currency is rejected (`currency_conflict`); nothing is inferred and no conversion is performed. The optional `currency` input must be a three-letter ISO code and flags rows whose currency differs (`currency_mismatch_expected`).

### Duplicates and conflicts

Two rows are the same notice only when normalized **title, deadline, buyer and reference** all match. A different buyer or reference always survives — two authorities can run identically titled tenders on the same day. An exact repeat is reported `duplicate`; the same identity with a different stated value or currency is reported `conflict` and never silently merged. A missing amount or currency is a wildcard: an enriched copy is a duplicate, not a conflict. Only earlier clean rows can suppress later copies, so an invalid row cannot hide its corrected copy.

### Errors and REPORT

Every issue carries a human message plus a stable machine `errorCode`: `bad_record`, `missing_title`, `bad_title`, `missing_deadline`, `bad_deadline`, `bad_value`, `bad_currency`, `currency_conflict`, `currency_mismatch_expected`, `duplicate`, `conflict`, `withheld_billing`. REPORT includes `normalizerVersion`, row counts (`rowsClean` counts delivered rows; `rowsPassed` counts every row that passed validation, including billing-withheld ones), `duplicates`, `conflicts`, `errorCounts` (issue occurrences per code — one row can carry several codes, so counts can sum higher than `rowsWithIssues`), and every failed row with full provenance.

Dataset rows preserve `sourceFields` verbatim — including text a spreadsheet would interpret as a formula. Treat spreadsheet exports as untrusted input; the raw provenance is deliberate and is never silently sanitized.

### fieldMap

Maps your column names to standard fields (`title`, `deadline`, `value`, `buyer`, `reference`, `currency`). An explicit mapping beats the built-in aliases for that field: if you map a column to `reference`, an `id` column no longer outranks it. Sources match case/separator-insensitively, exactly like the aliases. Mappings with unmatched sources or non-standard targets are inert — but mapping a column does not remove its alias role for other fields, so one column can feed two fields; check `mappedFrom`. `mappedFrom` records the source column per field.

### Offline demo and tests

```sh
python scripts/run_tender_demo.py    # golden fixtures: messy CSV + fieldMap, and direct JSON
python scripts/verify_all.py         # unit suites plus demo, exits non-zero on failure
python -m unittest discover -q       # unit suites only
```

`examples/tender_feed/` has the fixture pack (CSV, JSON, field map, golden outputs); `examples/apify_input/` has paste-ready fictional Actor inputs. Everything is fictional and offline.

### Use as an integration (dataset input)

Point this Actor at another Actor's output instead of pasting records: set `datasetId`, or trigger it from another Actor's **Integrations** tab — the platform then supplies `payload.resource.defaultDatasetId` automatically. Only Apify platform datasets are fetched (alphanumeric IDs, at most 10,000 rows, batched reads) — never arbitrary URLs or caller credentials. Example static input for the integration form:

```json
{"datasetId": "{{resource.defaultDatasetId}}", "currency": "USD"}
```

This is the pipeline pattern: any tender/procurement scraper Actor produces raw rows; this Actor turns them into an auditable clean table.

### Cost math (published pricing)

$0.00005 per run start plus $0.001 per delivered clean row; rejected rows are never charged. Examples: 100 clean rows = $0.10005; 1,000 clean rows = $1.00005. A run's maximum charge is bounded by its `maxTotalChargeUsd`; rows beyond the budget are withheld (never charged) and listed in REPORT with `withheld_billing`. Raw scraper Actors in the Store charge several times more per raw row; this Actor's job is to make those rows usable. Prices are configured in the Apify Console and may change there; check the Store page for the current values.

### Operator billing hook (disabled by default)

Configure a positive-price pay-per-event event named `normalized-tender`, remove `apify-default-dataset-item` from that Actor's pricing, and require a positive finite run spending limit before setting `NORMALIZER_ENABLE_BILLING=1`. An optional platform start event is separate; its already-charged cost is included in the SDK's remaining budget. Each delivered clean row uses the SDK's charged `push_data`; rejected rows do not generate this custom event. Withheld rows retain only row index, raw source data, and reason in REPORT, not their normalized output. Missing/invalid pricing, non-PPE mode, duplicate synthetic dataset charging, invalid/unbounded spending limits, and mismatched charge acknowledgements fail the run. No prices or remote settings are configured by this source tree. See https://docs.apify.com/sdk/python/reference/class/Actor#push\_data.

Billing writes are not a transaction: SDK 2.7.3 persists dataset rows before calling the charge API. A charge transport failure makes the run fail; do not automatically retry failed paid runs without reconciliation. Cloud owner-run checks verified clean-row event counts, rejected-row handling, and spending-limit enforcement. They do not demonstrate an external customer payment.

No scraping, revenue, accuracy, or publication approval guarantees. Actor pricing and platform charges depend on the published Console configuration.

# Actor input Schema

## `sourceUrl` (type: `string`):

Optional public HTTPS CSV or JSON feed, maximum 5 MiB; no redirects, XML, or private-network hosts. Provide this or records.

## `records` (type: `array`):

Optional. Paste raw records directly instead of fetching a URL. Prefilled sample is fictional and does not represent a procurement opportunity.

## `fieldMap` (type: `object`):

Optional. Map your column names onto the standard ones (title, deadline, value, buyer, reference, currency), e.g. {"Notice Title": "title", "Closes": "deadline"}. An explicit mapping beats the built-in aliases for that field. Unmapped fields are kept as-is.

## `currency` (type: `string`):

Three-letter ISO code. Rows whose currency differs are flagged. Leave blank to skip the check.

## `datasetId` (type: `string`):

Optional. Apify dataset ID to read records from - used automatically when another Actor hands over its output dataset (payload.resource.defaultDatasetId). Provide records, sourceUrl, or a dataset reference.

## Actor input object example

```json
{
  "records": [
    {
      "title": "Example tender (fictional)",
      "deadline": "2027-12-31",
      "buyer": "Example buyer",
      "value": "USD 12000"
    }
  ]
}
```

# Actor output Schema

## `tenders` (type: `string`):

Clean records sorted by deadline, with complete source fields and provenance.

## `report` (type: `string`):

Input counts, duplicate indices, and complete rejected rows with reasons.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "records": [
        {
            "title": "Example tender (fictional)",
            "deadline": "2027-12-31",
            "buyer": "Example buyer",
            "value": "USD 12000"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("ultrathink-labs/tender-feed-normalizer").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "records": [{
            "title": "Example tender (fictional)",
            "deadline": "2027-12-31",
            "buyer": "Example buyer",
            "value": "USD 12000",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("ultrathink-labs/tender-feed-normalizer").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "records": [
    {
      "title": "Example tender (fictional)",
      "deadline": "2027-12-31",
      "buyer": "Example buyer",
      "value": "USD 12000"
    }
  ]
}' |
apify call ultrathink-labs/tender-feed-normalizer --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ultrathink-labs/tender-feed-normalizer"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/fu978jaBsGjwaKeUE/builds/DlnLMuh3elp1pRD0k/openapi.json
