@@ -14,6 +14,9 @@ import type { AppUserData } from '../interface/AppUserData'
1414import type { AppEarnablePoints , BrowserEarnablePoints , MissingSearchPoints } from '../interface/Points'
1515import 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+
1720export 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 ( / \b I G : " ( [ A - F 0 - 9 ] { 32 } ) " / i) ??
685+ searchRes . data . match ( / [ ? & ] I G = ( [ A - F 0 - 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 ( / " r e d i r e c t U r l " \s * : \s * " ( [ ^ " ] + ) " / )
901+ const raw = m ?. [ 1 ]
902+ if ( raw && raw . includes ( 'bcid=' ) ) return raw . replace ( / \\ u 0 0 2 f / gi, '/' ) . replace ( / \\ \/ / g, '/' )
903+ }
904+ return null
905+ }
696906}
0 commit comments