CoolFace
Apppublic

moebiusT7/book-ocr-studio

sourceHugging Faceagpl-3.0updated 2d agoView on Hugging Face
0likes
oneclick.py263 linesDownload Raw Back to root
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