Skip to content

Commit 63865cc

Browse files
committed
Add visual search support and search refactor
Implements desktop visual-search support and refactors search/punchcard flows. Key changes: - New VisualSearch worker and API integration (acquire/report visual searches) in BrowserFunc. - Added config option workers.doVisualSearch and docker env var mapping. - Integrated visual-search into main flow with desktop session handling. - Refactored SearchManager into planning & separate mobile/desktop/bonus methods. - PunchcardManager simplified and runDesktop/runMobile separation improved. - Login improvements: email-proof handling and selector tweaks; ReactFunc parsing fixes. - Updated README and config.example.
1 parent a80d609 commit 63865cc

15 files changed

Lines changed: 798 additions & 143 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ ACCOUNT_1_PASSWORD=your_password
100100
```
101101

102102
- Review `compose.yaml` to adjust scheduling, timezone, and config options.
103-
103+
104104
> [!NOTE]
105105
> A valid `config.json` is auto-generated on first run using default values, and saved locally to `./config/`.
106106
> Optionally, use `CONFIG_*` variables in the `environment:` section of the `compose.yaml` to customise your options (e.g., clusters, webhook, etc.).
@@ -112,6 +112,7 @@ ACCOUNT_1_PASSWORD=your_password
112112
> To update, delete `./config/config.json` and restart — a fresh one will be generated from the latest example, with your `compose.yaml` overrides re-applied.
113113
114114
- Start the container: `docker compose up -d`
115+
115116
> [!TIP]
116117
> Monitor logs with `docker logs microsoft-rewards-script`, useful for viewing passwordless login codes or diagnosing issues.
117118
> You can also enable a webhook in `compose.yaml` for notifications.
@@ -160,6 +161,7 @@ Edit `config.json` to customize behavior, or set `CONFIG_*` environment variable
160161
| `workers.doDailyCheckIn` | boolean | `true` | Complete daily check-in | `CONFIG_WORKER_DAILY_CHECKIN` |
161162
| `workers.doReadToEarn` | boolean | `true` | Complete Read-to-Earn | `CONFIG_WORKER_READ_TO_EARN` |
162163
| `workers.doActivateSearchPerk` | boolean | `true` | Activate the "search Nx more" perk when present (runs after the daily set) | `CONFIG_WORKER_ACTIVATE_SEARCH_PERK` |
164+
| `workers.doVisualSearch` | boolean | `false` | Activate the visual-search streak and perform visual searches | `CONFIG_WORKER_VISUAL_SEARCH` |
163165

164166
### Activities
165167

config.example.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"doBonusSearches": false,
1818
"doDailyCheckIn": true,
1919
"doReadToEarn": true,
20-
"doActivateSearchPerk": true
20+
"doActivateSearchPerk": true,
21+
"doVisualSearch": false
2122
},
2223
"activities": {
2324
"urlReward": true,

scripts/docker/entrypoint.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ fi
7575
# CONFIG_WORKER_DAILY_CHECKIN → .workers.doDailyCheckIn
7676
# CONFIG_WORKER_READ_TO_EARN → .workers.doReadToEarn
7777
# CONFIG_WORKER_ACTIVATE_SEARCH_PERK → .workers.doActivateSearchPerk
78+
# CONFIG_WORKER_VISUAL_SEARCH → .workers.doVisualSearch
7879
#
7980
# Search settings:
8081
# CONFIG_SEARCH_SCROLL_RANDOM → .searchSettings.scrollRandomResults
@@ -240,6 +241,7 @@ _cfg "${CONFIG_WORKER_BONUS_SEARCHES:-}" '.workers.doBonusSearches'
240241
_cfg "${CONFIG_WORKER_DAILY_CHECKIN:-}" '.workers.doDailyCheckIn' bool
241242
_cfg "${CONFIG_WORKER_READ_TO_EARN:-}" '.workers.doReadToEarn' bool
242243
_cfg "${CONFIG_WORKER_ACTIVATE_SEARCH_PERK:-}" '.workers.doActivateSearchPerk' bool
244+
_cfg "${CONFIG_WORKER_VISUAL_SEARCH:-}" '.workers.doVisualSearch' bool
243245

244246
# Search settings
245247
_cfg "${CONFIG_SEARCH_SCROLL_RANDOM:-}" '.searchSettings.scrollRandomResults' bool

src/browser/BrowserFunc.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import type { AppUserData } from '../interface/AppUserData'
1414
import type { AppEarnablePoints, BrowserEarnablePoints, MissingSearchPoints } from '../interface/Points'
1515
import type { AppDashboardData } from '../interface/AppDashBoardData'
1616

17+
// Bing-hosted image used to seed the daily visual search. /images/kblob fetches it (Can be changed)
18+
const VISUAL_SEARCH_IMAGE_URL = 'https://th.bing.com/th?id=OMR.VisualSearch.VNext.BackgroundImage.png&pid=Rewards'
19+
1720
export default class BrowserFunc {
1821
private bot: MicrosoftRewardsBot
1922

@@ -636,6 +639,184 @@ export default class BrowserFunc {
636639
return { ig, ...parsed, gained }
637640
}
638641

642+
async reportVisualSearchActivity(visual: { bcid: string; query: string; serpUrl: string }): Promise<{
643+
ig: string | null
644+
balance: number | null
645+
previousBalance: number | null
646+
gained: number | null
647+
searchPointsEarned: number | null
648+
searchPointsLimit: number | null
649+
}> {
650+
const { bcid, query, serpUrl } = visual
651+
652+
const jar = this.getBingJar()
653+
654+
const base = { ...(this.bot.fingerprint?.headers ?? {}) }
655+
delete base['Cookie']
656+
delete base['cookie']
657+
658+
const empty = {
659+
ig: null,
660+
balance: null,
661+
previousBalance: null,
662+
searchPointsEarned: null,
663+
searchPointsLimit: null
664+
}
665+
666+
const searchRes = await this.bot.http.request({
667+
url: serpUrl,
668+
method: 'GET',
669+
headers: {
670+
...base,
671+
Cookie: this.jarToHeader(jar),
672+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
673+
'Sec-Fetch-Dest': 'document',
674+
'Sec-Fetch-Mode': 'navigate',
675+
'Sec-Fetch-Site': 'none',
676+
'Sec-Fetch-User': '?1',
677+
'Upgrade-Insecure-Requests': '1'
678+
}
679+
})
680+
this.mergeSetCookies(jar, searchRes.headers?.['set-cookie'] as string[] | string | undefined)
681+
682+
const ig =
683+
typeof searchRes.data === 'string'
684+
? ((searchRes.data.match(/\bIG:"([A-F0-9]{32})"/i) ??
685+
searchRes.data.match(/[?&]IG=([A-F0-9]{32})\b/i))?.[1] ?? null)
686+
: null
687+
if (!ig) {
688+
this.bot.logger.warn(
689+
this.bot.isMobile,
690+
'VISUAL-SEARCH-REPORT',
691+
`No IG for "${query}" - visual SERP not served as expected`
692+
)
693+
return { ...empty, gained: null }
694+
}
695+
696+
const params = new URLSearchParams({
697+
IG: ig,
698+
IID: 'SERP.5064',
699+
q: query,
700+
bcid,
701+
FORM: 'SBIHMP',
702+
hq: '1',
703+
ajaxreq: '1'
704+
})
705+
const reportUrl = `${URLs.bing.origin}/rewardsapp/reportActivity?${params.toString()}`
706+
707+
const reportRes = await this.bot.http.request({
708+
url: reportUrl,
709+
method: 'POST',
710+
headers: {
711+
...base,
712+
Cookie: this.jarToHeader(jar),
713+
Accept: '*/*',
714+
'Content-Type': 'application/x-www-form-urlencoded',
715+
Referer: serpUrl,
716+
Origin: URLs.bing.origin,
717+
'Sec-Fetch-Dest': 'empty',
718+
'Sec-Fetch-Mode': 'cors',
719+
'Sec-Fetch-Site': 'same-origin',
720+
'X-Requested-With': 'XMLHttpRequest'
721+
},
722+
data: `url=${encodeURIComponent(serpUrl)}&V=web`
723+
})
724+
this.mergeSetCookies(jar, reportRes.headers?.['set-cookie'] as string[] | string | undefined)
725+
726+
const parsed = this.parseReportResponse(reportRes.data)
727+
const gained =
728+
parsed.balance != null && parsed.previousBalance != null ? parsed.balance - parsed.previousBalance : null
729+
730+
this.bot.logger.debug(
731+
this.bot.isMobile,
732+
'VISUAL-SEARCH-REPORT',
733+
`Reported "${query}" | ig=${ig} | bcid=${bcid.slice(0, 12)} | gained=${gained ?? 'n/a'} | balance=${parsed.balance ?? 'n/a'} | searchPts=${parsed.searchPointsEarned ?? 'n/a'}/${parsed.searchPointsLimit ?? 'n/a'}`
734+
)
735+
736+
return { ig, ...parsed, gained }
737+
}
738+
739+
async acquireVisualSearch(
740+
imageUrl: string = VISUAL_SEARCH_IMAGE_URL
741+
): Promise<{ bcid: string; query: string; serpUrl: string } | null> {
742+
try {
743+
const jar = this.getBingJar()
744+
const base = { ...(this.bot.fingerprint?.headers ?? {}) }
745+
delete base['Cookie']
746+
delete base['cookie']
747+
748+
const enc = encodeURIComponent(imageUrl)
749+
const url =
750+
`${URLs.bing.origin}/images/kblob` + `?iss=sbi&form=SBIHMP&sbisrc=UrlPaste&vsimg=${enc}&imgurl=${enc}`
751+
752+
const boundary = `----WebKitFormBoundary${randomBytes(8).toString('hex')}`
753+
const body = this.buildMultipart(boundary, [
754+
{ name: 'cbir', value: 'sbi' },
755+
{ name: 'imageBin', value: '' },
756+
{ name: 'imgurl', value: '' }
757+
])
758+
759+
const res = await this.bot.http.request({
760+
url,
761+
method: 'POST',
762+
headers: {
763+
...base,
764+
Cookie: this.jarToHeader(jar),
765+
Accept: 'application/json',
766+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
767+
Referer: `${URLs.bing.origin}/visualsearch`,
768+
Origin: URLs.bing.origin,
769+
'Sec-Fetch-Dest': 'empty',
770+
'Sec-Fetch-Mode': 'cors',
771+
'Sec-Fetch-Site': 'same-origin'
772+
},
773+
data: body
774+
})
775+
this.mergeSetCookies(jar, res.headers?.['set-cookie'] as string[] | string | undefined)
776+
777+
const redirectUrl = this.parseKblobRedirect(res.data)
778+
if (!redirectUrl) {
779+
const dump = typeof res.data === 'string' ? res.data : JSON.stringify(res.data ?? '')
780+
this.bot.logger.warn(
781+
this.bot.isMobile,
782+
'VISUAL-SEARCH-BCID',
783+
`kblob returned no redirectUrl | status=${res.status} - the endpoint/shape may have changed`
784+
)
785+
this.bot.logger.debug(this.bot.isMobile, 'VISUAL-SEARCH-BCID', `kblob response: ${dump.slice(0, 400)}`)
786+
return null
787+
}
788+
789+
const qs = new URLSearchParams(redirectUrl.split('?')[1] ?? '')
790+
const bcid = qs.get('bcid')
791+
if (!bcid) {
792+
this.bot.logger.warn(this.bot.isMobile, 'VISUAL-SEARCH-BCID', `redirect had no bcid | ${redirectUrl}`)
793+
return null
794+
}
795+
796+
const query = qs.get('q') ?? ''
797+
const serpUrl = `${URLs.bing.origin}${redirectUrl}`
798+
799+
this.bot.logger.info(
800+
this.bot.isMobile,
801+
'VISUAL-SEARCH-BCID',
802+
`Acquired bcid=${bcid.slice(0, 14)} | q="${query}" | status=${res.status}`,
803+
'green'
804+
)
805+
return { bcid, query, serpUrl }
806+
} catch (error) {
807+
this.bot.logger.warn(
808+
this.bot.isMobile,
809+
'VISUAL-SEARCH-BCID',
810+
`Failed to acquire visual search | ${error instanceof Error ? error.message : String(error)}`
811+
)
812+
return null
813+
}
814+
}
815+
816+
resetHttpJars(): void {
817+
this.bingJars.clear()
818+
}
819+
639820
private getBingJar(): Map<string, string> {
640821
const src = this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop
641822
const key = `${src.find(c => c.name === '_U')?.value ?? ''}|${this.bot.isMobile}`
@@ -693,4 +874,33 @@ export default class BrowserFunc {
693874
return empty
694875
}
695876
}
877+
878+
private buildMultipart(boundary: string, fields: { name: string; value: string }[]): Buffer {
879+
const parts: Buffer[] = []
880+
for (const f of fields) {
881+
parts.push(
882+
Buffer.from(
883+
`--${boundary}\r\nContent-Disposition: form-data; name="${f.name}"\r\n\r\n${f.value}\r\n`,
884+
'utf8'
885+
)
886+
)
887+
}
888+
parts.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'))
889+
return Buffer.concat(parts)
890+
}
891+
892+
private parseKblobRedirect(data: unknown): string | null {
893+
try {
894+
const obj = typeof data === 'string' ? JSON.parse(data) : data
895+
const url = (obj as { redirectUrl?: unknown })?.redirectUrl
896+
if (typeof url === 'string' && url.includes('bcid=')) return url
897+
} catch {}
898+
899+
if (typeof data === 'string') {
900+
const m = data.match(/"redirectUrl"\s*:\s*"([^"]+)"/)
901+
const raw = m?.[1]
902+
if (raw && raw.includes('bcid=')) return raw.replace(/\\u002f/gi, '/').replace(/\\\//g, '/')
903+
}
904+
return null
905+
}
696906
}

src/browser/ReactFunc.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,8 @@ export default class ReactFunc {
383383
)
384384

385385
if (account.level === null && account.availablePoints === null) {
386-
this.bot.logger.warn(
386+
// Common error! Keep however for debugging!
387+
this.bot.logger.debug(
387388
this.bot.isMobile,
388389
'REACT-PARSE',
389390
'Account state empty - membership/header objects not found in payload'
@@ -553,27 +554,16 @@ export default class ReactFunc {
553554
const out: QuestChild[] = []
554555
const seen = new Set<string>()
555556

556-
const idRe = /"offerId":"([^"]*pcchild[^"]*)"/g
557-
for (const questMatch of combined.matchAll(idRe)) {
558-
const offerId = questMatch[1] as string
559-
if (seen.has(offerId)) continue
557+
for (const obj of this.extractObjects(combined, '"offerId"')) {
558+
const offerId = obj.offerId as string | undefined
559+
if (!offerId || !offerId.includes('pcchild') || seen.has(offerId)) continue
560560
seen.add(offerId)
561561

562-
const at = questMatch.index ?? 0
563-
const childAt = combined.indexOf('"children"', at)
564-
const windowEnd = childAt !== -1 && childAt - at < 1200 ? childAt : Math.min(at + 600, combined.length)
565-
const region = combined.slice(at, windowEnd)
566-
567-
const hashMatch = region.match(/"hash":"([0-9a-f]{64})"/)
568-
const hash = hashMatch?.[1] ?? null
569-
570-
const pointsMatch = region.match(/"points":(\d+)/)
571-
const points = pointsMatch ? Number(pointsMatch[1]) : 0
572-
573-
const flag = (name: string): boolean => new RegExp(`"${name}":true`).test(region)
574-
const isCompleted = flag('isCompleted')
575-
const isLocked = flag('isLocked')
576-
const isDisabled = flag('isDisabled')
562+
const hash = (obj.hash as string | null) ?? null
563+
const points = (obj.points as number) ?? (obj.pointProgressMax as number) ?? 0
564+
const isCompleted = ((obj.isCompleted ?? obj.complete) as boolean | undefined) === true
565+
const isLocked = (obj.isLocked as boolean | undefined) === true
566+
const isDisabled = (obj.isDisabled as boolean | undefined) === true
577567

578568
const reportable = !!hash && !isCompleted && !isLocked && !isDisabled
579569

0 commit comments

Comments
 (0)