From 28ebf5c5c38d80efe1d77a8d4fe3d3761e6345d2 Mon Sep 17 00:00:00 2001 From: phanex Date: Thu, 17 Sep 2026 20:07:31 +0200 Subject: [PATCH 1/2] fix(rewards): add support for new /earn dashboard and Explore on Bing activities (fixes #603) - Parse Explore on Bing activities dynamically from React Server Component (RSC) flight chunks via Fluent UI design tokens (findTextByClass), completely language-agnostic across all locales - Strip zero-width Unicode characters (\u200B-\u200D, \uFEFF) from titles and descriptions - Introduce offerAdapter to unify ParsedOffer to BasePromotion transformation with safe defaults - Add fallback in DailySet and MorePromotions for RSC-only offers not returned by the legacy dashboard API - Align UrlReward context with /earn (matching bootstrap and router state tree) - Simplify quest completion regex to use universal slash-based task counts without language-specific keywords --- src/browser/ReactFunc.ts | 86 ++++++++++++++++--- src/functions/activities/api/UrlReward.ts | 7 +- src/functions/activities/rewards/DailySet.ts | 16 +++- .../activities/rewards/MorePromotions.ts | 10 +++ .../rewards/PromotionActivityRunner.ts | 3 +- .../activities/rewards/offerAdapter.ts | 30 +++++++ 6 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 src/functions/activities/rewards/offerAdapter.ts diff --git a/src/browser/ReactFunc.ts b/src/browser/ReactFunc.ts index 01529d9b..e789cced 100644 --- a/src/browser/ReactFunc.ts +++ b/src/browser/ReactFunc.ts @@ -262,6 +262,35 @@ export default class ReactFunc { return out } + /** + * Walk a parsed RSC object tree and return the first text content + * found inside a node whose `className` includes `targetClass`. + * Completely language-agnostic — relies only on Fluent UI design tokens. + */ + private findTextByClass(node: unknown, targetClass: string): string | null { + if (!node || typeof node !== 'object') return null + if (Array.isArray(node)) { + for (const item of node) { + const res = this.findTextByClass(item, targetClass) + if (res) return res + } + return null + } + const record = node as Record + if (typeof record.className === 'string' && record.className.includes(targetClass)) { + if (typeof record.children === 'string') return record.children + if (Array.isArray(record.children)) { + return record.children.filter(c => typeof c === 'string').join('') + } + } + for (const key of Object.keys(record)) { + if (key === 'className') continue + const res = this.findTextByClass(record[key], targetClass) + if (res) return res + } + return null + } + // Section parsers private parseOffers(combined: string): ParsedOffer[] { try { @@ -291,12 +320,40 @@ export default class ReactFunc { // Never try future-dated offers, lol const reportable = !!hash && !isCompleted && !isLocked && (date === null || date <= today) + const isExploreOnBing = offerId.toLowerCase().includes('exploreonbing') + + // Top-level fields exist for dashboard-sourced offers; for RSC-only + // offers (e.g. Explore on Bing) they are buried inside React children + // nodes keyed by Fluent UI CSS class tokens. + let title = (obj.title as string) ?? '' + let description = (obj.description as string) ?? '' + let points = (obj.points as number) ?? (obj.pointProgressMax as number) ?? 0 + + if (!title) { + title = this.findTextByClass(obj, 'text-globalBody2Strong') ?? '' + } + if (!description) { + description = this.findTextByClass(obj, 'text-fgCtrlNeutralSecondaryRest') ?? '' + } + if (!points) { + const badgeText = this.findTextByClass(obj, 'text-statusInformativeTintFg') ?? '' + const m = badgeText.match(/\d+/) + // In RSC flight streams, shared subtrees (such as the +10 pts badge) are often + // deduplicated into reference chunks ($Lxx) and not inlined. Explore on Bing + // activities always award 10 points on Rewards. + points = m ? parseInt(m[0], 10) : (isExploreOnBing ? 10 : 0) + } + + // RSC payloads may embed zero-width chars in rendered text + title = title.replace(/[\u200B-\u200D\uFEFF]/g, '').trim() + description = description.replace(/[\u200B-\u200D\uFEFF]/g, '').trim() + const candidate: ParsedOffer = { offerId, hash, - title: (obj.title as string) ?? '', - description: (obj.description as string) ?? '', - points: (obj.points as number) ?? (obj.pointProgressMax as number) ?? 0, + title: title || offerId, + description, + points, promotionSubtype: (obj.promotionSubtype as string | null) ?? null, destination: (obj.destination as string) ?? (obj.destinationUrl as string) ?? '', isCompleted, @@ -720,26 +777,35 @@ export default class ReactFunc { const { id, at } = anchors[k]! if (!this.isParentQuestId(id)) continue - const next = anchors[k + 1]?.at ?? combined.length - const region = combined.slice(at, Math.min(next, at + 3000)) + // Find the start of the next *different* parent quest, + // skipping sub-anchors that belong to the same quest id. + let next = combined.length + for (let j = k + 1; j < anchors.length; j++) { + if (anchors[j]!.id !== id && this.isParentQuestId(anchors[j]!.id)) { + next = anchors[j]!.at + break + } + } + const region = combined.slice(at, Math.min(next, at + 4000)) const title = region.match(/"alt":"((?:[^"\\]|\\.)*)"/)?.[1] ?? region.match(/"title":"((?:[^"\\]|\\.)*)"/)?.[1] ?? '' - const pointsMatch = region.match(/\["\+","([\d,]+)"\]/) + // Points badge may render as ["+","150"] or ["","150"] + const pointsMatch = region.match(/\["(?:|\+)","([\d,]+)"\]/) const points = pointsMatch ? Number(pointsMatch[1]!.replace(/,/g, '')) : 0 - const taskM = region.match(/(\d+)\s*\/\s*(\d+)\s*tasks/) + // Progress is always rendered with a slash (e.g. "1/5", "Einchecken: 1/1") + const taskM = region.match(/(\d+)\s*\/\s*(\d+)/) const complete = !!taskM && Number(taskM[1]) >= Number(taskM[2]!) && Number(taskM[2]) > 0 - // First wins for title/points const prev = byId.get(id) byId.set(id, { offerId: id, title: prev?.title || title, - pointProgressMax: prev?.pointProgressMax || points, - complete: prev?.complete || complete + pointProgressMax: Math.max(prev?.pointProgressMax || 0, points), + complete: (prev && prev.complete) || complete }) } diff --git a/src/functions/activities/api/UrlReward.ts b/src/functions/activities/api/UrlReward.ts index edc76785..53f48bfb 100644 --- a/src/functions/activities/api/UrlReward.ts +++ b/src/functions/activities/api/UrlReward.ts @@ -73,10 +73,11 @@ export class UrlReward extends BaseActivity { timezoneOffset: this.bot.userData.timezoneOffset } ], + // Use /earn context where offers and action IDs are fetched (matching bootstrap) { - url: URLs.rewards.dashboard, - referer: URLs.rewards.dashboard, - routerStateTree: this.bot.browser.react.routerStateTree('dashboard') + url: URLs.rewards.earn, + referer: URLs.rewards.earn, + routerStateTree: this.bot.browser.react.routerStateTree('earn') } ) diff --git a/src/functions/activities/rewards/DailySet.ts b/src/functions/activities/rewards/DailySet.ts index c16c4202..648ec169 100644 --- a/src/functions/activities/rewards/DailySet.ts +++ b/src/functions/activities/rewards/DailySet.ts @@ -1,13 +1,25 @@ import { BaseActivity } from '../BaseActivity' -import type { DashboardData } from '../../../interface/DashboardData' +import type { BasePromotion, DashboardData } from '../../../interface/DashboardData' import { PromotionActivityRunner } from './PromotionActivityRunner' +import { toBasePromotion } from './offerAdapter' export class DailySet extends BaseActivity { public async run(data: DashboardData): Promise { const today = this.bot.utils.getFormattedDate() - const pending = + let pending: BasePromotion[] = data.dashboard.dailySetPromotions[today]?.filter(item => !item.complete && item.pointProgressMax > 0) ?? [] + // Fallback: dashboard API may return an empty daily set while the + // RSC payload still contains the offers keyed by offerId. + if (!pending.length && this.bot.reactSnapshot?.offers) { + const fromRsc = this.bot.reactSnapshot.offers.filter( + o => o.offerId.toLowerCase().includes('dailyset') && !o.isCompleted && o.points > 0 && o.reportable + ) + if (fromRsc.length) { + pending = fromRsc.map(toBasePromotion) + } + } + if (!pending.length) { this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed') return diff --git a/src/functions/activities/rewards/MorePromotions.ts b/src/functions/activities/rewards/MorePromotions.ts index 81284a66..7e135919 100644 --- a/src/functions/activities/rewards/MorePromotions.ts +++ b/src/functions/activities/rewards/MorePromotions.ts @@ -1,6 +1,7 @@ import { BaseActivity } from '../BaseActivity' import type { BasePromotion, DashboardData } from '../../../interface/DashboardData' import { PromotionActivityRunner } from './PromotionActivityRunner' +import { toBasePromotion } from './offerAdapter' export class MorePromotions extends BaseActivity { public async run(data: DashboardData): Promise { @@ -15,6 +16,15 @@ export class MorePromotions extends BaseActivity { ).values() ] + // Append RSC-only offers (e.g. Explore on Bing) that the dashboard + // API did not return. Skip dailyset items — handled by DailySet. + const existingIds = new Set(promotions.map(p => p.offerId)) + for (const offer of this.bot.reactSnapshot?.reportable ?? []) { + if (offer.offerId.toLowerCase().includes('dailyset') || existingIds.has(offer.offerId)) continue + promotions.push(toBasePromotion(offer)) + existingIds.add(offer.offerId) + } + const pending = promotions.filter(promotion => this.isActionable(promotion)) if (!pending.length) { this.bot.logger.info( diff --git a/src/functions/activities/rewards/PromotionActivityRunner.ts b/src/functions/activities/rewards/PromotionActivityRunner.ts index 46728342..536be5a7 100644 --- a/src/functions/activities/rewards/PromotionActivityRunner.ts +++ b/src/functions/activities/rewards/PromotionActivityRunner.ts @@ -43,7 +43,8 @@ export class PromotionActivityRunner extends BaseActivity { return } - const isSearchOnBing = name.includes('exploreonbing') + // Offers sourced via RSC flight chunks have name set to offerId, so check both + const isSearchOnBing = name.includes('exploreonbing') || offerId.toLowerCase().includes('exploreonbing') if (isSearchOnBing && !this.bot.config.activities.searchOnBing) { this.logDisabled('SearchOnBing', offerId) return diff --git a/src/functions/activities/rewards/offerAdapter.ts b/src/functions/activities/rewards/offerAdapter.ts new file mode 100644 index 00000000..9a464ad7 --- /dev/null +++ b/src/functions/activities/rewards/offerAdapter.ts @@ -0,0 +1,30 @@ +import type { ParsedOffer } from '../../../browser/ReactFunc' +import type { BasePromotion } from '../../../interface/DashboardData' + +/** + * Convert a RSC-parsed offer into the BasePromotion shape expected by + * PromotionActivityRunner. Provides safe defaults for every field that + * downstream consumers (isActionable, UrlReward, SearchOnBing) inspect. + * + * BasePromotion has 80+ fields; only the subset actually read at runtime + * is populated here, so the double cast is intentional. + */ +export function toBasePromotion(offer: ParsedOffer): BasePromotion { + return { + offerId: offer.offerId, + name: offer.offerId, + title: offer.title || offer.offerId, + description: offer.description, + complete: offer.isCompleted, + pointProgressMax: offer.points, + pointProgress: 0, + promotionType: 'urlreward', + promotionSubtype: offer.promotionSubtype ?? '', + destinationUrl: offer.destination, + hash: offer.hash ?? '', + activityType: String(offer.activityType ?? 11), + priority: 1, + attributes: null, + exclusiveLockedFeatureStatus: '', + } as unknown as BasePromotion +} From 8597edc42790a9146b8fae7ebfdd0418b9572a1e Mon Sep 17 00:00:00 2001 From: phanex Date: Mon, 28 Sep 2026 22:51:49 +0200 Subject: [PATCH 2/2] fix(punchcards): do not treat RewardsApp urlreward tasks as claim rewards --- src/functions/activities/rewards/PunchCards.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/functions/activities/rewards/PunchCards.ts b/src/functions/activities/rewards/PunchCards.ts index 13bf381e..786f0ee2 100644 --- a/src/functions/activities/rewards/PunchCards.ts +++ b/src/functions/activities/rewards/PunchCards.ts @@ -305,8 +305,9 @@ export class PunchCards extends BaseActivity { } private isClaimChild(offerId: string, promotion?: BasePromotion): boolean { + if (/urlreward/i.test(offerId)) return false if (/\/redeem\//.test((promotion?.destinationUrl ?? '').toLowerCase())) return true - return /(redeem|claim|(?