> SpecStream is the live web data engine for teams and agents. This is the Markdown copy of https://specstream.ai/blog/web-table-source-per-value. Every page, with what SpecStream does and costs: https://specstream.ai/llms.txt

# How to load a web table with a source on every value

SpecStream

[Guides](https://specstream.ai/blog.md) · Nov 12, 2026 · 8 min read

Store every value you pull from the web as three things: the value, the URL of the page it was read on, and the time it was read. Keep the three together through every step, and write them as columns in the export. Then anyone who doubts row 412 opens its page in one click, and anyone who reruns the job can tell a changed value from a stale one.

Below: the shape of one cell, a worked example on SEC Form D filings that you can run today, the places a hand built table breaks, and how to get the same table from one request.

## What the finished table looks like

These are six of the 45 US companies that filed a Form D for $5 million or more sold between October 13 and 16, 2026. Every value in a row was read from the filing it links to, so one link covers the row.

| Company | Industry | City | State | Amount sold | Source |
| --- | --- | --- | --- | --- | --- |
| Ramp Business Corp | Other Technology | New York | New York | $1,585,000,000 | [Form D](https://www.sec.gov/Archives/edgar/data/1803782/000180378226000003/primary_doc.xml) |
| Armadin, Inc. | Other Technology | Menlo Park | California | $264,999,494 | [Form D](https://www.sec.gov/Archives/edgar/data/2094470/000209447026000002/primary_doc.xml) |
| Onebrief, Inc. | Other Technology | Honolulu | Hawaii | $150,799,963 | [Form D](https://www.sec.gov/Archives/edgar/data/1888809/000188880926000002/primary_doc.xml) |
| PT&C Group Holdings, LLC | Other | Salt Lake City | Utah | $85,000,000 | [Form D](https://www.sec.gov/Archives/edgar/data/2159894/000215989426000001/primary_doc.xml) |
| Kaigene, Inc. | Biotechnology | Rockville | Maryland | $54,958,229 | [Form D](https://www.sec.gov/Archives/edgar/data/1919055/000191905526000004/primary_doc.xml) |
| NN INC | Manufacturing | Charlotte | North Carolina | $53,130,000 | [Form D](https://www.sec.gov/Archives/edgar/data/918541/000091854126000115/primary_doc.xml) |

The other 39 are in [the full week](https://specstream.ai/blog/form-d-filings-october-13-16-2026.md), and every Form D filer since January 2023 is on [one data page](https://specstream.ai/data/form-d).

When a row joins pages (the name from the company's site, the amount from a filing, the CEO from a press release), one link per row is no longer enough. Each value then needs its own.

## The shape of one cell

```json
{
  "value": "150799963",
  "source_url": "https://www.sec.gov/Archives/edgar/data/1888809/000188880926000002/primary_doc.xml",
  "read_at": "the UTC time your code read the page"
}
```

Four rules keep it useful:

-   **Record the time when you read, not when you export.** A value read on Monday and written on Friday is Monday's value.
-   **Keep the value as the page prints it.** Format it in the export, never in storage, so a reader can find it on the page.
-   **One source per value.** A row that joins three pages carries three URLs.
-   **Say why a cell is empty.** "The page doesn't print it" and "the page didn't load" lead to different next steps.

## A worked example: Form D filings in Python

Each Form D is an XML file on sec.gov, and every field sits at a fixed path. This script reads a list of filings, takes five values from each, and writes a CSV with a source and a read time beside every value. It needs nothing beyond the Python standard library.

```python
import csv
import time
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

FILINGS = [
    "https://www.sec.gov/Archives/edgar/data/1803782/000180378226000003/primary_doc.xml",
    "https://www.sec.gov/Archives/edgar/data/2094470/000209447026000002/primary_doc.xml",
    "https://www.sec.gov/Archives/edgar/data/1888809/000188880926000002/primary_doc.xml",
]
FIELDS = {
    "Company": "primaryIssuer/entityName",
    "City": "primaryIssuer/issuerAddress/city",
    "State": "primaryIssuer/issuerAddress/stateOrCountryDescription",
    "Industry": "offeringData/industryGroup/industryGroupType",
    "Amount sold": "offeringData/offeringSalesAmounts/totalAmountSold",
}
# The SEC asks every script to declare who it is: a name and an email.
HEADERS = {"User-Agent": "Your Company you@yourcompany.com"}


def read_filing(url):
    request = urllib.request.Request(url, headers=HEADERS)
    with urllib.request.urlopen(request) as reply:
        body = reply.read()
    read_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
    root = ET.fromstring(body)
    return {
        label: {"value": root.findtext(path), "source_url": url, "read_at": read_at}
        for label, path in FIELDS.items()
    }


rows = []
for url in FILINGS:
    rows.append(read_filing(url))
    time.sleep(0.2)  # the SEC allows at most 10 requests a second

with open("form_d.csv", "w", newline="") as f:
    out = csv.writer(f)
    out.writerow([name for label in FIELDS for name in (label, f"{label} source", f"{label} read at")])
    for row in rows:
        out.writerow([
            item for label in FIELDS
            for item in (row[label]["value"], row[label]["source_url"], row[label]["read_at"])
        ])
```

Two lines from the SEC's page on [accessing EDGAR data](https://www.sec.gov/search-filings/edgar-search-assistance/accessing-edgar-data) shape the script: "Please declare your user agent in request headers:" and "Current max request rate: 10 requests/second."

The source columns make the CSV three times as wide. Keep them: the export is the only copy most readers will see, so the sources travel in it.

## Where the hand built table breaks

This works because every Form D has the same fields at the same paths, and because you already had the list of filing URLs. Most tables have neither:

-   **Finding the pages is most of the job.** Someone has to decide which sites hold the rows and collect the URLs before any parsing starts.
-   **Every site prints the value differently.** Each new site is a new parser to write and to fix when the site changes.
-   **Rows join across sites.** Matching a company in a filing to the same company on its own site, without merging two companies that share a name, is its own project.
-   **Values change.** To keep the table up to date you rerun everything and compare it with the last run yourself.

When you hold the URLs but not a parser, the [Extract API](https://specstream.ai/extract-api.md) returns each page's main text, PDFs and Word files included, at $0.80 per 1,000 pages, and a page with no text is not charged. Record the URL and the time of the call beside each value, as above.

## The same table from one request

SpecStream is the live web data engine for teams and agents. With [Deep Search](https://specstream.ai/deep-search.md) you write the table you want in plain words, and you get every match on the live web as structured rows. Each value comes back with the URL of the page it was read on and the time it was read, and each condition of the request shows the page that states it.

Source URL and read time on every value.

With the [REST API](https://specstream.ai/docs/api.md), the request is read, priced and approved in three calls, and nothing is spent until the third. The key comes from Agents & API in your workspace and needs the `specs:write`, `finds:write` and `finds:spend` scopes.

```python
import csv, os, time, uuid
import requests

API = "https://api.specstream.ai"
KEY = {"Authorization": f"Bearer {os.environ['SPECSTREAM_API_KEY']}"}


def call(method, path, **kwargs):
    reply = requests.request(method, API + path, headers=KEY, **kwargs)
    reply.raise_for_status()
    return reply.json()["data"]


reading = call("POST", "/v1/parse", json={
    "q": "Every US company that filed a Form D this week reporting $5 million or more sold, "
         "with its city, state, industry, the amount and the filing"
})
print(reading["read_back"])  # how the request was understood

quote = call("POST", "/v1/capacity/quotes", json={"ast_hash": reading["ast_hash"], "requested_units": 100})
print("Most it can cost: $%.2f" % (quote["maximum_cents"] / 100))

run = call("POST", "/v1/capacity/executions", json={
    "quote_id": quote["quote_id"],
    "quote_hash": quote["quote_hash"],
    "confirm_max_cents": quote["maximum_cents"],
    "idempotency_key": str(uuid.uuid4()),  # keep it: a retry with the same key never buys twice
})
find_id = run["find_id"]

while call("GET", f"/v1/finds/{find_id}")["status"] in ("queued", "planning", "running"):
    time.sleep(30)

rows, cursor = [], None
while True:
    params = {"limit": 100}
    if cursor:
        params["cursor"] = cursor
    page = call("GET", f"/v1/finds/{find_id}/rows", params=params)
    rows += page["rows"]
    cursor = page.get("next_cursor")
    if not cursor:
        break

with open("form_d.csv", "w", newline="") as f:
    out = csv.writer(f)
    labels = [cell["label"] for cell in rows[0]["cells"]]
    out.writerow([name for label in labels for name in (label, f"{label} source", f"{label} read at")])
    for row in rows:
        line = []
        for cell in row["cells"]:
            seen = cell.get("provenance") or {}
            line += [cell.get("display") or cell.get("value"), seen.get("source_url"), seen.get("observed_at")]
        out.writerow(line)
```

Each cell's `provenance` holds the `source_url` and the `observed_at` time, the same three things as the hand built cell. To skip the CSV code, `GET /v1/finds/{id}/rows?format=csv` returns the columns you asked for as one table, with a source link for each value.

From Claude, Cursor or another MCP client, add `https://api.specstream.ai/mcp` as a server and ask for the table in the chat. [Setup for each client](https://specstream.ai/docs/mcp.md).

## What it costs

You see the most a run can cost before it starts, and nothing runs until you approve it. Each delivered row is 5¢, 15¢ or $1, depending on how much the row needs, and a row that carries a person's email adds 5¢, a phone 15¢. Duplicates and rows a page contradicts are not charged, and a run that finds nothing costs nothing. A saved request reruns hourly to yearly and labels every row changed, unchanged or no longer matching. New accounts start with $10 of credit, no card. [Prices](https://specstream.ai/pricing.md).

## Hand built or one request

|  | SpecStream Deep Search | Hand built script |
| --- | --- | --- |
| Finding the pages | Done by the run | You collect the URLs |
| Reading each site | Done by the run | One parser per site |
| Source on each value | Source URL and read time on every value | What your code records |
| Joining rows across sites | One record per match, from every page that holds its facts | Your matching code |
| Staying up to date | Reruns hourly to yearly, each change labeled | Rerun and compare yourself |
| Price | 5¢, 15¢ or $1 per delivered row, the maximum shown first | Your time, plus page reads |

[Describe the table you need](https://specstream.ai/deep-search.md) and see the price before anything runs.

## More from the blog

[Every post](https://specstream.ai/blog.md)

[### Competitor monitoring with MCP: a table your agent rechecks on a schedule Connect your agent to SpecStream's MCP server, describe the facts you track for each competitor, and turn the table into a monitor that rechecks every row and sends only what changed Nov 12, 2026 · SpecStream · 7 min read](https://specstream.ai/blog/competitor-monitoring-with-mcp.md)[### dzhng/deep-research without a Firecrawl key: set FIRECRAWL\_BASE\_URL dzhng's deep-research makes every web search through Firecrawl's v1 search Nov 12, 2026 · SpecStream · 4 min read](https://specstream.ai/blog/dzhng-deep-research-firecrawl-base-url.md)[### Exa Websets vs Clay in 2026: what 500 accounts cost on each, and the price per row Exa Websets checks companies against a query in plain words, and Clay buys each column from its providers Nov 12, 2026 · SpecStream · 8 min read](https://specstream.ai/blog/exa-websets-vs-clay.md)
