EU Supplier Concentration Intelligence
Under maintenancePricing
Pay per usage
EU Supplier Concentration Intelligence
Under maintenanceStructurally paired buyer and CPV-market supplier dependency metrics from official TED XML.
Turn official EU contract award notices into buyer-level and CPV-market supplier dependency metrics.
This Apify Actor searches the anonymous official TED Search API, downloads the official XML for each accepted source notice, and follows authoritative eForms references before aggregating an award. It produces stable group and supplier IDs, fractional consortium shares, Herfindahl–Hirschman Index (HHI), top-supplier dependency, single-supplier award rate, and conservative currency-specific value metrics.
What is safe to claim
TED Search fields such as buyer, winner, identifier, country, CPV, and value are flattened notice facets. Their array positions do not prove a lot-level relationship. This Actor therefore does not aggregate those arrays.
For supported eForms contract award XML, it resolves:
lot result → settled contract → winning tender → tendering party → tenderer organization
It separately proves the awarded lot and reads CPV from that lot. A winner/lot/CPV market observation is emitted only when every required reference resolves and the tender points to the same lot. There are no positional joins, inferred name-to-ID pairs, or notice-wide winner/CPV cross products.
Coverage is intentionally conservative:
- eForms
ContractAwardNoticeXML with resolvable result references is supported. - Legacy or otherwise unsupported XML increments
excluded.unsupportedXmland contributes no concentration record. - A notice whose official XML cannot be obtained as a usable document is excluded and counted, never aggregated from a guess. See Notice XML availability.
- Broken or incomplete award-reference paths increment
excluded.unpairableand are omitted. - Buyer aggregation requires exactly one root contracting organization that resolves to an XML organization. Market aggregation can still proceed when the buyer is unresolved.
- A market observation requires CPV on the structurally linked awarded lot. Notice-wide CPV is never substituted.
The structural model follows the official TED eForms schema usage and competition-results mapping. XML is retrieved through TED’s documented direct notice link: https://ted.europa.eu/en/notice/{publication-number}/xml.
Input
The example run input below is the Actor's prefilled default. It is a bounded, reproducible window that completes in seconds and emits a non-empty dataset:
{"mode": "full","groupBy": ["buyer", "market"],"buyerCountries": ["DEU"],"cpvPrefixes": ["48"],"publicationFrom": "2026-07-01","publicationTo": "2026-07-07","marketCpvDigits": 2,"maxItems": 100,"maxXmlDownloads": 60}
Aggregates are emitted only after the query window has been scanned to exhaustion, so
maxXmlDownloads must be at or above the notice count of the chosen window. A run that stops on
a cap is status: partial, emits nothing, and resumes from the exact source row on the next run.
A run also stops on a cap it is not given in the input: the scan has a three-minute wall-clock
budget, so a source answering too slowly to finish the window leaves the same replayable cursor
rather than running until the platform kills it.
| Field | Default | Meaning |
|---|---|---|
mode | incremental | full emits snapshots; incremental emits changes and removals; diff also emits unchanged active groups. |
groupBy | ["buyer","market"] | Build buyer records, structurally proven CPV-market records, or both. |
buyerKeywords | [] | OR match on the resolved buyer name. |
supplierKeywords | [] | OR match on structurally resolved winning organization names. |
cpvPrefixes | [] | OR match on CPV attached to the awarded lot. |
buyerCountries | [] | OR match on the resolved buyer country. |
supplierCountries | [] | OR match on resolved winning organization countries. |
publicationFrom, publicationTo | current day | Inclusive source publication dates. Omitting dates uses a safe one-day window. |
marketCpvDigits | 2 | CPV prefix width used to define a market. |
language | eng | Preferred organization-name language, then English, then deterministic fallback. |
maxItems | 1000 | Highest-award-count active groups. Removal tombstones may be added beyond this count. |
pageSize | 250 | TED iteration page size. |
maxPages | 100 | Maximum new source pages per run. |
maxScannedItems | 10000 | Maximum new source notices per run. |
maxXmlDownloads | 250 | Maximum official XML downloads per run. Must cover the whole window for aggregates to be emitted. |
overlapDays | 3 | Incremental watermark overlap. |
stateRetentionDays | 180 | Change-history retention. |
Filters are ANDed across categories and ORed within an array. Validated country, CPV, buyer-name, and winner-name selectors are pushed into TED expert search so selective runs do not first traverse the unfiltered source volume. The XML-resolved observation is filtered again locally before aggregation. A server-filtered query window above 4,000 notices is rejected; the safe default is one day, and explicit broad windows should be narrowed.
Output schema 1.1.0
The authoritative dataset schema is .actor/dataset_schema.json. One active record represents a buyer or a proven CPV market for the complete observation window.
Important fields:
recordId: stable logical group ID.contentDigest: compact digest used by change modes.groupType,groupKey,groupLabel: buyer or market identity.observationWindow: inclusive source publication window.awardCount,supplierCount,supplierShares: structurally paired award totals and shares.concentration.hhi: award-share HHI on the 0–10,000 scale.concentration.topSupplierDependency: largest unrounded supplier share, rounded only for output.valueMetrics: positive, usable tender values by currency; no FX conversion.sourceNoticeIds: contributing TED publication numbers.associationWarnings: explicit coverage omissions.changeType:snapshot,new,updated,unchanged, orremoved.
Legal-entity identity
The XML organization reference deterministically aligns the organization’s name, country, and legal identifier.
- Legal identifiers are normalized with their scheme and registration-country context, deduplicated, sorted, and retained as a set. The same unambiguous set with a changed name remains one entity.
- Conflicting identifiers in the same scheme/country context are preserved but never selected as identity; that organization remains notice-scoped.
- Different unambiguous identifier sets remain different entities even when their names are identical.
- When no legal identifier exists, the key is scoped to the source notice and XML organization reference. Names alone are never used to merge legal entities across notices.
Identifiers are hashed in public record keys; the Actor does not expose raw identifiers as if they were globally standardized.
Published text
Names, identifiers, and labels are emitted as the characters TED published. The five entity
references XML predefines (&, <, >, ", ') and numeric character
references (ö, 😀) are resolved once at extraction, so
Computacenter AG & Co. oHGsupplierKeywords, and hashed as Computacenter AG & Co. oHG.
Resolution is deliberately narrow, and it is not entity expansion:
- Only those five names are resolved. Any other entity name can only be declared in a DTD, and a
notice carrying a
DOCTYPEis refused before it is parsed, so an unknown name such as&xxe;is left as literal text rather than looked up. - A character reference that does not denote a character XML permits — NUL, a C0 control, a lone surrogate, a code point past the last one — is left as written rather than decoded.
- Each reference is replaced exactly once, left to right, and the replacement is never rescanned.
&lt;therefore yields the text<and can never cascade into markup.
recordId, groupKey, contentDigest, and every supplier ID are derived from this text, so the
run summary reports the identityVersion they were built under. Contract 2 is the one described
here; contract 1, written by any build before it, left the references literal.
Award and value allocation
Each structurally linked winning tender is one award observation. A single winner receives weight 1; a tendering party with n winning organizations gives each 1/n.
A positive tender PayableAmount is used only when the structurally linked winning tender has exactly one winning organization. Consortium values are omitted because TED does not state how to divide the tender value among members. Missing, invalid, negative, or zero totals also produce no value share and increment coverage counters where applicable. Currencies remain separate. Zero totals cannot produce NaN or infinity.
HHI is calculated from full-precision shares and rounded only after the sum of squares:
HHI = sum(raw supplier share²) × 10,000
Risk labels are screening aids:
- fewer than three awards:
insufficient-data; - HHI above 2,500 or top share at least 50%:
high; - HHI at least 1,500:
moderate; - otherwise:
low.
Full, incremental, diff, and removals
fullemits the selected active records assnapshotand seeds durable digest history for all computed groups, including active groups outsidemaxItems.incrementalemitsnew,updated, andremovedrecords.diffalso emitsunchangedselected active records.- If a previously emitted group disappears or falls outside the current top N, a
removedtombstone is emitted with zero counts and empty supplier/source arrays. Its stablerecordIdidentifies the record to delete downstream. - If an unselected group later enters the top N, it is
newfrom the downstream consumer’s perspective.
When stored state was written under an older identityVersion, the run reports
identityMigrated: true and handles the two kinds of stored state differently. A partial resume
cursor is discarded and its window rescanned: half of it would describe organizations under one
text contract and half under the other, which would split or merge suppliers inside a single
record. Completed digests are kept, because a digest is a hash filed under a recordId rather than
a partial aggregate. A record whose identity is unchanged — every CPV market, and every buyer with
a stable legal identifier — keeps its correct unchanged/updated classification, while a group
whose key did change is retired through the ordinary removal path as a removed tombstone and its
successor arrives as new.
Only a completed source scan emits aggregates. maxPages, maxScannedItems, maxXmlDownloads, and timeBudget produce a replayable partial run. maxItems is applied after a complete aggregation and produces status: partial with stopReason: maxItems while preserving complete digest history.
timeBudget is the wall clock rather than a count. Every other cap bounds something the source
hands over, and none of them bounds how long the source takes to hand it over: a window of a few
dozen notices is a few counted downloads whether the source answers each in a fifth of a second or
in twenty. The scan therefore stops after three minutes, which leaves the one source request still
in flight — a search page or a notice download, never both — and then the records, OUTPUT, and
the durable snapshot inside the five minutes an Actor run is judged against. A run given a shorter platform timeout (ACTOR_TIMEOUT_AT) stops earlier still,
keeping back what it needs to finish writing what it already has.
Notice XML availability
https://ted.europa.eu/en/notice/{publication-number}/xml is a public, throttled edge endpoint. A
bounded run must tolerate the responses it actually returns, so each response is classified before
it is aggregated:
A notice is excluded only on evidence TED itself returned about that notice. A condition that says nothing about the notice — a security invariant, or a transport failure that never delivered an answer — fails the run closed instead, because an unanswered request is an outage rather than an absent notice.
| Source response | Handling |
|---|---|
200 with an XML document | Parsed. An XML declaration is optional; a bare root element, a namespace-prefixed root, a root name using any legal XML characters including non-ASCII ones, a generic root name, and a document led by comments or processing instructions are all accepted as XML. |
200 with an HTML, empty, or other non-XML body | Retried with backoff. If it never becomes XML, the notice is excluded as excluded.malformedXml. A body counts as an edge page when it says so: an HTML doctype, a root the document itself places in the XHTML namespace, an undecorated html root, or an HTML Content-Type on a body TED did not introduce with an XML declaration. An html root carrying a business namespace (<x:html xmlns:x="urn:business"/>) is ordinary XML and reaches the parser. |
404 or 410 (including TED's <ErrorResponse> body) | Excluded immediately as excluded.unavailableXml; not retried. |
202 | The source accepted the request and stated the notice is not ready, so the payload is never notice content and is discarded unread. Retried against a two-second deferral floor and then excluded as excluded.throttledXml — never as a malformed body. TED's CDN also answers an edge challenge this way, typically an empty body under an HTML content type. |
429 or 5xx | Retried with backoff and Retry-After, from a rotated address, widening request spacing. If the budget is exhausted, the notice is excluded as excluded.throttledXml. TED's XML edge admits a short burst per address and then answers 429 until it drains, which it does in well under a second, so request spacing has a floor below that burst and recovers as fast as it widens. |
Other 4xx | Excluded as excluded.unavailableXml; not retried. |
| Body above the 5 MB cap | Streaming stops and the notice is excluded as excluded.oversizeXml. |
Valid XML that is not supported eForms, such as legacy TED_EXPORT | Excluded as excluded.unsupportedXml. |
| A DNS, TLS, connection, timeout, abort, or truncated-body failure | Retried to the bounded budget, then the run fails closed. No answer was received, so the notice is never written off as unreadable. |
A 3xx redirect, or a non-ted.europa.eu HTTPS XML URL | The run fails closed. These are integrity invariants, not source variance. |
Exclusions are per notice: one unreadable notice can never collapse a run. They stop being treated as coverage once they become systemic — if at least three XML downloads have been attempted in a run and more than half of them failed, the run fails closed rather than publishing an aggregate that silently under-counts its window. That ratio needs a sample, so a run in which every attempted download failed also fails closed regardless of how few notices it attempted: a one- or two-notice window with nothing readable in it is an outage, never a successful empty result.
Every run summary reports xmlFailures, the per-reason counters above, and xmlFailureSamples, a
bounded sample of the excluded notices with the source-side reason for each.
Reliability and bounded state
- TED Search and XML downloads use bounded retries, exponential backoff,
Retry-Afterin both the delta-seconds and HTTP-date forms RFC 9110 allows, a 30-second request timeout, adaptive request spacing that widens under throttling, trusted HTTPS XML URLs, and a 5 MB XML cap. - Retries are bounded by time as well as by count, on both source paths. One notice may spend twenty seconds across all of its attempts, and a search ladder may spend only what the run has left; no attempt is started, and no backoff waited out, past the deadline the run is working to — a request timeout is shortened to whatever remains. A retry budget counts answers, and a source can answer every one of them slowly; the clock is the bound that a count cannot express. A search that runs out of the run's time stops the scan on the page it could not fetch and leaves that page as the resume cursor, rather than failing a run whose earlier pages are already durable.
- Obtaining and retiring an egress address is charged to the same budget the download is. Composing a proxy URL is a network call and retiring a tunnel tears down a live socket, neither with an abort of its own, so both are abandoned on the deadline rather than allowed to hold a bounded run open. An abandoned operation is left with its rejection handled, so a late failure quoting the proxy URL can neither crash the run nor print the account's password.
- A notice whose budget runs out having never received an answer fails the run closed, exactly as an exhausted retry count does. Running out of time is never converted into evidence about a notice that was never read.
- Notice XML downloads egress through Apify's shared datacenter proxy, obtained from the Apify SDK. The tunnel is held while it is serving notice content and rotated the moment it is not, so a deferral, a rate limit, or an edge page is retried from a different address instead of from the one that produced it, while a run that finds a working address keeps it for the notices that follow. No session identifier is ever sent, since a sticky session would pin a run to the address being challenged. TED's edge challenges an egress address for a whole run at a time, which rotation is what escapes.
- The proxy is applied narrowly and never widens what the Actor trusts. Only the allowlisted
ted.europa.eunotice XML endpoint is proxied — TED Search always uses direct egress — and the URL is proven to be TED's own before any tunnel is opened. Only a URL Apify Proxy itself composed is dialled — its public endpoint, or the host the platform configured for the run — so an arbitrary proxy URL cannot be supplied through the input, a residential identity is refused, and URL validation, redirect refusal, and the bounded retry budget are unchanged. The proxy URL carries the account's proxy password in its userinfo and is never logged, returned, or placed in an error message. Where Apify Proxy is unavailable, such as a local run, the Actor logs the loss of rotation and continues over direct egress. - Pagination uses TED iteration tokens. Expired tokens replay from the start with a bounded duplicate ledger; conflicting source identities fail closed.
- Fully handled pages are checkpointed before later source work. Aggregates are emitted only after source exhaustion.
- The durable state lease is renewed on a cadence throughout the run, including inside a single page of notice downloads, so a scan that takes longer than the lock still holds it when it writes. The lease is separately re-proved immediately before
OUTPUTand before the durable snapshot, which is the boundary that fences a concurrent run. - A slow scan reports its progress — counters and elapsed time only, never a URL or anything the source returned — so a run that is taking too long can be diagnosed from its log rather than from its duration.
- Durable state is capped at 3.5 MB, six configurations, 4,000 source notices per window, 2,000 groups, and 500 suppliers per group.
- Resumes expire after seven days or twenty attempts so abandoned cursors cannot occupy state forever.
- A named request-queue lease prevents overlapping writers, and each state write is an immutable snapshot whose generation is the lock expiry the platform itself issued — that expiry and nothing else. A successor is granted the lock only after the incumbent's has expired, and every grant is stamped from one server clock, so a successor's generation always exceeds every generation the incumbent held. A writer suspended past its own expiry can therefore still land a snapshot, but never one that outranks the successor's, whatever order the two writes arrive in. Local expiry checks fence a stale owner before writes, and a prolongation that reported an earlier expiry than one already held would fail the run closed rather than be written through.
- Dataset output is written before
OUTPUT, and the final durable state snapshot is written last. A failed final state write may replay stable-ID rows but cannot silently advance state past missing output.
Run locally
npm cinpm run lintnpm run typechecknpm run buildnpm testnpm run smoke:localLIVE_SMOKE=1 npm run smoke:livenpm audit --omit=devnpm auditnpm run validate:schema
The live smoke executes the real runActor path against TED Search and official notice XML: once
with a three-notice cap, and once with the documented example run input, which must emit a
non-empty dataset inside five minutes.
Data source and reuse
- Source: TED
- Search API: official TED Search API documentation
- Field reference: TED Search field list
- XML direct links: TED notice-view help
- Reuse terms: TED legal notice
TED is the source of the notices. This Actor’s metrics and risk labels are derived analytics, not official EU assessments or legal advice.