# Changelog of Tech Stack & Buying Signal Enrichment (`flux_pull/tech-stack-buying-signal-enrichment`) Actor

- **URL**: https://apify.com/flux\_pull/tech-stack-buying-signal-enrichment/changelog.md
- **Full Actor documentation**: https://apify.com/flux\_pull/tech-stack-buying-signal-enrichment.md

## Changelog

### Unreleased

### 0.1.1 — 2026-09-16

Two fixes to change detection, both found on run `S78X1s8E7ZGloH8Q2`: three domains that
returned six dataset rows and $0.21 of changes that nobody had made.

#### One row per domain

- **`vendor_change` records now go to a named dataset of their own**, not into the run's
  dataset. The new `changesDatasetName` input names it (`tech-stack-changes` by default) and
  it is your **change feed**: the Actor appends to the same store on every run, so every
  vendor move this account has detected sits in one place instead of one fragment per run.
  It is created the first time a change is confirmed, and the run's `OUTPUT` reports its
  name, id and how many records that run added, under `changesDataset`.
- **The run's own dataset is now exactly one row per domain you asked for.** Two row shapes
  in one dataset read as duplicates in the Console table, and the "Vendor changes" view
  listed every result row with its change columns empty, because a dataset view can select
  fields but cannot filter by row type.
- **The "Vendor changes" view now reads the result row's own comparison**, so every row in it
  is a domain with its changes filled in: added, removed, upgraded, what is still being
  confirmed, and any suppression reason. The "dataset view shows empty change columns"
  limitation is gone.
- **New flat `changesAdded`, `changesRemoved`, `changesUpgraded` and `changesPending`
  columns** alongside the nested `changes` block, for the two surfaces that cannot read
  nested JSON: a CSV export and the Console table. Present only on a row that has a
  comparison, so a blank column means "first scan", never "wrong row type".
- Billing is unchanged: one `vendor_change` per changed domain, under the same charge limit.

#### A change has to happen twice

- **New `changeConfirmScans` input (default 2).** A technology must be seen in its new state
  this many consecutive scans before it counts as a change and is billed. The first sighting
  is a candidate: it is reported on the row under `changes.pending`, it does not move
  `changed` or `changeCount`, and it costs nothing. The next scan either confirms it — one
  record, one charge — or contradicts it, and it is dropped without ever being reported.
- This catches the one kind of noise no other rule can, because the detections are all
  *true*: ad-heavy sites load a different subset of their ad tags on every visit, so
  AppNexus, Tapad, Hotjar, Cxense, Rubicon Project, Facebook Pixel, DoubleClick Floodlight
  and Datadog genuinely come and go between two scans minutes apart. Persistence separates
  that from a migration; confidence, category and scan quality cannot.
- Rows carry `changes.pending` (the names waiting) and `changes.confirmScans` (the threshold
  used), so a customer can see a move being considered rather than wondering where it went.
- `changeConfirmScans: 1` restores the previous behaviour exactly — billed on the first scan
  that sees a difference.
- **A scan that could not see the page no longer rewrites your history.** On a partial or
  no-response scan an absent technology neither opens nor advances a removal candidate, and
  stays in the stored set — so the healthy scan that follows no longer reports the whole site
  as freshly installed. The "multi-homed sites can still produce an occasional change"
  limitation is gone with it.
- Snapshots written before this release load unchanged and start with nothing pending, so
  the first scan after the upgrade opens candidates rather than billing them. Each tier keeps
  its own candidates, like its own history.

### 0.1.0 — 2026-09-15

First release.

#### Technology detection

- Scan any list of domains or URLs and get the full technology stack behind each one: CMS,
  ecommerce platform, payment processors, analytics, tag managers, CDN, hosting, applicant
  tracking system, email provider, security headers, JavaScript frameworks, live chat, A/B
  testing, CRM and cookie-compliance tooling.
- Detection runs against a fingerprint database of 7,613 technologies across 109
  categories, pinned to an exact upstream commit and recorded on every output row so a
  result can be reproduced later.
- Signals come from HTML, response headers, cookies, meta tags, script URLs, `robots.txt`
  and `sitemap.xml` (optional), DNS records and the TLS certificate. DNS and TLS lookups
  cost no bandwidth and are on by default.
- Every detection carries a confidence score and the evidence behind it: which field
  matched, the pattern that fired and the text it matched.
- DNS output includes MX, NS, CNAME and A records, SPF `include:` targets, third-party TXT
  verification records, the inferred email provider and whether DMARC is published.
- Flat `cms`, `ecommerce`, `emailProvider`, `ats` and `techCount` columns alongside the
  full nested output, so CSV and spreadsheet exports are usable as-is.
- `minConfidence: 51` is a one-flag precision mode: it drops every detection that rests
  only on the inline-JavaScript heuristic, which is capped at confidence 50.
- The inline-JavaScript heuristic ignores generic identifiers. A bare root has to be
  namespaced (`Shopify.theme`), a compound name (`SHOPIFY_API_BASE_URL`), or at least six
  characters and not an everyday word, so ordinary source text no longer reports CmonSite
  for `baseUrl`, Catch for `catch`, List.js for `list` or Spin.js for `spinner`.
