moebiusT7/book-ocr-studio
0
1"""One command capture. Boundary proof is required; unchanged pages are not EOF."""2import time3import re4from core import read,save,sha5 6NAV=r'''() => {7 const visible=e=>{let r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'};8 return [...document.querySelectorAll('button,[role="button"],a')].filter(visible).map(e=>{9 let r=e.getBoundingClientRect();return {name:[e.getAttribute('aria-label'),e.getAttribute('title'),e.textContent].filter(Boolean).join(' ').trim(),x:r.x+r.width/2,y:r.y+r.height/2,disabled:!!e.disabled||e.getAttribute('aria-disabled')==='true'};});10}'''11 12def position_from_text(text):13 """Accept one unambiguous rendered Kindle position, never a slider value."""14 matches = {(int(a), int(b)) for a, b in re.findall(15 r'(?:位置|Location)\s*([0-9,]+)\s*/\s*([0-9,]+)',16 text.replace(',', ''), re.I)}17 if not matches:18 # Reflowable books can expose only progress, not stable location numbers.19 percentages={int(p) for p in re.findall(r'(?:●|•)\s*(\d{1,3})%',text)}20 if len(percentages)==1 and 0<=next(iter(percentages))<=100:21 return dict(current=next(iter(percentages)),total=100,unit='percent')22 return None23 if len(matches) != 1:24 return None25 current, total = next(iter(matches))26 if not 1 <= current <= total:27 return None28 return dict(current=current, total=total)29 30 31def reader_position(page):32 # innerText reflects the displayed UI, not hidden application state.33 values = []34 for frame in page.frames:35 try:36 value = position_from_text(frame.evaluate("() => document.body.innerText"))37 if value:38 values.append(value)39 except Exception:40 pass41 unique = {(v['current'], v['total']) for v in values}42 return values[0] if len(unique) == 1 else None43 44 45def navigation(page,direction,cover_position=None):46 import re47 pattern=r'next\s*page|次のページ|次ページ|ページを進む' if direction=='next' else r'previous\s*page|前のページ|前ページ|ページを戻る'48 found=[]49 for frame in page.frames:50 try:51 items=frame.evaluate(NAV)52 for item in items:53 if not re.search(pattern,item['name'],re.I):continue54 if frame!=page.main_frame:55 box=frame.frame_element().bounding_box()56 if not box:continue57 item['x']+=box['x'];item['y']+=box['y']58 found.append(item)59 except Exception:continue60 unique={(round(b['x']),round(b['y'])):b for b in found}61 if not unique:62 position = reader_position(page)63 boundary = (position and64 (((position['current'] == (0 if position.get('unit')=='percent' else 1)) or position == cover_position) if direction == 'previous'65 else position['current'] == position['total']))66 if boundary:67 return dict(disabled=True, evidence='displayed_position', position=position)68 if len(unique)!=1:raise RuntimeError(f'{direction} page navigation could not be uniquely identified. Stopped with saved images preserved.')69 return next(iter(unique.values()))70 71def dismiss_reading_sync(page):72 """Decline only Kindle's observed last-read-position prompt."""73 if not hasattr(page,'locator'):return False74 dialog=page.locator('ion-alert[role="alertdialog"][header="前回読んでいたページ"]')75 if dialog.count()==1 and dialog.is_visible():76 no=dialog.get_by_role('button',name='いいえ',exact=True)77 if no.count()==1:78 no.click(timeout=3000)79 dialog.wait_for(state='hidden',timeout=3000)80 return True81 return False82 83 84def jump_to_cover(page):85 """Try the explicit cover anchor, otherwise the labelled slider endpoint."""86 if not hasattr(page,'locator'):return None87 dismiss_reading_sync(page)88 toc=page.locator('[data-testid="top_menu_table_of_contents"]')89 used_cover=False90 if toc.count()==1 and toc.is_visible():91 if 'active' not in (toc.get_attribute('class') or '').split():toc.click()92 try:93 cover=page.get_by_text(re.compile(r'^(表紙|Cover)$',re.I),exact=True)94 try:cover.wait_for(state='visible',timeout=1500)95 except Exception:pass96 if cover.count()==1 and cover.is_visible():97 cover.click();used_cover=True;page.wait_for_timeout(800)98 finally:99 if 'active' in (toc.get_attribute('class') or '').split():toc.click()100 if not used_cover:101 slider=page.locator('#kr-scrubber-bar')102 if slider.count()!=1:raise RuntimeError('Could not identify the cover entry or location slider')103 nxt=navigation(page,'next')104 if 'x' in nxt:105 rtl=nxt['x']<page.viewport_size['width']/2106 else:107 prev=navigation(page,'previous')108 if 'x' not in prev:raise RuntimeError('Could not determine the reading direction')109 rtl=prev['x']>page.viewport_size['width']/2110 key='End' if rtl else 'Home'111 slider.locator('[role="slider"]').press(key)112 # Ionic versions can ignore Home/End. Click the observed track endpoint;113 # the host padding keeps this inside the range while clamping to min/max.114 track=slider.locator('.range-slider').bounding_box()115 if not track:raise RuntimeError('Could not determine the location slider bounds')116 page.mouse.click(track['x']+track['width']+2 if rtl else track['x']-2,track['y']+track['height']/2)117 page.mouse.move(0,0);page.wait_for_timeout(800)118 for _ in range(30):119 position=reader_position(page)120 if position:return position121 page.wait_for_timeout(200)122 raise RuntimeError('Moved to the beginning, but could not confirm the location indicator')123 124 125def book_identity(url):126 from urllib.parse import urlsplit, parse_qs127 u=urlsplit(url);asin=parse_qs(u.query).get('asin')128 return (u.hostname,asin[0]) if asin else url129 130 131def resume_manifest(folder,url):132 path=folder/'capture.json'133 if not path.exists():return None134 m=read(path)135 if m.get('status') not in {'partial','capturing'} or not m.get('pages'):return None136 if book_identity(m.get('url',''))!=book_identity(url):return None137 if not all(p.get('position') for p in m['pages']):return None138 if any(p['position'].get('unit')=='percent' for p in m['pages']):return None # Percent cannot identify an exact resume screen.139 for item in m['pages']:140 source=(folder/item['image']).resolve()141 if source.parent!=folder.resolve() or sha(source.read_bytes())!=item['sha256']:142 raise RuntimeError('Resume image integrity check failed.')143 return m144 145 146def capture_identity(digest, position):147 """An exact displayed location distinguishes repeated printed pages.148 149 Percent progress is too coarse, so it cannot disambiguate identical images.150 """151 if position and position.get('unit') != 'percent':152 return (digest, position['current'], position['total'])153 return digest154 155 156def run_book(page,folder,gemma=True,max_screens=5000):157 from kindle_capture import stable,difference,state as write_state,convert_saved158 from reader_detection import fit_and_detect159 from core import is_running160 control=folder161 prior=read(folder/'state.json')162 candidate=__import__('pathlib').Path(prior.get('active_run') or folder)163 manifest=resume_manifest(candidate,page.url)164 if manifest:165 folder=candidate166 if prior.get('job') and is_running(__import__('pathlib').Path(prior['job'])):167 raise RuntimeError('Saved images are being converted. Resume capture after conversion finishes.')168 elif (folder/'capture.json').exists() or prior.get('job'):169 import uuid170 folder=control/'books'/uuid.uuid4().hex171 folder.mkdir(parents=True,mode=0o700);save(folder/'state.json',{})172 if (control/'conversion-options.json').exists():save(folder/'conversion-options.json',read(control/'conversion-options.json'))173 def state(phase,message,**extra):174 write_state(folder,phase,message,**extra)175 if folder!=control:write_state(control,phase,message,active_run=str(folder),**extra)176 (control/'stop').unlink(missing_ok=True)177 write_state(control,'capturing','Preparing',job=None,count=len(manifest['pages']) if manifest else 0,active_run=str(folder))178 if folder!=control:write_state(folder,'capturing','Preparing',job=None)179 state('capturing','Detecting the capture area automatically')180 dismiss_reading_sync(page)181 detection=fit_and_detect(page);save(folder/'detection.json',detection)182 if detection.get('clipped') or not detection.get('selection'):raise RuntimeError(detection['reason'])183 clip=detection['selection']['clip'];page.mouse.move(0,0)184 current=stable(page,clip,control,timeout=60)185 if manifest:186 target=manifest['pages'][-1]['position']187 if clip!=manifest['clip']:raise RuntimeError('The capture area differs from the saved session. Restore the same display size.')188 for _ in range(max_screens):189 pos=reader_position(page)190 if not pos or pos['total']!=target['total']:raise RuntimeError('Could not confirm the resume location.')191 if pos==target:break192 direction='previous' if pos['current']>target['current'] else 'next'193 nav=navigation(page,direction)194 if nav['disabled']:raise RuntimeError('Could not return to the saved location.')195 state('capturing','Moving to the saved location')196 page.mouse.click(nav['x'],nav['y']);page.mouse.move(0,0)197 current=stable(page,clip,control,previous=current,timeout=60)198 else:raise RuntimeError('Reached the search limit for the resume location.')199 original=(folder/manifest['pages'][-1]['image']).read_bytes()200 if difference(original,current)>1:raise RuntimeError('The resume image does not match the saved image.')201 manifest.update(status='capturing');manifest.pop('error',None)202 save(folder/'capture.json',manifest)203 # The last saved screen is already durable. Advance exactly once.204 nav=navigation(page,'next')205 if nav['disabled']:manifest.update(status='complete',end_verified=True)206 else:207 page.mouse.click(nav['x'],nav['y']);page.mouse.move(0,0)208 current=stable(page,clip,control,previous=current,timeout=60)209 else:210 state('capturing','Jumping to the cover from the table of contents')211 if hasattr(page,'inner_text'):212 save(folder/'start-ui.json',dict(text=page.inner_text('body'),buttons=page.evaluate(NAV),header=page.locator('#reader-header').inner_html(),slider=page.locator('#kr-scrubber-bar').evaluate("e => e.outerHTML + (e.shadowRoot ? e.shadowRoot.innerHTML : '')")))213 page.screenshot(path=str(folder/'start-ui.png'))214 cover_position=jump_to_cover(page)215 if cover_position:216 current=stable(page,clip,control,timeout=60)217 save(folder/'start-anchor.json',dict(source='reader_start_anchor',position=cover_position))218 seen=set()219 for _ in range(max_screens):220 previous=navigation(page,'previous',cover_position=cover_position)221 if previous['disabled']:break222 digest=sha(current)223 if digest in seen:raise RuntimeError('Detected a loop while moving to the beginning.')224 seen.add(digest);state('capturing','Moving to the beginning of the book')225 page.mouse.click(previous['x'],previous['y']);page.mouse.move(0,0)226 current=stable(page,clip,control,previous=current,timeout=60)227 else:raise RuntimeError('Stopped because the beginning could not be confirmed.')228 next_point=detection['selection'].get('next')229 manifest=dict(pages=[],clip=clip,status='capturing',start_verified=True,end_verified=False,title=(page.locator('ion-title.top-chrome__book-title').inner_text().strip() if hasattr(page,'locator') and page.locator('ion-title.top-chrome__book-title').count()==1 else page.title()),url=page.url,230 reading_direction=('rtl' if next_point[0]<page.viewport_size['width']/2 else 'ltr') if next_point else 'unknown')231 save(folder/'capture.json',manifest)232 fingerprints={capture_identity(p['sha256'],p.get('position')) for p in manifest['pages']}233 try:234 for _ in range(max_screens):235 if manifest.get('end_verified'):break236 pos=reader_position(page)237 if manifest['pages'] and pos and manifest['pages'][-1].get('position'):238 previous_position=manifest['pages'][-1]['position']239 comparable=pos.get('unit')==previous_position.get('unit') and pos['total']==previous_position['total']240 previous_pos=previous_position['current']241 if comparable and (pos['current']<previous_pos or (pos['current']==previous_pos and pos.get('unit')!='percent')):raise RuntimeError('The reading position is not advancing.')242 if capture_identity(sha(current),pos) in fingerprints:raise RuntimeError('Stopped after detecting a capture loop.')243 elif capture_identity(sha(current),pos) in fingerprints:raise RuntimeError('Stopped after detecting a capture loop.')244 name=f"page-{len(manifest['pages'])+1:05d}.png"245 if (folder/name).exists():raise RuntimeError('An image already exists at the destination.')246 (folder/name).write_bytes(current);fingerprints.add(capture_identity(sha(current),pos))247 manifest['pages'].append(dict(image=name,sha256=sha(current),position=pos));save(folder/'capture.json',manifest)248 state('capturing',f"{len(manifest['pages'])} screens saved",count=len(manifest['pages']))249 nxt=navigation(page,'next')250 if nxt['disabled']:manifest.update(status='complete',end_verified=True);break251 page.mouse.click(nxt['x'],nxt['y']);page.mouse.move(0,0)252 current=stable(page,clip,control,previous=current,timeout=60)253 else:raise RuntimeError('Capture limit reached. The end of the book has not been confirmed.')254 except Exception as exc:255 manifest.update(status='partial',error=str(exc))256 save(folder/'stop-diagnostic.json',dict(position=reader_position(page),image_sha256=sha(current),error=str(exc)))257 save(folder/'capture.json',manifest)258 if manifest['pages']:259 save(folder/'autopilot.json',dict(source_capture=str(folder),capture_complete=manifest['end_verified'],capture_error=manifest.get('error')))260 job=convert_saved(folder,gemma=gemma,autopilot=read(folder/'autopilot.json'))261 if folder!=control:write_state(control,'converting','Converting to text',job=str(job),count=len(manifest['pages']),active_run=str(folder))262 return job263 