Skip to content

Commit d40d330

Browse files
committed
Prepare 0.3.2 release with search and popup sorting fixes
1 parent 45f5d21 commit d40d330

8 files changed

Lines changed: 191 additions & 8 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.3.2
4+
5+
### What's new
6+
7+
- Improved Steam promotion discovery so current free-to-keep offers are found more reliably.
8+
- The popup now prioritizes full games before other content types, making the main list easier to scan.
9+
310
## 0.3.1
411

512
### What's new

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": 3,
33
"name": "Steam Promo Watch",
44
"description": "Tracks new Steam free-to-keep promotions and notifies you about new giveaways.",
5-
"version": "0.3.1",
5+
"version": "0.3.2",
66
"minimum_chrome_version": "120",
77
"permissions": [
88
"alarms",

src/lib/providers/enrichmentProvider.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export function sanitizeMetadataCache(raw = {}) {
3737
screenshotFull: sanitizeSteamAssetUrl(value.screenshotFull),
3838
priceInitial: safeNumber(value.priceInitial, 0),
3939
priceFinal: safeNumber(value.priceFinal, 0),
40+
priceDiscountPercent: safeNumber(value.priceDiscountPercent, 0),
41+
priceFinalFormatted: typeof value.priceFinalFormatted === "string" ? value.priceFinalFormatted : "",
4042
...sanitizeSteamReviewSummary(value),
4143
reviewUpdatedAt: safeNumber(value.reviewUpdatedAt, 0),
4244
updatedAt: safeNumber(value.updatedAt, 0)
@@ -116,6 +118,10 @@ async function fetchMissingMetadata(cache, appIds) {
116118
screenshotFull: data ? sanitizeSteamAssetUrl(data.screenshots?.[0]?.path_full) : (existing.screenshotFull || ""),
117119
priceInitial: data ? safeNumber(data.price_overview?.initial, 0) : safeNumber(existing.priceInitial, 0),
118120
priceFinal: data ? safeNumber(data.price_overview?.final, 0) : safeNumber(existing.priceFinal, 0),
121+
priceDiscountPercent: data ? safeNumber(data.price_overview?.discount_percent, 0) : safeNumber(existing.priceDiscountPercent, 0),
122+
priceFinalFormatted: data && typeof data.price_overview?.final_formatted === "string"
123+
? data.price_overview.final_formatted
124+
: (existing.priceFinalFormatted || ""),
119125
reviewScore: reviewSummary.reviewScore || safeNumber(existing.reviewScore, 0),
120126
reviewScoreDesc: reviewSummary.reviewScoreDesc || existing.reviewScoreDesc || "",
121127
reviewPositive: reviewSummary.reviewPositive || safeNumber(existing.reviewPositive, 0),
@@ -175,8 +181,11 @@ export async function enrichPromotions(promotions, existingCache) {
175181
const metadata = metadataCache[promotion.stableId];
176182
const priceInitial = safeNumber(metadata?.priceInitial, -1);
177183
const priceFinal = safeNumber(metadata?.priceFinal, -1);
178-
// Steam appdetails may report final_formatted="Free" while keeping a non-zero numeric final price.
179-
const metadataConfirmedFreeToKeep = priceInitial < 0 ? true : (priceInitial > 0 && priceFinal === 0);
184+
const priceDiscountPercent = safeNumber(metadata?.priceDiscountPercent, 0);
185+
const priceFinalFormatted = String(metadata?.priceFinalFormatted || "");
186+
const metadataConfirmedFreeToKeep = priceInitial < 0
187+
? true
188+
: (priceInitial > 0 && (priceFinal === 0 || priceDiscountPercent >= 100 || /\bfree\b/i.test(priceFinalFormatted)));
180189

181190
return {
182191
...promotion,

src/lib/providers/steamStoreSearchProvider.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { PROMO_TYPES, SOURCE_IDS } from "../constants.js";
22
import { buildSteamUrlFromStableId, createHash, decodeHtmlEntities, fetchJsonWithTimeout, normalizeWhitespace, stripHtml } from "../utils.js";
33

44
const SEARCH_RESULTS_URL = "https://store.steampowered.com/search/results/?query&start=0&count=100&dynamic_data=&sort_by=_ASC&specials=1&maxprice=free&supportedlang=english&ndl=1&infinite=1";
5+
const FREE_PRICE_SEARCH_PAGE_SIZE = 100;
6+
const FREE_PRICE_SEARCH_PAGE_COUNT = 4;
7+
8+
function buildFreePriceSearchUrl(start) {
9+
return `https://store.steampowered.com/search/results/?query&start=${start}&count=${FREE_PRICE_SEARCH_PAGE_SIZE}&dynamic_data=&sort_by=Price_ASC&maxprice=free&category1=998&supportedlang=english&ndl=1&infinite=1`;
10+
}
511

612
function isDiscountedToZero(priceText, row) {
713
const finalPriceMatch = /data-price-final="(\d+)"/i.exec(row);
@@ -17,7 +23,9 @@ function isDiscountedToZero(priceText, row) {
1723
return hasZeroFinalPrice && (hasDiscountedOriginalPrice || hasFullDiscount);
1824
}
1925

20-
export function parseSearchRows(html) {
26+
export function parseSearchRows(html, options = {}) {
27+
const allowFreeLabel = options.allowFreeLabel !== false;
28+
const rawTypeLabel = options.rawTypeLabel || "Store special";
2129
const rows = String(html || "").match(/<a\b[\s\S]*?class="[^"]*search_result_row[^"]*"[\s\S]*?<\/a>/gi) || [];
2230
const promotions = [];
2331

@@ -29,8 +37,9 @@ export function parseSearchRows(html) {
2937
const title = titleMatch ? normalizeWhitespace(decodeHtmlEntities(stripHtml(titleMatch[1]))) : "";
3038
const priceText = normalizeWhitespace(stripHtml(priceMatch ? priceMatch[1] : ""));
3139
const isFreeLabel = /\bfree\b/i.test(priceText);
40+
const discountedToZero = isDiscountedToZero(priceText, row);
3241

33-
if (!href || !title || (!isFreeLabel && !isDiscountedToZero(priceText, row))) {
42+
if (!href || !title || (!discountedToZero && (!allowFreeLabel || !isFreeLabel))) {
3443
continue;
3544
}
3645

@@ -51,7 +60,7 @@ export function parseSearchRows(html) {
5160
title,
5261
url: href || buildSteamUrlFromStableId(stableId),
5362
promoType: PROMO_TYPES.FREE_TO_KEEP,
54-
rawTypeLabel: "Store special",
63+
rawTypeLabel,
5564
sourceId: SOURCE_IDS.STORE_SEARCH,
5665
sourceFingerprint: createHash(row),
5766
rowText
@@ -64,8 +73,19 @@ export function parseSearchRows(html) {
6473
export const steamStoreSearchProvider = {
6574
id: SOURCE_IDS.STORE_SEARCH,
6675
async fetchPromotions() {
67-
const response = await fetchJsonWithTimeout(SEARCH_RESULTS_URL);
68-
const promotions = parseSearchRows(response?.results_html || "");
76+
const [specialsResponse, ...freePriceResponses] = await Promise.all([
77+
fetchJsonWithTimeout(SEARCH_RESULTS_URL),
78+
...Array.from({ length: FREE_PRICE_SEARCH_PAGE_COUNT }, (_, index) => {
79+
return fetchJsonWithTimeout(buildFreePriceSearchUrl(index * FREE_PRICE_SEARCH_PAGE_SIZE)).catch(() => null);
80+
})
81+
]);
82+
const promotions = [
83+
...parseSearchRows(specialsResponse?.results_html || ""),
84+
...freePriceResponses.flatMap((response) => parseSearchRows(response?.results_html || "", {
85+
allowFreeLabel: false,
86+
rawTypeLabel: "Store free price"
87+
}))
88+
];
6989

7090
return {
7191
promotions,

src/ui/popup-state.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { CONTENT_TYPES } from "../lib/constants.js";
2+
3+
function getPopupContentPriority(entry) {
4+
return entry?.contentType === CONTENT_TYPES.GAME ? 0 : 1;
5+
}
6+
17
export function getVisiblePromotions(entries, maxItems = 10) {
28
return (Array.isArray(entries) ? entries : [])
39
.filter((entry) => entry?.status === "active")
10+
.sort((left, right) => getPopupContentPriority(left) - getPopupContentPriority(right))
411
.slice(0, maxItems);
512
}
613

tests/enrichmentProvider.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,77 @@ test("enrichPromotions backfills reviews for fresh cached metadata created befor
176176
assert.equal(result.promotions[0].reviewPercent, 86);
177177
assert.ok(result.metadataCache["app:599140"].reviewUpdatedAt > 0);
178178
});
179+
180+
test("enrichPromotions confirms 100 percent appdetails discounts with non-zero numeric final price", async (t) => {
181+
const originalFetch = globalThis.fetch;
182+
183+
globalThis.fetch = async (url) => {
184+
if (url.includes("/api/appdetails?")) {
185+
return {
186+
ok: true,
187+
async json() {
188+
return {
189+
3550490: {
190+
success: true,
191+
data: {
192+
name: "Overcome Your Fears - Caretaker",
193+
type: "game",
194+
genres: [{ description: "Adventure" }],
195+
categories: [{ description: "Single-player" }],
196+
header_image: "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3550490/header.jpg",
197+
capsule_image: "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3550490/capsule.jpg",
198+
screenshots: [],
199+
price_overview: {
200+
initial: 599,
201+
final: 599,
202+
discount_percent: 100,
203+
final_formatted: "Free"
204+
}
205+
}
206+
}
207+
};
208+
}
209+
};
210+
}
211+
212+
if (url.includes("/appreviews/3550490?")) {
213+
return {
214+
ok: true,
215+
async json() {
216+
return {
217+
success: 1,
218+
query_summary: {
219+
review_score: 6,
220+
review_score_desc: "Mostly Positive",
221+
total_positive: 108,
222+
total_negative: 32,
223+
total_reviews: 140
224+
}
225+
};
226+
}
227+
};
228+
}
229+
230+
throw new Error(`Unexpected URL: ${url}`);
231+
};
232+
233+
t.after(() => {
234+
globalThis.fetch = originalFetch;
235+
});
236+
237+
const result = await enrichPromotions([
238+
{
239+
id: "app:3550490|free-to-keep|metadata-only",
240+
stableId: "app:3550490",
241+
appId: 3550490,
242+
title: "",
243+
promoType: "free-to-keep",
244+
sourceId: "metadata-only"
245+
}
246+
], {});
247+
248+
assert.equal(result.promotions.length, 1);
249+
assert.equal(result.promotions[0].isLikelyFreeToKeep, true);
250+
assert.equal(result.metadataCache["app:3550490"].priceDiscountPercent, 100);
251+
assert.equal(result.metadataCache["app:3550490"].priceFinalFormatted, "Free");
252+
});

tests/popup-state.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,21 @@ test("getVisiblePromotions keeps only active entries for the popup", () => {
1717
);
1818
});
1919

20+
test("getVisiblePromotions shows games before other content types", () => {
21+
const visible = getVisiblePromotions([
22+
{ id: "dlc-1", status: "active", contentType: "dlc" },
23+
{ id: "package-1", status: "active", contentType: "package" },
24+
{ id: "game-1", status: "active", contentType: "game" },
25+
{ id: "game-2", status: "active", contentType: "game" },
26+
{ id: "demo-1", status: "active", contentType: "demo" }
27+
], 3);
28+
29+
assert.deepEqual(
30+
visible.map((entry) => entry.id),
31+
["game-1", "game-2", "dlc-1"]
32+
);
33+
});
34+
2035
test("getPopupStatusText reports when no new free promotions were found", () => {
2136
assert.equal(
2237
getPopupStatusText({ lastCheckOutcome: "success", lastResultCount: 0 }),

tests/steamStoreSearchProvider.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,45 @@ const NON_FREE_SPECIALS_HTML = `
8080
</a>
8181
`;
8282

83+
const FREE_PRICE_SEARCH_HTML = `
84+
<a href="https://store.steampowered.com/app/730/CounterStrike_2/"
85+
class="search_result_row ds_collapse_flag">
86+
<div class="responsive_search_name_combined">
87+
<div class="search_name ellipsis">
88+
<span class="title">Counter-Strike 2</span>
89+
</div>
90+
<div class="search_price_discount_combined responsive_secondrow" data-price-final="0">
91+
<div class="search_price">Free To Play</div>
92+
</div>
93+
</div>
94+
</a>
95+
<a href="https://store.steampowered.com/app/3550490/Overcome_Your_Fears__Caretaker/?snr=1_7_7_230_150_3"
96+
class="search_result_row ds_collapse_flag"
97+
data-ds-appid="3550490">
98+
<div class="responsive_search_name_combined">
99+
<div class="search_name ellipsis">
100+
<span class="title">Overcome Your Fears - Caretaker</span>
101+
</div>
102+
<div class="search_price_discount_combined responsive_secondrow" data-price-final="0">
103+
<div class="search_discount_and_price responsive_secondrow">
104+
<div class="discount_block search_discount_block"
105+
data-price-final="0"
106+
data-bundlediscount="0"
107+
data-discount="100"
108+
role="link"
109+
aria-label="100% off. $5.99 normally, discounted to $0.00">
110+
<div class="discount_pct">-100%</div>
111+
<div class="discount_prices">
112+
<div class="discount_original_price">$5.99</div>
113+
<div class="discount_final_price">$0.00</div>
114+
</div>
115+
</div>
116+
</div>
117+
</div>
118+
</div>
119+
</a>
120+
`;
121+
83122
test("parseSearchRows detects active free-to-keep rows discounted to zero", () => {
84123
const promotions = parseSearchRows(SEARCH_RESULTS_HTML);
85124

@@ -96,3 +135,15 @@ test("parseSearchRows ignores specials that are not actually free", () => {
96135

97136
assert.equal(promotions.length, 0);
98137
});
138+
139+
test("parseSearchRows can scan free-price pages without accepting permanent free-to-play rows", () => {
140+
const promotions = parseSearchRows(FREE_PRICE_SEARCH_HTML, {
141+
allowFreeLabel: false,
142+
rawTypeLabel: "Store free price"
143+
});
144+
145+
assert.equal(promotions.length, 1);
146+
assert.equal(promotions[0].appId, 3550490);
147+
assert.equal(promotions[0].title, "Overcome Your Fears - Caretaker");
148+
assert.equal(promotions[0].rawTypeLabel, "Store free price");
149+
});

0 commit comments

Comments
 (0)