- `categoriesFilter` narrows what is reported without narrowing what is detected, so
  implied technologies stay correct.

#### JavaScript rendering (Tier 2)

- `renderJs: true` renders each page in headless Chromium and adds evidence no static fetch
  can produce: real JavaScript globals, DOM selectors evaluated by a browser CSS engine,
  the hosts the page calls over XHR, scripts injected after load, and stylesheet text.
- Tier 2 merges with Tier 1 rather than replacing it, so a render never loses evidence the
  static fetch already had.
- Images, media and fonts are blocked at the network layer, keeping a render to roughly
  1.6–3.4 MB per page.
- Rendering requires at least 2048 MB of run memory. Below that the run stays on Tier 1,
  rows are stamped `renderSkipped`, and nothing extra is charged.
- `renderTimeoutSecs` (default 25 s) is the browser's own budget for one page — loading it
  *and* reading it — kept separate from `timeoutSecs`, which bounds a single HTTP request.
  The whole render is held to it: a page that pins its main thread after loading costs one
  domain instead of stalling every domain queued behind it.
- `renderBlockResources` chooses what the browser refuses to download: `default` (images,
  media and fonts), `aggressive` (stylesheets too, at the cost of CSS-based detections) or
  `none`.
- A render that completes but finds nothing a fingerprint can read — no JavaScript global,
  no matched DOM selector, no XHR host, no stylesheet text — leaves the row on Tier 1 with
  `renderSkipped: "no_signals"` and charges no `domain_rendered`. The row would have been
  identical to the Tier 1 one.
- A render whose in-page probe ran out of budget is stamped `renderTruncated: true`, so a
  partial read is never mistaken for an absent technology.
- The browser now presents the same Chrome identity the Tier 1 fetch does, instead of
  announcing itself as `HeadlessChrome` with `navigator.webdriver` set — some sites serve
  different markup to the two, and it was the *paid* tier getting the bot treatment.
- A crashed Chromium is recovered from in every case, including one that dies during
  start-up; previously such a browser could be handed out for the rest of the run.

#### Change detection

- Every scan is compared with your previous scan of the same domain and a `vendor_change`
  record is written when the stack has moved, listing what was added, removed and upgraded.
- Scan history is stored in a named key-value store on your own Apify account, retained for
  180 days, and deletable at any time with `purgeSnapshots: true`.
- Suppression rules keep monitoring quiet and cheap: first scans never count, a stricter
  confidence floor applies to changes than to output, partial scans never report removals,
  scans that got no response never report removals, an identical change set is suppressed
  for 24 hours, an exact reversal of the previous change is suppressed as a flap, and
  page characteristics such as HTTP/3 or Open Graph never count as vendor changes.
- Scan history is kept **separately per tier**: a rendered scan is compared only with your
  previous rendered scan, and a Tier-1 scan only with your previous Tier-1 one. A browser
  sees tools a static fetch cannot, so mixing the two would have billed the change of
  instrument as a change of stack. Both series share one bucket record, so a mixed-tier
  schedule costs no extra storage calls, and `purgeSnapshots: true` clears both.
- Certificate authorities — DigiCert, Let's Encrypt, Sectigo and the rest of the
  "SSL/TLS certificate authorities" category — stay on the technology list but never count
  as a vendor change. Which authority answers depends on the edge node and the renewal
  cycle, not on a decision anyone made. In the same spirit, a TLS-issuer or DNS move never
  bills on its own, and the DNS fingerprint now covers MX and NS only: A and CNAME records
  differ per edge node and are left out of change detection entirely.

#### Standby API

- The Actor runs as a warm HTTP service as well as a batch job: `GET /?domain=…` for a
  single domain, `POST /` for up to 25, `GET /health` for readiness and counters, and
  `GET /openapi.json` for the full contract.
- A warm Tier 1 request answers in well under a second. Chromium is loaded lazily, so a
  container that only ever serves Tier 1 never starts a browser.

#### Pricing

- Pay-per-event pricing: `domain_scanned`, `domain_failed`, `domain_rendered` and
  `vendor_change`, plus the platform's per-run start event. No subscription.
- Proxy bandwidth is included in the event price and is never billed to you on top of it.
  Residential proxies are not supported — one rendered page costs more in residential
  transfer than the render earns — and a run configured with the `RESIDENTIAL` group stops
  in its first second with that explanation rather than scanning and charging.
- A 4xx or 5xx response is a result, not a failure — those rows still carry technologies
  from headers, DNS and TLS.
- A render that fails, times out or finds nothing is never charged.
- The run summary's `chargedTotals` now matches the invoice. It previously reported twice
  the real `domain_scanned` count, because the platform reports two charges for the one it
  makes on an atomic write-and-charge; the bill itself was always correct.
- `dryRun: true` computes everything and charges nothing, so a large run can be priced
  before it is paid for.
