

# How to Keep Comparable Bing Keyword Rank Snapshots

Save each Bing rank with the same query, country, language, settings, result cap, and observation time. A rank is the position number in that collected sample. A smaller number means the result came earlier. A result cap is the most results kept for one query. Compare only snapshots that match on all of those. A snapshot is one saved run. If your page is missing from a capped sample, record that it was not seen in that sample. Do not invent a rank. Do not say the page is absent from all of Bing.

## What you are saving

An Actor is a hosted program that collects data. This method uses the [Google Search Scraper](https://apify.com/chronometrica/google-search-scraper) Actor, build 1.0.15. A Task is a saved input preset. The current [Keyword Search Rank Snapshot](https://apify.com/chronometrica/google-search-scraper/examples/keyword-rank-snapshot) Task is only a starting input. It does not analyze ranks by itself.

The Actor saves source rows. You compare those rows with the steps below.

Build 1.0.15 can take Google, Bing, Brave, and DuckDuckGo in the engines list. This method accepts Bing only. Keep Google, Brave, and DuckDuckGo out of this ledger. A held Google path returned redirect addresses that hide the final site. Those addresses are not accepted destination URLs.

A ledger is your table of snapshots. This ledger is not Google Search Console average position. It is not the rank every person sees.

## Set the Bing input

The current [Keyword Search Rank Snapshot](https://apify.com/chronometrica/google-search-scraper/examples/keyword-rank-snapshot) Task saves this input:

```json
{
  "queries": ["best CRM", "small business accounting software", "AI marketing tools"],
  "engines": ["google", "bing", "brave", "duckduckgo"],
  "maxResultsPerExecutedQuery": 20,
  "country": "US",
  "language": "en",
  "safeSearch": true,
  "includeSponsoredResults": false
}
```

Set the Bing input

Replace the whole queries list. Replace the whole engines list. For the first check, use one intended query. Set engines to ["bing"]. Set maxResultsPerExecutedQuery to 2. Set country and language to the pair you will repeat. Keep safeSearch true. Keep includeSponsoredResults false.

This input has no expansion setting. It has no public page-limit field. The result cap is maxResultsPerExecutedQuery. Do not add a page-cap field.

A requested country does not remove personalization. Personalization means Bing can still show different results to different people. The country does not certify every local user's result.

Use this shape for the first check. Put your real phrase in queries:

```json
{
  "queries": ["one intended phrase"],
  "engines": ["bing"],
  "maxResultsPerExecutedQuery": 2,
  "country": "US",
  "language": "en",
  "safeSearch": true,
  "includeSponsoredResults": false
}
```

Set the Bing input

Change country and language only when the new pair is the pair you will keep. Write the same values on every later snapshot.

Fix these choices before the first comparison:

- the exact query text
- country
- language
- safeSearch
- includeSponsoredResults
- maxResultsPerExecutedQuery

A later change to the cap can change the apparent order. A later change in how results are read can do that too. Keep the collection scope similar when you compare.

## Read each result row

One row is one observed result for one engine, one query, one results page, and one position. The Actor can keep many queries in one run. It does not collapse them into one row per website. For this method, the run should contain one Bing query.

These are the exact row fields from the [Google Search Scraper](https://apify.com/chronometrica/google-search-scraper) Actor:

- runId
- country
- language
- safeSearch
- engine
- executedQuery
- page
- rankOnPage
- rank
- title
- canonicalUrl
- resultDomain
- snippet
- isSponsored
- advertiserDomain
- observedAt
- schemaVersion

rank is the position in the collected sample for that exact run. rankOnPage is the position on that results page. page is the results page number.

canonicalUrl is the address string the Actor stored after it cleaned the link. It is a candidate address. It is not a checked HTML rel=canonical link. It is not a perfect copy of the original href. Keep the returned string unchanged. That string is your source evidence.

snippet is text from the search result. It is not the article text from the destination site. It does not prove a claim on that site.

isSponsored tells you whether the row is a paid result. An organic result is an unpaid result. Keep paid and unpaid rows separate. With includeSponsoredResults set to false, do not treat a sponsored row as the organic rank.

advertiserDomain can appear on a sponsored row. It is not a second organic rank.

country, language, and safeSearch on the row are the requested context. They are not a promise about every personalized result.

The Actor does not open the destination site. It does not remove all extra page text. It does not check citations. It does not score sales. It does not schedule the next run. If you need a destination check, you do that check yourself.

## Read the run summary

OUTPUT is the run summary. It is separate from each result row. Read both. OUTPUT uses these fields:

- queriesRequested
- searchesCompiled
- executionsPlanned
- enginesSuccessful
- enginesFailed
- pagesAttempted
- pagesParsed
- resultsSaved
- engineStatusCounts
- engineResultCounts
- queryResultCounts
- stopReason

A current Bing control on this Actor used the query Python asyncio documentation. The Actor attempted one results page and parsed that page. It saved two rows. Both rows were direct docs.python.org URLs. engine was bing. executedQuery was that exact phrase. page was 1. One row had rank 1. The other row had rank 2. Each row had observedAt.

The control OUTPUT was:

```json
{
  "queriesRequested": 1,
  "searchesCompiled": 1,
  "executionsPlanned": 1,
  "enginesSuccessful": 1,
  "enginesFailed": 0,
  "pagesAttempted": 1,
  "pagesParsed": 1,
  "resultsSaved": 2,
  "engineStatusCounts": {"bing": {"ok": 1}},
  "engineResultCounts": {"bing": 2},
  "queryResultCounts": {"Python asyncio documentation": 2},
  "stopReason": "result_caps_reached"
}
```

Read the run summary

The known control row values are:

| Field | Control value |
| --- | --- |
| engine | bing |
| executedQuery | Python asyncio documentation |
| page | 1 |
| rank | 1 and 2 |
| canonicalUrl | two direct docs.python.org URLs |
| observedAt | present on each row |

stopReason value result_caps_reached means the run hit the result cap. It does not mean Bing has only two results.

This control checks a bounded Bing sample. It checks the URL, the rank, and the saved query, results page, and time. It does not test Google, Brave, or DuckDuckGo. It does not test every search operator. It does not test every country. It does not repeat the rank later. It does not measure a rank change. It does not measure traffic. It does not extract the destination site.

If enginesFailed is not zero, do not call the target absent.  A failed query has no absent-target state.

An empty sample is also not full Bing coverage. Capped, failed, and empty outcomes only describe that run.

## Compare snapshots in eight steps

### 1. Choose the target and a short fixed query list.

Decide the target before you look at movement. An exact-page target is one declared address and path. A domain target is the best accepted page on one host. Say which choice the decision uses. A short fixed list means you repeat the same phrases. The first check still uses one phrase and Bing only. Add another phrase only as its own fixed row, with the same settings.

A bounded Bing ledger does not report Google Search Console average position. It does not report every person's rank.

### 2. Lock Bing and the sample settings.

Replace the Task engine list with engines ["bing"]. Replace the sample queries with one intended query for the first check. Set maxResultsPerExecutedQuery to 2 for that pilot. Fix country, language, safeSearch, includeSponsoredResults, and the result cap.

The same words in a different country are a different snapshot. Safe search on and safe search off are different snapshots. A cap of 2 and a cap of 10 are different snapshots. A requested country still does not certify every local user result.

### 3. Inspect the successful rows and OUTPUT.

Keep executedQuery, rank, rankOnPage, page, and observedAt from each successful row. Keep the OUTPUT counts and stopReason beside them. The small current control has two ranked direct URLs on results page 1. That control is not a measured change for your page.

If the query failed, do not assign an absent state to the target. Save source_failed instead.

### 4. Match the target on purpose.

For an exact page, compare your declared address and path rule. Preserve the returned canonicalUrl. Do not change the returned string.

For a domain, match the intended host. Among that host's accepted organic rows, keep the smallest rank. That smallest rank is the best observed domain rank in the sample.

Do not merge a similar company name. Do not merge a different subdomain. Do not merge a tracking redirect unless you check that redirect and accept the destination. If the identity is unclear, the state is unresolved_target.

### 5. Store one valid state. Never store a fake rank.

Use observed_rank when the target matches a row. A useful sample is a finished successful run with its cap recorded. Use not_seen_within_cap when that sample does not contain the target. Use source_failed when collection failed. Use unresolved_target when you cannot tell whether a row is the target.

Do not use 0. Do not use cap + 1. Those numbers look like ranks, but they are not observed ranks.

With the pilot cap of 2, a missing target is not_seen_within_cap for cap 2. The page might appear later. The true rank stays unknown. The same rule applies to any larger cap you fix later.

### 6. Repeat the same input later.

Run the fixed input again when you choose to spend that budget. Save the new snapshot as a [new observation](/en/articles/web-result-dedup/). Do not overwrite the old one.

The Task does not watch ranks by itself. A schedule is a separate step you set up on purpose. Compare the same query and the same target rule. Use the same country, language, and safeSearch value. Use the same sponsored setting and a similar collection scope.

If the cap changed, do not call the pair comparable. If the way results are read changed, do not call the pair comparable.

### 7. Compute movement only for two known ranks.

Movement uses plain subtraction. Movement = old rank - new rank. A positive result means the new rank number is smaller. The result sits earlier in the new sample than in the old sample.

Keep not_seen_within_cap out of that subtraction. Keep source_failed and unresolved_target out too. Do not average unknown values with real ranks. Do not average stand-in numbers such as 0 or cap + 1.

A change in this sampled order does not prove a traffic change. It does not prove a sales change. It does not prove that a content edit caused the change.

### 8. Deliver the ledger and stop at a known comparison.

Make one ledger row per query and target rule. Include the source run, the context, the cap, the state, and a comparison note. Keep Google and any other engine in a separate place. Do not claim them here.

End with one matching pair, or write that the comparison is unresolved. Do not fill the gap with a guessed rank.

## A fictional rank ledger

The table below is original fiction. It teaches the states. It is not a result from the Actor. It is not the Bing control above. The fictional target page is https://garden.example/guide. The fictional domain is garden.example. S1 is the older snapshot. S2 is the newer snapshot. Both Q1 snapshots use the same Bing settings and the same top-ten cap. The pilot cap of 2 is a different scope. Do not mix these caps. No dates are supplied, so none are shown.

Q1 compares the exact guide page. S1 observes rank 7. S2 observes rank 4. Both ranks are inside equal top-ten samples. Movement = 7 - 4 = +3. Under this definition, the page moved three positions toward the start of the sample.

Q2 uses the same exact page and the same cap of 10. S1 observes rank 2. S2 is not_seen_within_cap for cap 10. The new rank is unknown. The movement is unknown. Do not store 11. The page might rank later than 10. You do not know the later position.

Q3 uses the same exact page. The S1 rank and time were not supplied, so S1 stays unknown. S2 is source_failed. The comparison is unavailable. This is not a ranking loss. Failure means the new sample was not collected.

Q4 is one domain snapshot, not a movement pair. Two accepted pages on the domain have organic ranks 3 and 8. If the target is the domain, the best observed rank is 3. If the target is the exact guide page, that page can be rank 8. Another page on the domain can be rank 3 at the same time. Say which definition you use. Do not report 3 as the exact page rank.

| Query | Target rule | S1 state | S2 state | Movement | Safe conclusion |
| --- | --- | --- | --- | --- | --- |
| Q1 | Exact page https://garden.example/guide | observed_rank 7 | observed_rank 4 | 7 - 4 = +3 | Known pair inside equal top-ten samples |
| Q2 | Exact page, cap 10 | observed_rank 2 | not_seen_within_cap 10 | unknown | Not rank 11, and not movement 2 - 11 |
| Q3 | Exact page | unknown | source_failed | unknown | Comparison unavailable, not a ranking loss |
| Q4 | Domain garden.example, or the exact guide page | One snapshot: accepted ranks 3 and 8 | Same snapshot, not a second time | none | Domain minimum is 3. Exact guide page can be 8 |

Someone might invent a Q2 rank of 11. They might then compute 2 - 11 = -9. That -9 is not an observation. Averaging Q1's +3 with that fake -9 produces a false summary. Show the known pair first. Show the sample-bound absence. Show the source failure. Leave unknown states out of any average.

These S1 and S2 values are ledger states for the lesson. They are not native rank fields from the control. That control saved ranks 1 and 2 only. It did not measure change.

## Next action

Save one Bing snapshot with your exact query, country, language, safeSearch setting, sponsored setting, and result cap. Record one valid target state: observed rank, not seen within the cap, source failed, or unresolved target. Start from the [Google Search Scraper](https://apify.com/chronometrica/google-search-scraper) Actor and the current [Keyword Search Rank Snapshot](https://apify.com/chronometrica/google-search-scraper/examples/keyword-rank-snapshot) Task. Change that Task to one query and engines ["bing"] before you compare anything.

## Sources

- [Google Search Scraper](https://apify.com/chronometrica/google-search-scraper)
- [Keyword Search Rank Snapshot](https://apify.com/chronometrica/google-search-scraper/examples/keyword-rank-snapshot)
- [https://www.reddit.com/r/SEO/comments/1dybuk8/any_tools_to_just_monitor_specific_keyword_ranking/](https://www.reddit.com/r/SEO/comments/1dybuk8/any_tools_to_just_monitor_specific_keyword_ranking/)
- [https://www.reddit.com/r/SEO/comments/1d5kve4/how_to_check_if_you_ranked_on_a_keyword/](https://www.reddit.com/r/SEO/comments/1d5kve4/how_to_check_if_you_ranked_on_a_keyword/)