From 28ebf5c5c38d80efe1d77a8d4fe3d3761e6345d2 Mon Sep 17 00:00:00 2001 From: phanex Date: Thu, 17 Sep 2026 20:07:31 +0200 Subject: [PATCH 1/3] 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 8de126d00a018f7994deb6ae2701a1f02717d6bc Mon Sep 17 00:00:00 2001 From: phanex Date: Wed, 23 Sep 2026 22:48:28 +0200 Subject: [PATCH 2/3] feat(rewards): add opt-in AI search query generator with auto-learning --- config.example.json | 3 +- .../search/AiSearchQueryGenerator.ts | 165 +++++++++++++++++ .../activities/search/BrowserSearchOnBing.ts | 40 ++++- .../activities/search/SearchOnBingShared.ts | 166 ++++++++++++++++-- src/interface/Config.ts | 1 + src/util/ConfigEnvOverrides.ts | 1 + src/util/Validator.ts | 14 +- 7 files changed, 373 insertions(+), 17 deletions(-) create mode 100644 src/functions/activities/search/AiSearchQueryGenerator.ts diff --git a/config.example.json b/config.example.json index 32c96499..ae73b1f8 100644 --- a/config.example.json +++ b/config.example.json @@ -65,7 +65,8 @@ "apiSearch": false, "apiSearchOnBing": false, "blockMedia": false, - "edgeBrowsing": false + "edgeBrowsing": false, + "aiQueryGenerator": false }, "debugLogs": false, "consoleLogFilter": { diff --git a/src/functions/activities/search/AiSearchQueryGenerator.ts b/src/functions/activities/search/AiSearchQueryGenerator.ts new file mode 100644 index 00000000..1b3b6412 --- /dev/null +++ b/src/functions/activities/search/AiSearchQueryGenerator.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs' +import path from 'path' + +export interface ActivityQueryEntry { + title: string + queries: string[] +} + +/** + * Generates targeted Bing search queries via LLM for a given Rewards activity. + * Supports localized/non-English cards and avoids repeating failed queries. + */ +export async function generateAiQueries( + title: string, + description: string, + failedQueries: string[] = [] +): Promise { + const cleanTitle = (title || '').trim() + const cleanDesc = (description || '').trim() + + if (!cleanTitle && !cleanDesc) return [] + + let prompt = `Generate 2 to 3 short Bing search queries in the task's language for the following Microsoft Rewards activity:\n` + prompt += `Title: "${cleanTitle}"\n` + if (cleanDesc) { + prompt += `Description: "${cleanDesc}"\n` + } + if (failedQueries.length > 0) { + prompt += `The following queries FAILED and must NOT be repeated: ${JSON.stringify(failedQueries)}\n` + } + prompt += `\nOutput format must be strictly a JSON array of strings (2 to 4 words each), for example:\n` + prompt += `["query 1", "query 2"]\n` + prompt += `Return ONLY the JSON array without any markdown formatting or extra text.` + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 15000) + + try { + const url = `https://text.pollinations.ai/${encodeURIComponent(prompt)}?model=openai` + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + return [] + } + + const rawText = await response.text() + let queries: string[] = [] + + // Extract JSON array via regex + const jsonMatch = rawText.match(/\[[\s\S]*?\]/) + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]) + if (Array.isArray(parsed)) { + queries = parsed.map(item => String(item).trim()).filter(Boolean) + } + } catch { + // Ignore parse errors, continue to fallbacks + } + } + + // If regex parsing failed, attempt line-by-line fallback + if (queries.length === 0) { + queries = rawText + .split('\n') + .map(l => l.replace(/^[-*0-9.)"'\s]+|["'\s]+$/g, '').trim()) + .filter(l => l.length > 2 && l.length < 50 && !l.includes('{') && !l.includes('}')) + } + + const failedSet = new Set(failedQueries.map(q => q.trim().toLowerCase())) + return [...new Set(queries)].filter(q => q.length > 0 && !failedSet.has(q.toLowerCase())) + } catch { + clearTimeout(timeoutId) + return [] + } +} + +/** + * Persists a verified working query to custom.json so subsequent runs + * resolve the activity instantly and locally without external calls. + */ +export function saveSuccessfulQuery(offerId: string, title: string, query: string): void { + try { + const trimmedQuery = (query || '').trim() + if (!trimmedQuery) return + + const configDir = path.join(process.cwd(), 'config') + const targetDir = fs.existsSync(configDir) ? configDir : process.cwd() + const customPath = path.join(targetDir, 'custom.json') + + let entries: ActivityQueryEntry[] = [] + + if (fs.existsSync(customPath)) { + try { + entries = JSON.parse(fs.readFileSync(customPath, 'utf8')) as ActivityQueryEntry[] + } catch { + entries = [] + } + } else { + // Seed from built-in custom.json if available + const seedCandidates = [ + path.join(__dirname, '../../custom.json'), + path.join(process.cwd(), 'src/functions/custom.json'), + path.join(process.cwd(), 'dist/functions/custom.json') + ] + for (const cand of seedCandidates) { + if (fs.existsSync(cand)) { + try { + entries = JSON.parse(fs.readFileSync(cand, 'utf8')) as ActivityQueryEntry[] + break + } catch { + // Continue to next candidate + } + } + } + } + + // Look for matching entry by offerId or title + const cleanOfferId = (offerId || '').trim() + const cleanTitle = (title || '').trim() + + let entry = entries.find(e => { + const t = (e.title || '').trim() + return (cleanOfferId && t === cleanOfferId) || (cleanTitle && t === cleanTitle) + }) + + if (!entry) { + const entryKey = cleanOfferId || cleanTitle + if (!entryKey) return + + entry = { + title: entryKey, + queries: [trimmedQuery] + } + entries.push(entry) + } else { + // Put the working query at the very front + entry.queries = [trimmedQuery, ...entry.queries.filter(q => q.trim().toLowerCase() !== trimmedQuery.toLowerCase())] + } + + // Write atomically + const tmpPath = `${customPath}.${process.pid}.tmp` + fs.writeFileSync(tmpPath, JSON.stringify(entries, null, 4), 'utf8') + fs.renameSync(tmpPath, customPath) + + // Enforce ownership if PUID/PGID are defined in Docker environment + if (process.env.PUID && process.env.PGID) { + try { + fs.chownSync(customPath, Number(process.env.PUID), Number(process.env.PGID)) + } catch { + // Ignore if not supported (e.g. Windows) + } + } + } catch { + // Silently ignore disk write errors to prevent interrupting bot operations + } +} diff --git a/src/functions/activities/search/BrowserSearchOnBing.ts b/src/functions/activities/search/BrowserSearchOnBing.ts index e46e3010..6e86c871 100644 --- a/src/functions/activities/search/BrowserSearchOnBing.ts +++ b/src/functions/activities/search/BrowserSearchOnBing.ts @@ -1,6 +1,12 @@ import type { Page } from 'patchright' import { BaseActivity } from '../BaseActivity' -import { activateSearchOnBing, findSearchOnBingOffer, getSearchOnBingQueries } from './SearchOnBingShared' +import { + activateSearchOnBing, + findSearchOnBingOffer, + getSearchOnBingQueries, + recordFailedSearchOnBing +} from './SearchOnBingShared' +import { saveSuccessfulQuery } from './AiSearchQueryGenerator' import { URLs } from '../../../constants/urls' import type { BasePromotion } from '../../../interface/DashboardData' @@ -25,6 +31,7 @@ export class SearchOnBing extends BaseActivity { try { const activated = await activateSearchOnBing(this.bot, promotion) if (!activated) { + recordFailedSearchOnBing(promotion, 'Activation failed or not acknowledged by server') this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING', @@ -33,9 +40,24 @@ export class SearchOnBing extends BaseActivity { return } - const queries = await getSearchOnBingQueries(this.bot, promotion) + let queries = await getSearchOnBingQueries(this.bot, promotion) await this.searchBing(page, queries, promotion) + // If not completed and AI query generator is enabled, attempt 1 retry with alternative AI queries + if (!this.success && this.bot.config.experimental.aiQueryGenerator) { + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-AI', + `Initial queries did not complete offer ${offerId}, requesting alternative AI queries...` + ) + const retryQueries = await getSearchOnBingQueries(this.bot, promotion, queries) + const newQueries = retryQueries.filter(q => !queries.includes(q)) + if (newQueries.length > 0) { + await this.searchBing(page, newQueries, promotion) + queries = [...queries, ...newQueries] + } + } + if (this.success) { this.bot.logger.info( this.bot.isMobile, @@ -44,6 +66,10 @@ export class SearchOnBing extends BaseActivity { 'green' ) } else { + recordFailedSearchOnBing( + promotion, + `Queries exhausted without completion (tried ${queries.length} queries)` + ) this.bot.logger.warn( this.bot.isMobile, 'SEARCH-ON-BING', @@ -51,6 +77,7 @@ export class SearchOnBing extends BaseActivity { ) } } catch (error) { + recordFailedSearchOnBing(promotion, error instanceof Error ? error.message : String(error)) this.bot.logger.error( this.bot.isMobile, 'SEARCH-ON-BING', @@ -117,6 +144,15 @@ export class SearchOnBing extends BaseActivity { `SearchOnBing activity completed | pointsGained=${this.gainedPoints} | currentBalance=${newBalance} | query="${query}" | offerProgress=${offerProgress}`, 'green' ) + if (this.bot.config.experimental.aiQueryGenerator) { + saveSuccessfulQuery(offerId, promotion.title ?? '', query) + this.bot.logger.info( + this.bot.isMobile, + 'SEARCH-ON-BING-AI', + `Persisted verified working query "${query}" to custom.json for ${offerId}`, + 'cyan' + ) + } return } diff --git a/src/functions/activities/search/SearchOnBingShared.ts b/src/functions/activities/search/SearchOnBingShared.ts index 363b1bc7..568ffa2c 100644 --- a/src/functions/activities/search/SearchOnBingShared.ts +++ b/src/functions/activities/search/SearchOnBingShared.ts @@ -4,6 +4,7 @@ import path from 'path' import { URLs } from '../../../constants/urls' import type { BasePromotion, Dashboard } from '../../../interface/DashboardData' import type { MicrosoftRewardsBot } from '../../../index' +import { generateAiQueries } from './AiSearchQueryGenerator' interface ActivityQueries { title: string @@ -71,7 +72,11 @@ export function findSearchOnBingOffer(dashboard: Dashboard, offerId: string): Ba return offers.find(offer => offer.offerId === offerId) } -export async function getSearchOnBingQueries(bot: MicrosoftRewardsBot, promotion: BasePromotion): Promise { +export async function getSearchOnBingQueries( + bot: MicrosoftRewardsBot, + promotion: BasePromotion, + failedQueries?: string[] +): Promise { try { let activities: ActivityQueries[] @@ -90,25 +95,119 @@ export async function getSearchOnBingQueries(bot: MicrosoftRewardsBot, promotion ).data } - const match = activities.find( - activity => bot.utils.normalizeString(activity.title) === bot.utils.normalizeString(promotion.title) - ) - if (match?.queries.length) { - const shuffled = bot.utils.shuffleArray(match.queries) + // Load custom local queries if present (e.g. localized/user-defined activities) + const customCandidates = [ + path.join(process.cwd(), 'config/custom.json'), + path.join(__dirname, '../../custom.json'), + path.join(process.cwd(), 'custom.json'), + path.join(process.cwd(), 'src/functions/custom.json') + ] + let customActivities: ActivityQueries[] = [] + for (const candidate of customCandidates) { + if (fs.existsSync(candidate)) { + try { + customActivities = JSON.parse(fs.readFileSync(candidate, 'utf8')) as ActivityQueries[] + bot.logger.debug( + bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Loaded ${customActivities.length} custom activity queries from ${candidate}` + ) + break + } catch (e) { + bot.logger.warn( + bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Failed reading custom queries file ${candidate} | ${e instanceof Error ? e.message : String(e)}` + ) + } + } + } + + // Helper to check if an activity entry matches the promotion + const isMatch = (activityTitle: string): boolean => { + const normActivity = bot.utils.normalizeString(activityTitle) + const normPromotionTitle = bot.utils.normalizeString(promotion.title ?? '') + const normOfferId = bot.utils.normalizeString(promotion.offerId ?? '') + + if (!normActivity) return false + + // 1. Exact match on title (localized or English) + if (normPromotionTitle && normActivity === normPromotionTitle) return true + + // 2. Exact match on offerId + if (normOfferId && normActivity === normOfferId) return true + + // 3. Keyword match in offerId (e.g. activityTitle "recipe" in offerId "ENUS_recipe_exploreonbing...") + if (normOfferId && normOfferId.includes(normActivity)) return true + + return false + } + + // 1. Check custom dictionary first (prioritizes local offerId / localized overrides) + // If failedQueries were provided, this is a retry and custom match shouldn't loop indefinitely + if (!failedQueries?.length) { + const customMatch = customActivities.find(activity => isMatch(activity.title)) + if (customMatch?.queries.length) { + const shuffled = bot.utils.shuffleArray(customMatch.queries) + bot.logger.info( + bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Found ${shuffled.length} queries for "${promotion.title}" (${promotion.offerId}) | source=custom` + ) + return shuffled + } + } + + // 2. Check stock/remote dictionary + if (!failedQueries?.length) { + const match = activities.find(activity => isMatch(activity.title)) + if (match?.queries.length) { + const shuffled = bot.utils.shuffleArray(match.queries) + bot.logger.info( + bot.isMobile, + 'SEARCH-ON-BING-QUERY', + `Found ${shuffled.length} queries for "${promotion.title}" (${promotion.offerId}) | source=${bot.config.searchOnBingLocalQueries ? 'local' : 'remote'}` + ) + return shuffled + } + } + + // 3. Optional AI query generator for localized/unhandled tasks + if (bot.config.experimental.aiQueryGenerator) { bot.logger.info( bot.isMobile, - 'SEARCH-ON-BING-QUERY', - `Found ${shuffled.length} queries for "${promotion.title}" | source=${bot.config.searchOnBingLocalQueries ? 'local' : 'remote'}` + 'SEARCH-ON-BING-AI', + `Generating AI queries for "${promotion.title}" (${promotion.offerId})${failedQueries?.length ? ` | retrying without ${failedQueries.length} failed queries` : ''}` + ) + const aiQueries = await generateAiQueries( + promotion.title ?? '', + promotion.description ?? '', + failedQueries + ) + if (aiQueries.length > 0) { + bot.logger.info( + bot.isMobile, + 'SEARCH-ON-BING-AI', + `Received ${aiQueries.length} AI queries for "${promotion.title}" | queries=${JSON.stringify(aiQueries)}`, + 'cyan' + ) + return aiQueries + } + bot.logger.warn( + bot.isMobile, + 'SEARCH-ON-BING-AI', + `AI query generator returned 0 queries, falling back to heuristics` ) - return shuffled } + // 4. No curated/AI match — fall back safely to the activity description and title + const fallback = fallbackQueries(promotion) bot.logger.info( bot.isMobile, 'SEARCH-ON-BING-QUERY', - `No curated queries for "${promotion.title}", falling back to the activity title and description` + `No curated queries for "${promotion.title}" (${promotion.offerId}), falling back to activity description/title | queriesCount=${fallback.length}` ) - return fallbackQueries(promotion) + return fallback } catch (error) { bot.logger.error( bot.isMobile, @@ -140,3 +239,48 @@ function extractSearchTerm(description: string): string { .replace(/[.!?]+$/g, '') .trim() } + +interface FailedActivityEntry { + offerId: string + title: string + description: string + reason: string + lastAttempt: string +} + +export function recordFailedSearchOnBing(promotion: BasePromotion, reason: string): void { + try { + const configDir = path.join(process.cwd(), 'config') + const filePath = fs.existsSync(configDir) + ? path.join(configDir, 'failed.json') + : path.join(process.cwd(), 'failed.json') + let entries: FailedActivityEntry[] = [] + + if (fs.existsSync(filePath)) { + try { + entries = JSON.parse(fs.readFileSync(filePath, 'utf8')) as FailedActivityEntry[] + } catch { + entries = [] + } + } + + const existingIndex = entries.findIndex(e => e.offerId === promotion.offerId) + const entry: FailedActivityEntry = { + offerId: promotion.offerId, + title: promotion.title ?? '', + description: promotion.description ?? '', + reason, + lastAttempt: new Date().toISOString() + } + + if (existingIndex >= 0) { + entries[existingIndex] = entry + } else { + entries.push(entry) + } + + fs.writeFileSync(filePath, JSON.stringify(entries, null, 4), 'utf8') + } catch { + // Silently ignore disk write issues so bot execution is never disrupted + } +} diff --git a/src/interface/Config.ts b/src/interface/Config.ts index d8261f32..e62caf5d 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -48,6 +48,7 @@ export interface ConfigExperimental { apiSearchOnBing: boolean blockMedia: boolean edgeBrowsing: boolean + aiQueryGenerator?: boolean } export interface ConfigProxy { diff --git a/src/util/ConfigEnvOverrides.ts b/src/util/ConfigEnvOverrides.ts index 10dc65e3..cf1da932 100644 --- a/src/util/ConfigEnvOverrides.ts +++ b/src/util/ConfigEnvOverrides.ts @@ -59,6 +59,7 @@ export const ENV_OVERRIDES: EnvOverrideEntry[] = [ { env: 'CONFIG_EXPERIMENTAL_API_SEARCH_ON_BING', path: 'experimental.apiSearchOnBing', type: 'bool' }, { env: 'CONFIG_EXPERIMENTAL_BLOCK_MEDIA', path: 'experimental.blockMedia', type: 'bool' }, { env: 'CONFIG_EXPERIMENTAL_EDGE_BROWSING', path: 'experimental.edgeBrowsing', type: 'bool' }, + { env: 'CONFIG_EXPERIMENTAL_AI_QUERY_GENERATOR', path: 'experimental.aiQueryGenerator', type: 'bool' }, // Proxy { env: 'CONFIG_PROXY_QUERY_ENGINE', path: 'proxy.queryEngine', type: 'bool' }, diff --git a/src/util/Validator.ts b/src/util/Validator.ts index 907cc998..04b95137 100644 --- a/src/util/Validator.ts +++ b/src/util/Validator.ts @@ -150,9 +150,16 @@ export const ConfigSchema = z.object({ apiSearch: z.boolean().default(false), apiSearchOnBing: z.boolean().default(false), blockMedia: z.boolean().default(false), - edgeBrowsing: z.boolean().default(false) + edgeBrowsing: z.boolean().default(false), + aiQueryGenerator: z.boolean().default(false) }) - .default({ apiSearch: false, apiSearchOnBing: false, blockMedia: false, edgeBrowsing: false }), + .default({ + apiSearch: false, + apiSearchOnBing: false, + blockMedia: false, + edgeBrowsing: false, + aiQueryGenerator: false + }), debugLogs: z.boolean(), proxy: z.object({ queryEngine: z.boolean(), @@ -291,7 +298,8 @@ const defaultConfig: Config = { apiSearch: false, apiSearchOnBing: false, blockMedia: false, - edgeBrowsing: false + edgeBrowsing: false, + aiQueryGenerator: false }, debugLogs: false, proxy: { queryEngine: true, ignoreCertificateErrors: false }, From 0b626cbcb579f8d74d4ad638eecb6d164d8d8d66 Mon Sep 17 00:00:00 2001 From: phanex Date: Fri, 25 Sep 2026 18:18:41 +0200 Subject: [PATCH 3/3] feat(rewards): optimize failed.json structure for easy editing and enforce file permissions --- .../search/AiSearchQueryGenerator.ts | 1 + .../activities/search/BrowserSearchOnBing.ts | 9 +++-- .../activities/search/SearchOnBingShared.ts | 34 ++++++++++++++----- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/functions/activities/search/AiSearchQueryGenerator.ts b/src/functions/activities/search/AiSearchQueryGenerator.ts index 1b3b6412..c12f8f4d 100644 --- a/src/functions/activities/search/AiSearchQueryGenerator.ts +++ b/src/functions/activities/search/AiSearchQueryGenerator.ts @@ -155,6 +155,7 @@ export function saveSuccessfulQuery(offerId: string, title: string, query: strin if (process.env.PUID && process.env.PGID) { try { fs.chownSync(customPath, Number(process.env.PUID), Number(process.env.PGID)) + fs.chmodSync(customPath, 0o666) } catch { // Ignore if not supported (e.g. Windows) } diff --git a/src/functions/activities/search/BrowserSearchOnBing.ts b/src/functions/activities/search/BrowserSearchOnBing.ts index 6e86c871..0e5014de 100644 --- a/src/functions/activities/search/BrowserSearchOnBing.ts +++ b/src/functions/activities/search/BrowserSearchOnBing.ts @@ -28,6 +28,8 @@ export class SearchOnBing extends BaseActivity { `Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentBalance=${this.oldBalance}` ) + let queries: string[] = [] + try { const activated = await activateSearchOnBing(this.bot, promotion) if (!activated) { @@ -40,7 +42,7 @@ export class SearchOnBing extends BaseActivity { return } - let queries = await getSearchOnBingQueries(this.bot, promotion) + queries = await getSearchOnBingQueries(this.bot, promotion) await this.searchBing(page, queries, promotion) // If not completed and AI query generator is enabled, attempt 1 retry with alternative AI queries @@ -68,7 +70,8 @@ export class SearchOnBing extends BaseActivity { } else { recordFailedSearchOnBing( promotion, - `Queries exhausted without completion (tried ${queries.length} queries)` + `Queries exhausted without completion (tried ${queries.length} queries)`, + queries ) this.bot.logger.warn( this.bot.isMobile, @@ -77,7 +80,7 @@ export class SearchOnBing extends BaseActivity { ) } } catch (error) { - recordFailedSearchOnBing(promotion, error instanceof Error ? error.message : String(error)) + recordFailedSearchOnBing(promotion, error instanceof Error ? error.message : String(error), queries ?? []) this.bot.logger.error( this.bot.isMobile, 'SEARCH-ON-BING', diff --git a/src/functions/activities/search/SearchOnBingShared.ts b/src/functions/activities/search/SearchOnBingShared.ts index 568ffa2c..20b92bb1 100644 --- a/src/functions/activities/search/SearchOnBingShared.ts +++ b/src/functions/activities/search/SearchOnBingShared.ts @@ -241,14 +241,18 @@ function extractSearchTerm(description: string): string { } interface FailedActivityEntry { - offerId: string title: string - description: string + cardTitle?: string + description?: string reason: string - lastAttempt: string + queries: string[] } -export function recordFailedSearchOnBing(promotion: BasePromotion, reason: string): void { +export function recordFailedSearchOnBing( + promotion: BasePromotion, + reason: string, + attemptedQueries: string[] = [] +): void { try { const configDir = path.join(process.cwd(), 'config') const filePath = fs.existsSync(configDir) @@ -264,13 +268,16 @@ export function recordFailedSearchOnBing(promotion: BasePromotion, reason: strin } } - const existingIndex = entries.findIndex(e => e.offerId === promotion.offerId) + const offerId = promotion.offerId + const existingIndex = entries.findIndex(e => e.title === offerId) + + const uniqueQueries = [...new Set(attemptedQueries.map(q => q.trim()).filter(Boolean))] const entry: FailedActivityEntry = { - offerId: promotion.offerId, - title: promotion.title ?? '', - description: promotion.description ?? '', + title: offerId, + ...(promotion.title && promotion.title !== offerId ? { cardTitle: promotion.title } : {}), + ...(promotion.description ? { description: promotion.description } : {}), reason, - lastAttempt: new Date().toISOString() + queries: uniqueQueries } if (existingIndex >= 0) { @@ -280,6 +287,15 @@ export function recordFailedSearchOnBing(promotion: BasePromotion, reason: strin } fs.writeFileSync(filePath, JSON.stringify(entries, null, 4), 'utf8') + + if (process.env.PUID && process.env.PGID) { + try { + fs.chownSync(filePath, Number(process.env.PUID), Number(process.env.PGID)) + fs.chmodSync(filePath, 0o666) + } catch { + // Ignore if not supported on the host OS + } + } } catch { // Silently ignore disk write issues so bot execution is never disrupted }