Private Research Workspace

Hotel Rate Intelligence.

Access restricted. Enter your email and password to continue.

Two-factor authentication · Search results kept in temporary memory · HTTPS required

+(groups*.60).toFixed(2)+'. Your limit allows '+Math.min(groups,attempts)+' new Agent runs. This is NOT a provider-enforced spending limit; only verified rates are shown.' : 'Select hotels and dates to estimate the number of attempts.'; } $('creditBudget').oninput=updateCreditEstimate; $('batchSize').onchange=updateCreditEstimate; $('hotelRows').addEventListener('input',updateCreditEstimate); function getHotels(){return [...$('hotelRows').children].map(row=>({name:row.querySelector('.hname').value.trim(),city:row.querySelector('.city').value.trim(),officialUrl:row.querySelector('.url').value.trim()})).filter(x=>x.name);} function startStream(){ if(!state.job)return;closeStreams(); const es=new EventSource('/api/jobs/events',{withCredentials:true});state.stream=es; es.addEventListener('snapshot',e=>{try{const obj=JSON.parse(e.data);if(obj&&obj.id===state.job?.id){state.job=obj;renderJob();}}catch{}}); es.addEventListener('cell',e=>{try{const obj=JSON.parse(e.data);let i=state.job?.cells.findIndex(c=>c.key===obj.key);if(i>=0){state.job.cells[i]=obj;renderCell(obj);} }catch{}}); es.addEventListener('progress',e=>{try{const obj=JSON.parse(e.data);if(obj.id===state.job?.id){state.job={...state.job,...obj};renderJob();}}catch{}}); es.addEventListener('done',e=>{try{const obj=JSON.parse(e.data);if(obj.id===state.job?.id){state.job={...state.job,...obj};renderJob();}}catch{};closeStreams();}); es.onerror=()=>{if(!state.poll){state.poll=setInterval(async()=>{try{const j=(await request('/api/jobs')).job;if(j){state.job=j;renderJob();} }catch{}},3500);}}; } function closeStreams(){if(state.stream){state.stream.close();state.stream=null;}if(state.poll){clearInterval(state.poll);state.poll=null;}} $('startSearch').onclick=async()=>{ try{ if(!state.dates.length)throw Error('Generate or import observation dates first.'); const hotels=getHotels();if(!hotels.length||hotels.length>10)throw Error('Please provide 1–10 hotels.'); if(!$('approve').checked)throw Error('Confirm the maximum scraping credit allocation.'); const budget=Number($('creditBudget').value);if(budget<60||budget>120000)throw Error('Credit budget must be 60–120,000.'); if(state.job&&['running','budget_paused'].includes(state.job.state))throw Error('Cancel the current search first.'); const r=await request('/api/jobs/start',{hotels,dates:state.dates,sourceMode:state.sourceMode,batchSize:Number($('batchSize').value),creditsLimit:budget,confirmCost:true}); state.job=r.job;state.selection=null;renderJob();startStream();setMobilePanel('results'); }catch(e){notice(e.message);} }; $('cancelTop').onclick=async()=>{ try{const r=await request('/api/jobs/cancel',{});state.job=r.job;renderJob();closeStreams();flash('Search stopped. Already-found rates remain available for export.',true);} catch(e){flash(e.message);} }; function cellMarkup(c){ if(!c)return '
—
'; if(c.excluded)return '
Excluded
'; let inner='',stateName=c.status; if(c.rate!==null&&c.rate!==undefined&&Number(c.rate)>0){ inner='
'+escape(Number(c.rate).toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:2}))+' '+escape(c.currency||'')+'
'; const source=c.source.includes('Booking.com')?'Booking.com · OTA':c.status==='needs_review'?'Official · review':'Official website'; inner+='
'+escape(source)+'
'; inner+=''; if(c.sourceUrl)inner+='↗'; }else{ let label={queued:'Queued',processing:c.stage||'Checking…',unavailable:'No eligible rate',failed:'Failed',cancelled:'Cancelled'}[stateName]||'—'; inner='
'+escape(label)+'
'; } return inner; } function renderMobile(){ const job=state.job;if(!job)return; const sel=$('mobileHotel'),old=sel.value; const expected=job.hotels.map((hotel,i)=>'').join(''); if(sel.dataset.job!==job.id){sel.innerHTML=expected;sel.dataset.job=job.id;sel.value='0';} else if(old!==sel.value)sel.value=old; const index=Number(sel.value)||0; const map=new Map(job.cells.filter(c=>c.hotelIndex===index).map(c=>[c.date,c])); $('mobileRates').innerHTML=job.dates.map(date=>{ const cell=map.get(date),found=cell&&!cell.excluded&&Number.isFinite(cell.rate)&&cell.rate>0; const source=cell?.source?.includes('Booking.com')?'Booking.com · OTA':cell?.status==='needs_review'?'Official · review':'Official website'; const note=found?source:cell?.excluded?'Excluded':(cell?.stage||'Queued'); const value=found?Number(cell.rate).toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:2})+' '+escape(cell.currency):'—'; return '
'+escape(date)+'
'+value+'
'+escape(note)+'
'+ (found&&cell.sourceUrl?'↗':'')+ (found?'':cell?.excluded?'':'')+'
'; }).join(''); } $('mobileHotel').onchange=()=>{state.selection=null;renderMobile();}; $('mobileRates').addEventListener('click',async e=>{ const exclude=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]'); if(!exclude&&!restore)return; try{ const key=(exclude||restore).dataset.exclude||(exclude||restore).dataset.restore; const res=await request('/api/jobs/exclude',{key,excluded:!!exclude}); const at=state.job.cells.findIndex(c=>c.key===key); if(at>=0)state.job.cells[at]=res.cell; renderCell(res.cell);renderMobile(); }catch(ex){flash(ex.message);} }); function renderCell(c){ const td=document.querySelector('[data-cell-key="'+CSS.escape(c.key)+'"]');if(!td)return; td.innerHTML=cellMarkup(c);td.classList.toggle('excluded',!!c.excluded); td.title=c.reason||c.source||c.stage||''; if(window.matchMedia('(max-width:760px)').matches)renderMobile(); } function renderJob(){ const j=state.job;if(!j){$('cancelTop').disabled=true;return;} $('excel').disabled=false;$('cancelTop').disabled=!['running','budget_paused'].includes(j.state); $('progressLabel').textContent=j.finished+' / '+j.total+' completed'; $('stateBadge').textContent=j.state.toUpperCase().replace('_',' '); $('stage').textContent=j.stage||'Processing hotel observations'; $('barFill').style.width=j.percent+'%';$('bar').setAttribute('aria-valuenow',j.percent); $('verified').textContent=j.cells.filter(c=>['verified','needs_review'].includes(c.status)&&!c.excluded).length; $('fallback').textContent=j.cells.filter(c=>c.status==='booking_fallback'&&!c.excluded).length; $('notFound').textContent=j.cells.filter(c=>['unavailable','failed','cancelled'].includes(c.status)).length; $('credits').textContent=j.creditsReserved+' / '+j.creditsLimit; renderMobile(); const previous=$('ratesTable').dataset.id; if(previous===j.id && $('ratesTable').querySelectorAll('tbody tr').length===j.dates.length){ j.cells.forEach(renderCell);return; } const cols=['Stay date',...j.hotels.map((h,i)=>''+escape(h.name)+'')]; const data=new Map(j.cells.map(c=>[c.key,c])); $('ratesTable').querySelector('thead').innerHTML=''+cols.join('')+''; $('ratesTable').querySelector('tbody').innerHTML=j.dates.map((d,r)=>{ return ''+escape(d)+''+j.hotels.map((h,i)=>{ const c=data.get(i+'|'+d);return ''+cellMarkup(c)+''; }).join('')+''; }).join(''); $('ratesTable').dataset.id=j.id;markSelection(); } function rowCol(el){const td=el.closest('[data-r][data-c]');return td?{r:Number(td.dataset.r),c:Number(td.dataset.c)}:null;} function markSelection(){ document.querySelectorAll('.selected').forEach(x=>x.classList.remove('selected')); if(!state.selection)return; const {r1,r2,c1,c2}=state.selection; document.querySelectorAll('#ratesTable [data-r][data-c]').forEach(td=>{ const r=Number(td.dataset.r),c=Number(td.dataset.c); if(r>=Math.min(r1,r2)&&r<=Math.max(r1,r2)&&c>=Math.min(c1,c2)&&c<=Math.max(c1,c2))td.classList.add('selected'); }); } const table=$('ratesTable'); table.addEventListener('mousedown',e=>{if(e.target.closest('button,a'))return;const x=rowCol(e.target);if(!x)return;e.preventDefault();state.mouseDown=true;state.anchor=x;state.selection={r1:x.r,r2:x.r,c1:x.c,c2:x.c};markSelection();}); table.addEventListener('mouseover',e=>{if(!state.mouseDown||!state.anchor)return;const x=rowCol(e.target);if(!x)return;state.selection={r1:state.anchor.r,r2:x.r,c1:state.anchor.c,c2:x.c};markSelection();}); document.addEventListener('mouseup',()=>state.mouseDown=false); table.addEventListener('click',async e=>{ const excluded=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]');const target=excluded||restore; if(!target)return; try{ const r=await request('/api/jobs/exclude',{key:target.dataset.exclude||target.dataset.restore,excluded:!!excluded}); const i=state.job.cells.findIndex(c=>c.key===r.cell.key);if(i>=0)state.job.cells[i]=r.cell;renderCell(r.cell); }catch(ex){flash(ex.message);} }); function cellValue(r,c){ const j=state.job;if(!j)return ''; if(r===-1)return c===0?'Stay date':j.hotels[c-1]?.name||''; if(c===0)return j.dates[r]||''; const row=j.cells.find(x=>x.date===j.dates[r]&&x.hotelIndex===c-1); if(!row||row.excluded||row.rate===null||row.rate===undefined)return ''; return String(row.rate); } function visibleTSV(){ if(!state.job)return ''; let s=state.selection; if(!s&&window.matchMedia('(max-width:760px)').matches){ const hotelCol=Number($('mobileHotel').value||0)+1; return [['Stay date',state.job.hotels[hotelCol-1]?.name||''],...state.job.dates.map((date,row)=>[date,cellValue(row,hotelCol)])].map(row=>row.join('\t')).join('\r\n'); } if(!s)s={r1:-1,r2:state.job.dates.length-1,c1:0,c2:state.job.hotels.length}; const rows=[];for(let r=Math.min(s.r1,s.r2);r<=Math.max(s.r1,s.r2);r++){ const cols=[];for(let c=Math.min(s.c1,s.c2);c<=Math.max(s.c1,s.c2);c++)cols.push(cellValue(r,c)); rows.push(cols.join('\t')); }return rows.join('\r\n'); } async function copy(){ const text=visibleTSV();if(!text){flash('No visible cells to copy');return;} try{await navigator.clipboard.writeText(text);flash('Visible cells copied. Paste directly into Excel.',true);} catch{ const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-2000px';document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();flash('Copied spreadsheet cells',true); } } $('copy').onclick=copy; document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'&&state.job&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();copy();} }); $('excel').onclick=async()=>{ if(!state.job)return;try{ const r=await fetch('/api/jobs/export.xlsx',{credentials:'same-origin',cache:'no-store'}); if(!r.ok){const x=await r.json().catch(()=>({}));throw Error(x.error||'Excel export failed');} const b=await r.blob(),url=URL.createObjectURL(b),a=document.createElement('a'); a.href=url;a.download='hotel-rate-observations.xlsx';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000); flash('Excel file downloaded',true); }catch(e){flash(e.message);} }; checkSession(); setInterval(()=>{if(!$('workspace').hidden&&!state.tabClosing)request('/api/session').catch(()=>{});},60000); })(); +Number(j.budgetReservedUsd||0).toFixed(2)+' / renderMobile(); const previous=$('ratesTable').dataset.id; if(previous===j.id && $('ratesTable').querySelectorAll('tbody tr').length===j.dates.length){ j.cells.forEach(renderCell);return; } const cols=['Stay date',...j.hotels.map((h,i)=>''+escape(h.name)+'')]; const data=new Map(j.cells.map(c=>[c.key,c])); $('ratesTable').querySelector('thead').innerHTML=''+cols.join('')+''; $('ratesTable').querySelector('tbody').innerHTML=j.dates.map((d,r)=>{ return ''+escape(d)+''+j.hotels.map((h,i)=>{ const c=data.get(i+'|'+d);return ''+cellMarkup(c)+''; }).join('')+''; }).join(''); $('ratesTable').dataset.id=j.id;markSelection(); } function rowCol(el){const td=el.closest('[data-r][data-c]');return td?{r:Number(td.dataset.r),c:Number(td.dataset.c)}:null;} function markSelection(){ document.querySelectorAll('.selected').forEach(x=>x.classList.remove('selected')); if(!state.selection)return; const {r1,r2,c1,c2}=state.selection; document.querySelectorAll('#ratesTable [data-r][data-c]').forEach(td=>{ const r=Number(td.dataset.r),c=Number(td.dataset.c); if(r>=Math.min(r1,r2)&&r<=Math.max(r1,r2)&&c>=Math.min(c1,c2)&&c<=Math.max(c1,c2))td.classList.add('selected'); }); } const table=$('ratesTable'); table.addEventListener('mousedown',e=>{if(e.target.closest('button,a'))return;const x=rowCol(e.target);if(!x)return;e.preventDefault();state.mouseDown=true;state.anchor=x;state.selection={r1:x.r,r2:x.r,c1:x.c,c2:x.c};markSelection();}); table.addEventListener('mouseover',e=>{if(!state.mouseDown||!state.anchor)return;const x=rowCol(e.target);if(!x)return;state.selection={r1:state.anchor.r,r2:x.r,c1:state.anchor.c,c2:x.c};markSelection();}); document.addEventListener('mouseup',()=>state.mouseDown=false); table.addEventListener('click',async e=>{ const excluded=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]');const target=excluded||restore; if(!target)return; try{ const r=await request('/api/jobs/exclude',{key:target.dataset.exclude||target.dataset.restore,excluded:!!excluded}); const i=state.job.cells.findIndex(c=>c.key===r.cell.key);if(i>=0)state.job.cells[i]=r.cell;renderCell(r.cell); }catch(ex){flash(ex.message);} }); function cellValue(r,c){ const j=state.job;if(!j)return ''; if(r===-1)return c===0?'Stay date':j.hotels[c-1]?.name||''; if(c===0)return j.dates[r]||''; const row=j.cells.find(x=>x.date===j.dates[r]&&x.hotelIndex===c-1); if(!row||row.excluded||row.rate===null||row.rate===undefined)return ''; return String(row.rate); } function visibleTSV(){ if(!state.job)return ''; let s=state.selection; if(!s&&window.matchMedia('(max-width:760px)').matches){ const hotelCol=Number($('mobileHotel').value||0)+1; return [['Stay date',state.job.hotels[hotelCol-1]?.name||''],...state.job.dates.map((date,row)=>[date,cellValue(row,hotelCol)])].map(row=>row.join('\t')).join('\r\n'); } if(!s)s={r1:-1,r2:state.job.dates.length-1,c1:0,c2:state.job.hotels.length}; const rows=[];for(let r=Math.min(s.r1,s.r2);r<=Math.max(s.r1,s.r2);r++){ const cols=[];for(let c=Math.min(s.c1,s.c2);c<=Math.max(s.c1,s.c2);c++)cols.push(cellValue(r,c)); rows.push(cols.join('\t')); }return rows.join('\r\n'); } async function copy(){ const text=visibleTSV();if(!text){flash('No visible cells to copy');return;} try{await navigator.clipboard.writeText(text);flash('Visible cells copied. Paste directly into Excel.',true);} catch{ const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-2000px';document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();flash('Copied spreadsheet cells',true); } } $('copy').onclick=copy; document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'&&state.job&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();copy();} }); $('excel').onclick=async()=>{ if(!state.job)return;try{ const r=await fetch('/api/jobs/export.xlsx',{credentials:'same-origin',cache:'no-store'}); if(!r.ok){const x=await r.json().catch(()=>({}));throw Error(x.error||'Excel export failed');} const b=await r.blob(),url=URL.createObjectURL(b),a=document.createElement('a'); a.href=url;a.download='hotel-rate-observations.xlsx';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000); flash('Excel file downloaded',true); }catch(e){flash(e.message);} }; checkSession(); setInterval(()=>{if(!$('workspace').hidden&&!state.tabClosing)request('/api/session').catch(()=>{});},60000); })(); +(groups*.60).toFixed(2)+'. Your limit allows '+Math.min(groups,attempts)+' new Agent runs. This is NOT a provider-enforced spending limit; only verified rates are shown.' : 'Select hotels and dates to estimate the number of attempts.'; } $('creditBudget').oninput=updateCreditEstimate; $('batchSize').onchange=updateCreditEstimate; $('hotelRows').addEventListener('input',updateCreditEstimate); function getHotels(){return [...$('hotelRows').children].map(row=>({name:row.querySelector('.hname').value.trim(),city:row.querySelector('.city').value.trim(),officialUrl:row.querySelector('.url').value.trim()})).filter(x=>x.name);} function startStream(){ if(!state.job)return;closeStreams(); const es=new EventSource('/api/jobs/events',{withCredentials:true});state.stream=es; es.addEventListener('snapshot',e=>{try{const obj=JSON.parse(e.data);if(obj&&obj.id===state.job?.id){state.job=obj;renderJob();}}catch{}}); es.addEventListener('cell',e=>{try{const obj=JSON.parse(e.data);let i=state.job?.cells.findIndex(c=>c.key===obj.key);if(i>=0){state.job.cells[i]=obj;renderCell(obj);} }catch{}}); es.addEventListener('progress',e=>{try{const obj=JSON.parse(e.data);if(obj.id===state.job?.id){state.job={...state.job,...obj};renderJob();}}catch{}}); es.addEventListener('done',e=>{try{const obj=JSON.parse(e.data);if(obj.id===state.job?.id){state.job={...state.job,...obj};renderJob();}}catch{};closeStreams();}); es.onerror=()=>{if(!state.poll){state.poll=setInterval(async()=>{try{const j=(await request('/api/jobs')).job;if(j){state.job=j;renderJob();} }catch{}},3500);}}; } function closeStreams(){if(state.stream){state.stream.close();state.stream=null;}if(state.poll){clearInterval(state.poll);state.poll=null;}} $('startSearch').onclick=async()=>{ try{ if(!state.dates.length)throw Error('Generate or import observation dates first.'); const hotels=getHotels();if(!hotels.length||hotels.length>10)throw Error('Please provide 1–10 hotels.'); if(!$('approve').checked)throw Error('Confirm the maximum scraping credit allocation.'); const budget=Number($('creditBudget').value);if(budget<60||budget>120000)throw Error('Credit budget must be 60–120,000.'); if(state.job&&['running','budget_paused'].includes(state.job.state))throw Error('Cancel the current search first.'); const r=await request('/api/jobs/start',{hotels,dates:state.dates,sourceMode:state.sourceMode,batchSize:Number($('batchSize').value),creditsLimit:budget,confirmCost:true}); state.job=r.job;state.selection=null;renderJob();startStream();setMobilePanel('results'); }catch(e){notice(e.message);} }; $('cancelTop').onclick=async()=>{ try{const r=await request('/api/jobs/cancel',{});state.job=r.job;renderJob();closeStreams();flash('Search stopped. Already-found rates remain available for export.',true);} catch(e){flash(e.message);} }; function cellMarkup(c){ if(!c)return '
—
'; if(c.excluded)return '
Excluded
'; let inner='',stateName=c.status; if(c.rate!==null&&c.rate!==undefined&&Number(c.rate)>0){ inner='
'+escape(Number(c.rate).toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:2}))+' '+escape(c.currency||'')+'
'; const source=c.source.includes('Booking.com')?'Booking.com · OTA':c.status==='needs_review'?'Official · review':'Official website'; inner+='
'+escape(source)+'
'; inner+=''; if(c.sourceUrl)inner+='↗'; }else{ let label={queued:'Queued',processing:c.stage||'Checking…',unavailable:'No eligible rate',failed:'Failed',cancelled:'Cancelled'}[stateName]||'—'; inner='
'+escape(label)+'
'; } return inner; } function renderMobile(){ const job=state.job;if(!job)return; const sel=$('mobileHotel'),old=sel.value; const expected=job.hotels.map((hotel,i)=>'').join(''); if(sel.dataset.job!==job.id){sel.innerHTML=expected;sel.dataset.job=job.id;sel.value='0';} else if(old!==sel.value)sel.value=old; const index=Number(sel.value)||0; const map=new Map(job.cells.filter(c=>c.hotelIndex===index).map(c=>[c.date,c])); $('mobileRates').innerHTML=job.dates.map(date=>{ const cell=map.get(date),found=cell&&!cell.excluded&&Number.isFinite(cell.rate)&&cell.rate>0; const source=cell?.source?.includes('Booking.com')?'Booking.com · OTA':cell?.status==='needs_review'?'Official · review':'Official website'; const note=found?source:cell?.excluded?'Excluded':(cell?.stage||'Queued'); const value=found?Number(cell.rate).toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:2})+' '+escape(cell.currency):'—'; return '
'+escape(date)+'
'+value+'
'+escape(note)+'
'+ (found&&cell.sourceUrl?'↗':'')+ (found?'':cell?.excluded?'':'')+'
'; }).join(''); } $('mobileHotel').onchange=()=>{state.selection=null;renderMobile();}; $('mobileRates').addEventListener('click',async e=>{ const exclude=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]'); if(!exclude&&!restore)return; try{ const key=(exclude||restore).dataset.exclude||(exclude||restore).dataset.restore; const res=await request('/api/jobs/exclude',{key,excluded:!!exclude}); const at=state.job.cells.findIndex(c=>c.key===key); if(at>=0)state.job.cells[at]=res.cell; renderCell(res.cell);renderMobile(); }catch(ex){flash(ex.message);} }); function renderCell(c){ const td=document.querySelector('[data-cell-key="'+CSS.escape(c.key)+'"]');if(!td)return; td.innerHTML=cellMarkup(c);td.classList.toggle('excluded',!!c.excluded); td.title=c.reason||c.source||c.stage||''; if(window.matchMedia('(max-width:760px)').matches)renderMobile(); } function renderJob(){ const j=state.job;if(!j){$('cancelTop').disabled=true;return;} $('excel').disabled=false;$('cancelTop').disabled=!['running','budget_paused'].includes(j.state); $('progressLabel').textContent=j.finished+' / '+j.total+' completed'; $('stateBadge').textContent=j.state.toUpperCase().replace('_',' '); $('stage').textContent=j.stage||'Processing hotel observations'; $('barFill').style.width=j.percent+'%';$('bar').setAttribute('aria-valuenow',j.percent); $('verified').textContent=j.cells.filter(c=>['verified','needs_review'].includes(c.status)&&!c.excluded).length; $('fallback').textContent=j.cells.filter(c=>c.status==='booking_fallback'&&!c.excluded).length; $('notFound').textContent=j.cells.filter(c=>['unavailable','failed','cancelled'].includes(c.status)).length; $('credits').textContent=j.creditsReserved+' / '+j.creditsLimit; renderMobile(); const previous=$('ratesTable').dataset.id; if(previous===j.id && $('ratesTable').querySelectorAll('tbody tr').length===j.dates.length){ j.cells.forEach(renderCell);return; } const cols=['Stay date',...j.hotels.map((h,i)=>''+escape(h.name)+'')]; const data=new Map(j.cells.map(c=>[c.key,c])); $('ratesTable').querySelector('thead').innerHTML=''+cols.join('')+''; $('ratesTable').querySelector('tbody').innerHTML=j.dates.map((d,r)=>{ return ''+escape(d)+''+j.hotels.map((h,i)=>{ const c=data.get(i+'|'+d);return ''+cellMarkup(c)+''; }).join('')+''; }).join(''); $('ratesTable').dataset.id=j.id;markSelection(); } function rowCol(el){const td=el.closest('[data-r][data-c]');return td?{r:Number(td.dataset.r),c:Number(td.dataset.c)}:null;} function markSelection(){ document.querySelectorAll('.selected').forEach(x=>x.classList.remove('selected')); if(!state.selection)return; const {r1,r2,c1,c2}=state.selection; document.querySelectorAll('#ratesTable [data-r][data-c]').forEach(td=>{ const r=Number(td.dataset.r),c=Number(td.dataset.c); if(r>=Math.min(r1,r2)&&r<=Math.max(r1,r2)&&c>=Math.min(c1,c2)&&c<=Math.max(c1,c2))td.classList.add('selected'); }); } const table=$('ratesTable'); table.addEventListener('mousedown',e=>{if(e.target.closest('button,a'))return;const x=rowCol(e.target);if(!x)return;e.preventDefault();state.mouseDown=true;state.anchor=x;state.selection={r1:x.r,r2:x.r,c1:x.c,c2:x.c};markSelection();}); table.addEventListener('mouseover',e=>{if(!state.mouseDown||!state.anchor)return;const x=rowCol(e.target);if(!x)return;state.selection={r1:state.anchor.r,r2:x.r,c1:state.anchor.c,c2:x.c};markSelection();}); document.addEventListener('mouseup',()=>state.mouseDown=false); table.addEventListener('click',async e=>{ const excluded=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]');const target=excluded||restore; if(!target)return; try{ const r=await request('/api/jobs/exclude',{key:target.dataset.exclude||target.dataset.restore,excluded:!!excluded}); const i=state.job.cells.findIndex(c=>c.key===r.cell.key);if(i>=0)state.job.cells[i]=r.cell;renderCell(r.cell); }catch(ex){flash(ex.message);} }); function cellValue(r,c){ const j=state.job;if(!j)return ''; if(r===-1)return c===0?'Stay date':j.hotels[c-1]?.name||''; if(c===0)return j.dates[r]||''; const row=j.cells.find(x=>x.date===j.dates[r]&&x.hotelIndex===c-1); if(!row||row.excluded||row.rate===null||row.rate===undefined)return ''; return String(row.rate); } function visibleTSV(){ if(!state.job)return ''; let s=state.selection; if(!s&&window.matchMedia('(max-width:760px)').matches){ const hotelCol=Number($('mobileHotel').value||0)+1; return [['Stay date',state.job.hotels[hotelCol-1]?.name||''],...state.job.dates.map((date,row)=>[date,cellValue(row,hotelCol)])].map(row=>row.join('\t')).join('\r\n'); } if(!s)s={r1:-1,r2:state.job.dates.length-1,c1:0,c2:state.job.hotels.length}; const rows=[];for(let r=Math.min(s.r1,s.r2);r<=Math.max(s.r1,s.r2);r++){ const cols=[];for(let c=Math.min(s.c1,s.c2);c<=Math.max(s.c1,s.c2);c++)cols.push(cellValue(r,c)); rows.push(cols.join('\t')); }return rows.join('\r\n'); } async function copy(){ const text=visibleTSV();if(!text){flash('No visible cells to copy');return;} try{await navigator.clipboard.writeText(text);flash('Visible cells copied. Paste directly into Excel.',true);} catch{ const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-2000px';document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();flash('Copied spreadsheet cells',true); } } $('copy').onclick=copy; document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'&&state.job&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();copy();} }); $('excel').onclick=async()=>{ if(!state.job)return;try{ const r=await fetch('/api/jobs/export.xlsx',{credentials:'same-origin',cache:'no-store'}); if(!r.ok){const x=await r.json().catch(()=>({}));throw Error(x.error||'Excel export failed');} const b=await r.blob(),url=URL.createObjectURL(b),a=document.createElement('a'); a.href=url;a.download='hotel-rate-observations.xlsx';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000); flash('Excel file downloaded',true); }catch(e){flash(e.message);} }; checkSession(); setInterval(()=>{if(!$('workspace').hidden&&!state.tabClosing)request('/api/session').catch(()=>{});},60000); })(); +Number(j.budgetLimitUsd||0).toFixed(2); renderMobile(); const previous=$('ratesTable').dataset.id; if(previous===j.id && $('ratesTable').querySelectorAll('tbody tr').length===j.dates.length){ j.cells.forEach(renderCell);return; } const cols=['Stay date',...j.hotels.map((h,i)=>''+escape(h.name)+'')]; const data=new Map(j.cells.map(c=>[c.key,c])); $('ratesTable').querySelector('thead').innerHTML=''+cols.join('')+''; $('ratesTable').querySelector('tbody').innerHTML=j.dates.map((d,r)=>{ return ''+escape(d)+''+j.hotels.map((h,i)=>{ const c=data.get(i+'|'+d);return ''+cellMarkup(c)+''; }).join('')+''; }).join(''); $('ratesTable').dataset.id=j.id;markSelection(); } function rowCol(el){const td=el.closest('[data-r][data-c]');return td?{r:Number(td.dataset.r),c:Number(td.dataset.c)}:null;} function markSelection(){ document.querySelectorAll('.selected').forEach(x=>x.classList.remove('selected')); if(!state.selection)return; const {r1,r2,c1,c2}=state.selection; document.querySelectorAll('#ratesTable [data-r][data-c]').forEach(td=>{ const r=Number(td.dataset.r),c=Number(td.dataset.c); if(r>=Math.min(r1,r2)&&r<=Math.max(r1,r2)&&c>=Math.min(c1,c2)&&c<=Math.max(c1,c2))td.classList.add('selected'); }); } const table=$('ratesTable'); table.addEventListener('mousedown',e=>{if(e.target.closest('button,a'))return;const x=rowCol(e.target);if(!x)return;e.preventDefault();state.mouseDown=true;state.anchor=x;state.selection={r1:x.r,r2:x.r,c1:x.c,c2:x.c};markSelection();}); table.addEventListener('mouseover',e=>{if(!state.mouseDown||!state.anchor)return;const x=rowCol(e.target);if(!x)return;state.selection={r1:state.anchor.r,r2:x.r,c1:state.anchor.c,c2:x.c};markSelection();}); document.addEventListener('mouseup',()=>state.mouseDown=false); table.addEventListener('click',async e=>{ const excluded=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]');const target=excluded||restore; if(!target)return; try{ const r=await request('/api/jobs/exclude',{key:target.dataset.exclude||target.dataset.restore,excluded:!!excluded}); const i=state.job.cells.findIndex(c=>c.key===r.cell.key);if(i>=0)state.job.cells[i]=r.cell;renderCell(r.cell); }catch(ex){flash(ex.message);} }); function cellValue(r,c){ const j=state.job;if(!j)return ''; if(r===-1)return c===0?'Stay date':j.hotels[c-1]?.name||''; if(c===0)return j.dates[r]||''; const row=j.cells.find(x=>x.date===j.dates[r]&&x.hotelIndex===c-1); if(!row||row.excluded||row.rate===null||row.rate===undefined)return ''; return String(row.rate); } function visibleTSV(){ if(!state.job)return ''; let s=state.selection; if(!s&&window.matchMedia('(max-width:760px)').matches){ const hotelCol=Number($('mobileHotel').value||0)+1; return [['Stay date',state.job.hotels[hotelCol-1]?.name||''],...state.job.dates.map((date,row)=>[date,cellValue(row,hotelCol)])].map(row=>row.join('\t')).join('\r\n'); } if(!s)s={r1:-1,r2:state.job.dates.length-1,c1:0,c2:state.job.hotels.length}; const rows=[];for(let r=Math.min(s.r1,s.r2);r<=Math.max(s.r1,s.r2);r++){ const cols=[];for(let c=Math.min(s.c1,s.c2);c<=Math.max(s.c1,s.c2);c++)cols.push(cellValue(r,c)); rows.push(cols.join('\t')); }return rows.join('\r\n'); } async function copy(){ const text=visibleTSV();if(!text){flash('No visible cells to copy');return;} try{await navigator.clipboard.writeText(text);flash('Visible cells copied. Paste directly into Excel.',true);} catch{ const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-2000px';document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();flash('Copied spreadsheet cells',true); } } $('copy').onclick=copy; document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'&&state.job&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();copy();} }); $('excel').onclick=async()=>{ if(!state.job)return;try{ const r=await fetch('/api/jobs/export.xlsx',{credentials:'same-origin',cache:'no-store'}); if(!r.ok){const x=await r.json().catch(()=>({}));throw Error(x.error||'Excel export failed');} const b=await r.blob(),url=URL.createObjectURL(b),a=document.createElement('a'); a.href=url;a.download='hotel-rate-observations.xlsx';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000); flash('Excel file downloaded',true); }catch(e){flash(e.message);} }; checkSession(); setInterval(()=>{if(!$('workspace').hidden&&!state.tabClosing)request('/api/session').catch(()=>{});},60000); })(); +(groups*.60).toFixed(2)+'. Your limit allows '+Math.min(groups,attempts)+' new Agent runs. This is NOT a provider-enforced spending limit; only verified rates are shown.' : 'Select hotels and dates to estimate the number of attempts.'; } $('creditBudget').oninput=updateCreditEstimate; $('batchSize').onchange=updateCreditEstimate; $('hotelRows').addEventListener('input',updateCreditEstimate); function getHotels(){return [...$('hotelRows').children].map(row=>({name:row.querySelector('.hname').value.trim(),city:row.querySelector('.city').value.trim(),officialUrl:row.querySelector('.url').value.trim()})).filter(x=>x.name);} function startStream(){ if(!state.job)return;closeStreams(); const es=new EventSource('/api/jobs/events',{withCredentials:true});state.stream=es; es.addEventListener('snapshot',e=>{try{const obj=JSON.parse(e.data);if(obj&&obj.id===state.job?.id){state.job=obj;renderJob();}}catch{}}); es.addEventListener('cell',e=>{try{const obj=JSON.parse(e.data);let i=state.job?.cells.findIndex(c=>c.key===obj.key);if(i>=0){state.job.cells[i]=obj;renderCell(obj);} }catch{}}); es.addEventListener('progress',e=>{try{const obj=JSON.parse(e.data);if(obj.id===state.job?.id){state.job={...state.job,...obj};renderJob();}}catch{}}); es.addEventListener('done',e=>{try{const obj=JSON.parse(e.data);if(obj.id===state.job?.id){state.job={...state.job,...obj};renderJob();}}catch{};closeStreams();}); es.onerror=()=>{if(!state.poll){state.poll=setInterval(async()=>{try{const j=(await request('/api/jobs')).job;if(j){state.job=j;renderJob();} }catch{}},3500);}}; } function closeStreams(){if(state.stream){state.stream.close();state.stream=null;}if(state.poll){clearInterval(state.poll);state.poll=null;}} $('startSearch').onclick=async()=>{ try{ if(!state.dates.length)throw Error('Generate or import observation dates first.'); const hotels=getHotels();if(!hotels.length||hotels.length>10)throw Error('Please provide 1–10 hotels.'); if(!$('approve').checked)throw Error('Confirm the maximum scraping credit allocation.'); const budget=Number($('creditBudget').value);if(budget<60||budget>120000)throw Error('Credit budget must be 60–120,000.'); if(state.job&&['running','budget_paused'].includes(state.job.state))throw Error('Cancel the current search first.'); const r=await request('/api/jobs/start',{hotels,dates:state.dates,sourceMode:state.sourceMode,batchSize:Number($('batchSize').value),creditsLimit:budget,confirmCost:true}); state.job=r.job;state.selection=null;renderJob();startStream();setMobilePanel('results'); }catch(e){notice(e.message);} }; $('cancelTop').onclick=async()=>{ try{const r=await request('/api/jobs/cancel',{});state.job=r.job;renderJob();closeStreams();flash('Search stopped. Already-found rates remain available for export.',true);} catch(e){flash(e.message);} }; function cellMarkup(c){ if(!c)return '
—
'; if(c.excluded)return '
Excluded
'; let inner='',stateName=c.status; if(c.rate!==null&&c.rate!==undefined&&Number(c.rate)>0){ inner='
'+escape(Number(c.rate).toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:2}))+' '+escape(c.currency||'')+'
'; const source=c.source.includes('Booking.com')?'Booking.com · OTA':c.status==='needs_review'?'Official · review':'Official website'; inner+='
'+escape(source)+'
'; inner+=''; if(c.sourceUrl)inner+='↗'; }else{ let label={queued:'Queued',processing:c.stage||'Checking…',unavailable:'No eligible rate',failed:'Failed',cancelled:'Cancelled'}[stateName]||'—'; inner='
'+escape(label)+'
'; } return inner; } function renderMobile(){ const job=state.job;if(!job)return; const sel=$('mobileHotel'),old=sel.value; const expected=job.hotels.map((hotel,i)=>'').join(''); if(sel.dataset.job!==job.id){sel.innerHTML=expected;sel.dataset.job=job.id;sel.value='0';} else if(old!==sel.value)sel.value=old; const index=Number(sel.value)||0; const map=new Map(job.cells.filter(c=>c.hotelIndex===index).map(c=>[c.date,c])); $('mobileRates').innerHTML=job.dates.map(date=>{ const cell=map.get(date),found=cell&&!cell.excluded&&Number.isFinite(cell.rate)&&cell.rate>0; const source=cell?.source?.includes('Booking.com')?'Booking.com · OTA':cell?.status==='needs_review'?'Official · review':'Official website'; const note=found?source:cell?.excluded?'Excluded':(cell?.stage||'Queued'); const value=found?Number(cell.rate).toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:2})+' '+escape(cell.currency):'—'; return '
'+escape(date)+'
'+value+'
'+escape(note)+'
'+ (found&&cell.sourceUrl?'↗':'')+ (found?'':cell?.excluded?'':'')+'
'; }).join(''); } $('mobileHotel').onchange=()=>{state.selection=null;renderMobile();}; $('mobileRates').addEventListener('click',async e=>{ const exclude=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]'); if(!exclude&&!restore)return; try{ const key=(exclude||restore).dataset.exclude||(exclude||restore).dataset.restore; const res=await request('/api/jobs/exclude',{key,excluded:!!exclude}); const at=state.job.cells.findIndex(c=>c.key===key); if(at>=0)state.job.cells[at]=res.cell; renderCell(res.cell);renderMobile(); }catch(ex){flash(ex.message);} }); function renderCell(c){ const td=document.querySelector('[data-cell-key="'+CSS.escape(c.key)+'"]');if(!td)return; td.innerHTML=cellMarkup(c);td.classList.toggle('excluded',!!c.excluded); td.title=c.reason||c.source||c.stage||''; if(window.matchMedia('(max-width:760px)').matches)renderMobile(); } function renderJob(){ const j=state.job;if(!j){$('cancelTop').disabled=true;return;} $('excel').disabled=false;$('cancelTop').disabled=!['running','budget_paused'].includes(j.state); $('progressLabel').textContent=j.finished+' / '+j.total+' completed'; $('stateBadge').textContent=j.state.toUpperCase().replace('_',' '); $('stage').textContent=j.stage||'Processing hotel observations'; $('barFill').style.width=j.percent+'%';$('bar').setAttribute('aria-valuenow',j.percent); $('verified').textContent=j.cells.filter(c=>['verified','needs_review'].includes(c.status)&&!c.excluded).length; $('fallback').textContent=j.cells.filter(c=>c.status==='booking_fallback'&&!c.excluded).length; $('notFound').textContent=j.cells.filter(c=>['unavailable','failed','cancelled'].includes(c.status)).length; $('credits').textContent=j.creditsReserved+' / '+j.creditsLimit; renderMobile(); const previous=$('ratesTable').dataset.id; if(previous===j.id && $('ratesTable').querySelectorAll('tbody tr').length===j.dates.length){ j.cells.forEach(renderCell);return; } const cols=['Stay date',...j.hotels.map((h,i)=>''+escape(h.name)+'')]; const data=new Map(j.cells.map(c=>[c.key,c])); $('ratesTable').querySelector('thead').innerHTML=''+cols.join('')+''; $('ratesTable').querySelector('tbody').innerHTML=j.dates.map((d,r)=>{ return ''+escape(d)+''+j.hotels.map((h,i)=>{ const c=data.get(i+'|'+d);return ''+cellMarkup(c)+''; }).join('')+''; }).join(''); $('ratesTable').dataset.id=j.id;markSelection(); } function rowCol(el){const td=el.closest('[data-r][data-c]');return td?{r:Number(td.dataset.r),c:Number(td.dataset.c)}:null;} function markSelection(){ document.querySelectorAll('.selected').forEach(x=>x.classList.remove('selected')); if(!state.selection)return; const {r1,r2,c1,c2}=state.selection; document.querySelectorAll('#ratesTable [data-r][data-c]').forEach(td=>{ const r=Number(td.dataset.r),c=Number(td.dataset.c); if(r>=Math.min(r1,r2)&&r<=Math.max(r1,r2)&&c>=Math.min(c1,c2)&&c<=Math.max(c1,c2))td.classList.add('selected'); }); } const table=$('ratesTable'); table.addEventListener('mousedown',e=>{if(e.target.closest('button,a'))return;const x=rowCol(e.target);if(!x)return;e.preventDefault();state.mouseDown=true;state.anchor=x;state.selection={r1:x.r,r2:x.r,c1:x.c,c2:x.c};markSelection();}); table.addEventListener('mouseover',e=>{if(!state.mouseDown||!state.anchor)return;const x=rowCol(e.target);if(!x)return;state.selection={r1:state.anchor.r,r2:x.r,c1:state.anchor.c,c2:x.c};markSelection();}); document.addEventListener('mouseup',()=>state.mouseDown=false); table.addEventListener('click',async e=>{ const excluded=e.target.closest('[data-exclude]'),restore=e.target.closest('[data-restore]');const target=excluded||restore; if(!target)return; try{ const r=await request('/api/jobs/exclude',{key:target.dataset.exclude||target.dataset.restore,excluded:!!excluded}); const i=state.job.cells.findIndex(c=>c.key===r.cell.key);if(i>=0)state.job.cells[i]=r.cell;renderCell(r.cell); }catch(ex){flash(ex.message);} }); function cellValue(r,c){ const j=state.job;if(!j)return ''; if(r===-1)return c===0?'Stay date':j.hotels[c-1]?.name||''; if(c===0)return j.dates[r]||''; const row=j.cells.find(x=>x.date===j.dates[r]&&x.hotelIndex===c-1); if(!row||row.excluded||row.rate===null||row.rate===undefined)return ''; return String(row.rate); } function visibleTSV(){ if(!state.job)return ''; let s=state.selection; if(!s&&window.matchMedia('(max-width:760px)').matches){ const hotelCol=Number($('mobileHotel').value||0)+1; return [['Stay date',state.job.hotels[hotelCol-1]?.name||''],...state.job.dates.map((date,row)=>[date,cellValue(row,hotelCol)])].map(row=>row.join('\t')).join('\r\n'); } if(!s)s={r1:-1,r2:state.job.dates.length-1,c1:0,c2:state.job.hotels.length}; const rows=[];for(let r=Math.min(s.r1,s.r2);r<=Math.max(s.r1,s.r2);r++){ const cols=[];for(let c=Math.min(s.c1,s.c2);c<=Math.max(s.c1,s.c2);c++)cols.push(cellValue(r,c)); rows.push(cols.join('\t')); }return rows.join('\r\n'); } async function copy(){ const text=visibleTSV();if(!text){flash('No visible cells to copy');return;} try{await navigator.clipboard.writeText(text);flash('Visible cells copied. Paste directly into Excel.',true);} catch{ const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-2000px';document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();flash('Copied spreadsheet cells',true); } } $('copy').onclick=copy; document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='c'&&state.job&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();copy();} }); $('excel').onclick=async()=>{ if(!state.job)return;try{ const r=await fetch('/api/jobs/export.xlsx',{credentials:'same-origin',cache:'no-store'}); if(!r.ok){const x=await r.json().catch(()=>({}));throw Error(x.error||'Excel export failed');} const b=await r.blob(),url=URL.createObjectURL(b),a=document.createElement('a'); a.href=url;a.download='hotel-rate-observations.xlsx';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),5000); flash('Excel file downloaded',true); }catch(e){flash(e.message);} }; checkSession(); setInterval(()=>{if(!$('workspace').hidden&&!state.tabClosing)request('/api/session').catch(()=>{});},60000); })();