# Open Graph & Meta Tags API - Link Preview, OG Image (`stas_k/open-graph-meta-tags-api`) Actor

Link previews come back blank: no title, no image, or a relative /og.png nothing can render. Send a list of pages and get title, description, Open Graph and Twitter tags with every URL absolute, plus JSON-LD types, favicon and a preview verdict. Broken or blocked pages cost nothing.

- **URL**: https://apify.com/stas_k/open-graph-meta-tags-api.md
- **Developed by:** [Stas K](https://apify.com/stas_k) (community)
- **Categories:** SEO tools, Developer tools
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 metadata extracteds

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

## Open Graph & Meta Tags API - Link Preview, OG Image

A link preview that shows nothing is almost always one of three things: the page has no
Open Graph block, its `og:image` is `/img/cover.png` instead of a full URL, or the page is
rendered in the browser and ships an empty HTML shell. This actor reads the page the way a
preview bot does and returns what a preview needs, with every URL already absolute, so the
row can be rendered as it is.

### What you get for each page

- `title` - `og:title` when declared, otherwise `<title>`, otherwise `twitter:title`;
- `description` - `og:description`, otherwise `<meta name="description">`, otherwise the Twitter one;
- `openGraph` - title, description, image, imageAlt, type, siteName, url, locale, video;
- `twitterCard` - card, title, description, image, site;
- `canonical`, `lang`, and `charset` (the encoding the page was really decoded with);
- `icons` - favicon, apple-touch-icon and manifest, absolute;
- `jsonLd` - how many `application/ld+json` blocks the page carries and their `@type` values,
  including `@graph` children;
- `hreflang` - language alternates, absolute;
- `preview` - `ready` plus the exact `issues`: `no_title`, `no_description`, `no_image`,
  `no_open_graph`, `image_was_relative`, `title_longer_than_60`, `description_longer_than_160`.

`image_was_relative` is the useful one: it means the value on the page was relative and this
actor resolved it, which is exactly the bug that makes a preview blank.

### Input

```json
{
  "urls": ["https://github.com/apify", "theverge.com", "https://www.wikipedia.org/"],
  "maxUrls": 100,
  "timeoutSecs": 20,
  "maxRedirects": 5,
  "includeJsonLd": true
}
```

`urls` takes a list or one string with newlines. `maxUrls: 0` means no limit.

### Output

One row per input line, in input order:

```json
{
  "index": 1,
  "source": "https://github.com/apify",
  "status": "EXTRACTED",
  "title": "Apify: Full-stack platform for web scraping, data extraction and AI agents",
  "description": "...",
  "canonical": "https://apify.com/",
  "lang": "en",
  "charset": "utf-8",
  "openGraph": {"image": "https://apify.com/img/og-image.png", "siteName": "Apify", "type": "website"},
  "twitterCard": {"card": "summary_large_image"},
  "icons": {"favicon": "https://apify.com/favicon.ico"},
  "jsonLd": {"count": 2, "types": ["Organization", "WebSite"]},
  "preview": {"ready": true, "issues": []},
  "billable": true
}
```

### Price

You pay **$0.002 per `metadata-extracted` event**, that is per page whose metadata was
delivered. Nothing else is charged by this actor:

- a dead link, a 404, a 403, a TLS failure, a timeout and a non-HTML body cost nothing;
- a redirect that was not followed (`REDIRECT`: `maxRedirects` is 0, or the answer names no
  `Location`) costs nothing - the redirect itself is the answer, not a page;
- a page that opened but has no metadata at all (`NO_METADATA`, a browser-rendered shell)
  costs nothing - there was no data to sell;
- the same page sent twice is delivered once and the repeat is free.

Apify also bills its own default dataset item event (about $0.00001 per row) and its actor
start event, as it does for every actor on the platform.

**Spending cap.** If the buyer does not set one, the platform applies its own default (about
$4.63 on our runs, `isMaxTotalChargeUsdSetByUser=false`). At $0.002 per page that is roughly
2300 pages. For a longer list, raise *Max total charge* before the run; pages that the cap cut
come back as `NOT_CHECKED_CAP` with the reason, never as silent gaps.

### Statuses

| status | meaning | charged |
|---|---|---|
| `EXTRACTED` | metadata delivered | yes |
| `NO_METADATA` | page opened, nothing to show (browser-rendered) | no |
| `HTTP_ERROR` | 4xx or 5xx answer | no |
| `NOT_HTML` | the link returned JSON, an image, a PDF, a file | no |
| `REDIRECT` | a 3xx that was not followed: `maxRedirects` is 0, or the answer names no target | no |
| `UNREACHABLE` | DNS, TLS, timeout, connection, redirect loop | no |
| `INVALID_URL` | not an http(s) address | no |
| `PRIVATE_TARGET` | the host resolves to a private or loopback address | no |
| `NOT_CHECKED_LIMIT` | beyond `maxUrls` | no |
| `NOT_CHECKED_CAP` | the spending cap stopped the run | no |
| `NOT_CHECKED_BILLING` | two billing failures stopped the run | no |
| `NOT_CHECKED_ERROR` | the dataset refused the row | no |

### Run summary

`requested`, `processed`, `delivered`, `rowsDelivered`, `notChecked`, `chargedEvents`,
`deliveredUncharged`, `billingActive`, `byStatus`, `duplicatesSkipped`, `cutByMaxUrls`,
`maxUrls`, `metadataMissing`, `stopReason`, `chargeErrors`, `datasetItemCount`,
`datasetItemCountApi`, `datasetItemCountLag`.

It is written as `RUN_SUMMARY` in the named store `open-graph-meta-tags-summary` and also as
`RUN_SUMMARY-<runId>`, because a named store is shared by the whole account: two runs at the
same time would otherwise overwrite each other's summary.

### What this actor does not do

- **It does not run JavaScript.** A page whose metadata is injected in the browser comes back
  `NO_METADATA` and costs nothing. Use a browser-rendering actor when you need those.
- **It does not check that the image really loads, or its size.** It returns the URL the page
  declares, made absolute.
- **It does not probe `/favicon.ico`.** If the page declares no icon, `icons.favicon` is null.
  Guessing would be an invention, not a fact.
- **No oEmbed, no Twitter/Telegram API calls.** The values are read from the page itself.
- **No retries.** One request per page. A 429 or a 503 is reported as `HTTP_ERROR` with its code
  and costs nothing. Retrying would slow every list down for a rare case.
- **No proxy rotation and no browser headers beyond a normal User-Agent.** Sites that block
  datacenter addresses answer 403, and that row is free.
  Wikipedia is one of them: from a datacenter address `en.wikipedia.org` answers 403, so that
  row comes back `HTTP_ERROR` and costs nothing.
- **No cookies, no logins, no authenticated pages.**
- **No crawling.** It reads exactly the addresses you send; a sitemap crawler is a different actor.
- **No cache between runs.** Every page is read live, so the answer is the page as it is now.
- **At most 4 MB of a page** is downloaded; the metadata lives in `<head>`, so a bigger body is
  cut, not failed.
- JSON-LD is capped at 50 blocks and 60 types, hreflang at 50 entries.

### Requests and safety

Redirects are followed in code, one hop at a time, and every hop is resolved and checked
against private, loopback and link-local ranges before the next request. A redirect into a
private address is blocked with `PRIVATE_TARGET`. Only `http` and `https` are fetched.
A 3xx that is not followed is reported as `REDIRECT` with its own status code: it is never
parsed as a page and never charged.

# Actor input Schema

## `urls` (type: `array`):

One row per page. A page that answers 403 or 404, a domain that does not resolve, a non-HTML body and a page with no metadata at all cost nothing. The same page sent twice is delivered free.

## `maxUrls` (type: `integer`):

How many pages to read. 0 or less means no limit. Pages beyond the limit come back as rows with the status NOT_CHECKED_LIMIT naming the limit, so nothing disappears silently.

## `timeoutSecs` (type: `number`):

How long one page may take before it is reported as UNREACHABLE with the reason timeout. A timeout costs nothing.

## `maxRedirects` (type: `integer`):

Redirects are followed by hand so every hop is checked against private addresses. 0 means do not follow at all: the answer comes back as a REDIRECT row with its own status code and costs nothing.

## `includeJsonLd` (type: `boolean`):

Count the application/ld+json blocks and list their @type values, including @graph children. Turn it off to keep rows smaller; a page whose only metadata was JSON-LD then comes back NO_METADATA and costs nothing.

## `userAgent` (type: `string`):

Send your own User-Agent when a site answers differently to a browser one. Empty means the actor's default browser string.

## Actor input object example

```json
{
  "urls": [
    "https://apify.com/",
    "https://github.com/apify"
  ],
  "maxUrls": 100,
  "timeoutSecs": 20,
  "maxRedirects": 5,
  "includeJsonLd": true,
  "userAgent": ""
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per input line, in input order: index (so nothing is lost), source, url, status, reason, statusCode, finalUrl, redirectCount, responseTimeMs, title, description, canonical, lang, charset, openGraph (title, description, image, imageAlt, type, siteName, url, locale, video, all URLs absolute), twitterCard, icons (favicon, appleTouchIcon, manifest), jsonLd (count and types), hreflang, preview (ready plus issues: no_title, no_description, no_image, no_open_graph, image_was_relative, title_longer_than_60, description_longer_than_160), metaCount, billable, and duplicateOf or billingInactive when the row was delivered free. Statuses: EXTRACTED means metadata was delivered and charged. NO_METADATA means the page opened but carries no title, description, Open Graph or JSON-LD, or its only metadata was JSON-LD and includeJsonLd is false: it is an answer and costs nothing. UNREACHABLE, HTTP_ERROR, NOT_HTML, INVALID_URL, PRIVATE_TARGET, REDIRECT and ERROR mean no page was read, nothing was charged and the reason says why; REDIRECT is a 3xx answer that was not followed because maxRedirects is 0 or the answer names no Location. NOT_CHECKED_LIMIT, NOT_CHECKED_CAP, NOT_CHECKED_BILLING and NOT_CHECKED_ERROR mean the page was not read because of maxUrls, the spending cap, billing, or a refused dataset write. Only EXTRACTED rows are charged; money itself is reported in the run summary.

## `summary` (type: `string`):

Stored in the named key-value store open-graph-meta-tags-summary as RUN_SUMMARY and also as RUN_SUMMARY-<runId>, because one named store is shared by the whole account and two runs at once would overwrite each other. Keys: requested, processed, delivered, rowsDelivered, notChecked, chargedEvents, deliveredUncharged, billingActive, byStatus, duplicatesSkipped, maxUrls, cutByMaxUrls, metadataMissing, stopReason, chargeErrors and the dataset counters. requested always equals processed plus notChecked. deliveredUncharged counts every row delivered without a charge: the charge call failed, the platform charged nothing, or the run already knew it could not bill.

# 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 = {
    "urls": [
        "https://apify.com/",
        "https://github.com/apify"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("stas_k/open-graph-meta-tags-api").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 = { "urls": [
        "https://apify.com/",
        "https://github.com/apify",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("stas_k/open-graph-meta-tags-api").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 '{
  "urls": [
    "https://apify.com/",
    "https://github.com/apify"
  ]
}' |
apify call stas_k/open-graph-meta-tags-api --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,stas_k/open-graph-meta-tags-api"
        }
    }
}
```

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/YhGUIieC7iUf7Y9xw/builds/VfngP9xGS6prBADWe/openapi.json
