---
title: "Recommendations for publishers | Snowplow Signals"
description: "Snowplow Signals tells your recommender what this reader read, finished and skipped this visit, so it ranks a next story they want to open."
source: https://signals.snowplow.io/solutions/media/recommendations
---

[Media and publishing](https://signals.snowplow.io/solutions/media) / Recommendations 

# A rail that knows what this reader skipped. 

Signals tells your recommender what this reader has read, finished and skipped since they arrived, and what they come back for, so it can pick a next story they want.

[Start free](https://signals.snowplow.io/start) [Talk to us](https://signals.snowplow.io/start?intent=talk) 

More pages per visit

## Keep readers on the site by showing a next story they want. 

Pages per visit move when the rail changes with the visit. Signals gives your recommender what this reader has read, finished and skipped since they arrived, and the section they come back for over recent weeks, so it can rank the next link for the reader on the page instead of the average one.

Four ways publishers use it

01 Readers who keep skipping one story Shown the same story four times, never opened. Drop it. 02 Readers deep in one section Third politics story in a row, rail still shows the site mix. Weight politics. 03 Readers who finish what they open Read the long feature to the end, left the next one in seconds. Rank on the finish. 04 Returning readers with a habit Reads sport most mornings, landed on a business story. Keep one sport slot. 

[Read the docs →](https://docs.snowplow.io/docs/signals/introduction/) 

### Drop the story a reader has passed over this visit

A reader is on their fourth article since they arrived, and the same story has sat in the rail under every one of them. They have not opened it. The model learns about the skip after this reader has moved on, so the rail keeps offering it. Four shows without a click is an answer, and the slot could hold something they have not been offered yet.

What Signals knows

`rail_impressions_session` 

How many times each story was shown in the rail this visit

`rail_clicked_session` 

The stories opened from the rail this visit

What your recommender does

Removes any story shown three times this visit without a click from the candidate set, and fills the slot with one this reader has not been offered.

Define it · PythonUse it · TypeScript

article.tsx · 13 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, Event, EventProperty, StreamAttributeGroup, domain_sessionid

rec_shown = Event(name="recommendation_shown", vendor="com.example", version="1-0-0")
rec_clicked = Event(name="recommendation_clicked", vendor="com.example", version="1-0-0")

# Keyed on the visit, so no rolling window; a ttl lets the row expire after it.
rail_impressions_session = Attribute(
    name="rail_impressions_session",
    type="dict",
    events=[rec_shown],
    aggregation="category_count",
    property=EventProperty(vendor="com.example", name="recommendation_shown", major_version=1, path="article_id"),
    ttl=timedelta(hours=6),
)

rail_clicked_session = Attribute(
    name="rail_clicked_session",
    type="string_list",
    events=[rec_clicked],
    aggregation="unique_list",
    property=EventProperty(vendor="com.example", name="recommendation_clicked", major_version=1, path="article_id"),
    ttl=timedelta(hours=6),
)

recs_skipped = StreamAttributeGroup(
    name="recs_skipped",
    version=1,
    attribute_key=domain_sessionid,
    owner="you@example.com",
    attributes=[rail_impressions_session, rail_clicked_session],
)
```

```
// In the code that renders the article page
const reader = await signals.getServiceAttributes({
  name: "recs_skipped",
  attribute_key: "domain_sessionid",
  identifier: sessionId,
});
// { rail_impressions_session: { c04: 4, f17: 2, a81: 1 }, rail_clicked_session: ["a81"] }

const skipped = Object.entries(reader.rail_impressions_session)
  .filter(([id, shows]) => shows >= 3 && !reader.rail_clicked_session.includes(id))
  .map(([id]) => id);

recommender.rank({ exclude: skipped });
```

### Weight the section a reader is working through

A reader has opened three politics stories in a row since they arrived, and the rail under the third still carries the site-wide mix of culture, sport and a recipe. The ranker's picture of this reader comes from earlier visits, and this one reaches it after they have gone. Three stories in one section is a reading session with a subject, and the rail can follow it while it is happening.

What Signals knows

`sections_session` 

How many articles they read in each section this visit

`articles_session` 

Articles opened this visit

What your recommender does

Boosts the section once a reader has opened three stories in it this visit, so most of the rail comes from that section and one slot stays open for the rest of the site.

Define it · PythonUse it · TypeScript

article.tsx · 13 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, EntityProperty, Event, StreamAttributeGroup, domain_sessionid

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")

sections_session = Attribute(
    name="sections_session",
    type="dict",
    events=[page_view],
    aggregation="category_count",
    property=EntityProperty(vendor="com.example", name="article", major_version=1, path="section"),
    ttl=timedelta(hours=6),
)

articles_session = Attribute(
    name="articles_session",
    type="int32",
    events=[page_view],
    aggregation="counter",
    default_value=0,
    ttl=timedelta(hours=6),
)

recs_section = StreamAttributeGroup(
    name="recs_section",
    version=1,
    attribute_key=domain_sessionid,
    owner="you@example.com",
    attributes=[sections_session, articles_session],
)
```

```
// In the code that renders the article page
const reader = await signals.getServiceAttributes({
  name: "recs_section",
  attribute_key: "domain_sessionid",
  identifier: sessionId,
});
// { sections_session: { politics: 3 }, articles_session: 3 }

const [section, count] = Object.entries(reader.sections_session).sort((a, b) => b[1] - a[1])[0] ?? ["", 0];

if (count >= 3) {
  recommender.rank({ boostSection: section, keepOpenSlots: 1 });
}
```

### Weigh a finished read more than a glance

This reader opened a long feature on housing and read it to the end, then opened a headline from the rail and left within a few seconds. In the page views, both count as a read on that subject, and how far the reader got arrives after the rail for the next page has been built. Reading to the end says more about what this reader wants than a bounce does, and the rail can rank on the difference.

What Signals knows

`finished_session` 

The stories read to the end this visit

`bounced_session` 

The stories left in the first few paragraphs this visit

What your recommender does

Ranks the next stories by similarity to what this reader finished, and pulls down anything close to what they bounced from.

Define it · PythonUse it · TypeScript

article.tsx · 14 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import (
    Attribute, Criteria, Criterion, EntityProperty, Event, EventProperty, StreamAttributeGroup, domain_sessionid,
)

article_read = Event(name="article_read", vendor="com.example", version="1-0-0")
article_id = EntityProperty(vendor="com.example", name="article", major_version=1, path="id")
percent_read = EventProperty(vendor="com.example", name="article_read", major_version=1, path="percent_read")

finished_session = Attribute(
    name="finished_session",
    type="string_list",
    events=[article_read],
    aggregation="unique_list",
    property=article_id,
    criteria=Criteria(all=[Criterion.gte(percent_read, 80)]),
    ttl=timedelta(hours=6),
)

bounced_session = Attribute(
    name="bounced_session",
    type="string_list",
    events=[article_read],
    aggregation="unique_list",
    property=article_id,
    criteria=Criteria(all=[Criterion.lt(percent_read, 20)]),
    ttl=timedelta(hours=6),
)

recs_finished = StreamAttributeGroup(
    name="recs_finished",
    version=1,
    attribute_key=domain_sessionid,
    owner="you@example.com",
    attributes=[finished_session, bounced_session],
)
```

```
// In the code that renders the article page
const reader = await signals.getServiceAttributes({
  name: "recs_finished",
  attribute_key: "domain_sessionid",
  identifier: sessionId,
});
// { finished_session: ["housing-long-read"], bounced_session: ["c04"] }

if (reader.finished_session.length > 0) {
  recommender.rank({
    similarTo: reader.finished_session,
    downweight: reader.bounced_session,
  });
}
```

### Keep one slot for the section a reader comes back for

A reader has come to the site most mornings this month, nearly always for sport, and today a shared link landed them on a business story. A rail ranked against the article in front of them fills with more business, which is not why this reader keeps coming back. The habit is in their last month of visits, and the rail can hold a place for it even when the article is off-topic.

What Signals knows

`habit_section_30d` 

The section they read most in the last 30 days

`visits_30d` 

Separate visits in the last 30 days

What your recommender does

Reserves one slot in the rail for the section a returning reader reads most, whenever the article they are on is from somewhere else.

Define it · PythonUse it · TypeScript

article.tsx · 11 lines Copy Copied 

```
from datetime import timedelta
from snowplow_signals import Attribute, AtomicProperty, EntityProperty, Event, StreamAttributeGroup, domain_userid

page_view = Event(name="page_view", vendor="com.snowplowanalytics.snowplow", version="1-0-0")

habit_section_30d = Attribute(
    name="habit_section_30d",
    type="string",
    events=[page_view],
    aggregation="most_frequent",
    property=EntityProperty(vendor="com.example", name="article", major_version=1, path="section"),
    period=timedelta(days=30),
)

visits_30d = Attribute(
    name="visits_30d",
    type="int32",
    events=[page_view],
    aggregation="approx_count_distinct",
    property=AtomicProperty(name="domain_sessionid"),
    period=timedelta(days=30),
)

recs_habit = StreamAttributeGroup(
    name="recs_habit",
    version=1,
    attribute_key=domain_userid,
    owner="you@example.com",
    attributes=[habit_section_30d, visits_30d],
)
```

```
// In the code that renders the article page
const reader = await signals.getServiceAttributes({
  name: "recs_habit",
  attribute_key: "domain_userid",
  identifier: domainUserId,
});
// { habit_section_30d: "sport", visits_30d: 14 }

if (reader.visits_30d >= 5 && reader.habit_section_30d !== article.section) {
  recommender.rank({ reserve: { slots: 1, section: reader.habit_section_30d } });
}
```

## Try it on your own site. 

Add the SDK, define one thing you want to know about a visitor, and watch it update while you click around. The free tier runs on the real engine: no card, no sales call.

[Start free](https://signals.snowplow.io/start) [Talk to an engineer](https://signals.snowplow.io/start?intent=engineer)
