mirror of
https://github.com/hanzoskill/heypocket-reader.git
synced 2026-10-30 21:05:19 +00:00
348 lines
12 KiB
Python
348 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Pocket (heypocket.com) API client.
|
|
|
|
Reads transcripts and summaries from Pocket AI devices via reverse-engineered API.
|
|
API Base: https://production.heypocketai.com/api/v1
|
|
Auth: Firebase Bearer token extracted from browser
|
|
|
|
Usage:
|
|
# Extract token (requires Chrome with --profile, logged into app.heypocket.com)
|
|
python3 reader.py extract
|
|
|
|
# List recordings
|
|
python3 reader.py [days]
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, List
|
|
from pathlib import Path
|
|
|
|
API_BASE = "https://production.heypocketai.com/api/v1"
|
|
TOKEN_CACHE_FILE = Path.home() / ".pocket_token.json"
|
|
|
|
|
|
@dataclass
|
|
class PocketRecording:
|
|
"""A Pocket recording with metadata."""
|
|
id: str
|
|
title: str
|
|
description: str
|
|
duration: int
|
|
recorded_at: datetime
|
|
created_at: datetime
|
|
has_transcription: bool
|
|
has_summarization: bool
|
|
transcription_status: str
|
|
summarization_status: str
|
|
num_speakers: int
|
|
latitude: Optional[float] = None
|
|
longitude: Optional[float] = None
|
|
tags: List[str] = None
|
|
folder_id: Optional[str] = None
|
|
|
|
@classmethod
|
|
def from_api(cls, data: dict) -> 'PocketRecording':
|
|
return cls(
|
|
id=data['id'],
|
|
title=data.get('title', 'Untitled'),
|
|
description=data.get('description', ''),
|
|
duration=data.get('duration', 0) or 0,
|
|
recorded_at=_parse_datetime(data.get('recordingAt')),
|
|
created_at=_parse_datetime(data.get('createdAt')),
|
|
has_transcription=data.get('hasTranscription', False),
|
|
has_summarization=data.get('hasSummarization', False),
|
|
transcription_status=data.get('transcriptionStatus', 'unknown'),
|
|
summarization_status=data.get('summarizationStatus', 'unknown'),
|
|
num_speakers=int(data.get('numOfSpeakers', 0) or 0),
|
|
latitude=data.get('latitude'),
|
|
longitude=data.get('longitude'),
|
|
tags=[t.get('name', '') for t in data.get('tags', [])],
|
|
folder_id=data.get('folderId'),
|
|
)
|
|
|
|
@property
|
|
def duration_str(self) -> str:
|
|
if self.duration < 60:
|
|
return f"{self.duration}s"
|
|
elif self.duration < 3600:
|
|
return f"{self.duration // 60}m"
|
|
else:
|
|
h = self.duration // 3600
|
|
m = (self.duration % 3600) // 60
|
|
return f"{h}h {m}m"
|
|
|
|
|
|
@dataclass
|
|
class PocketSummarization:
|
|
"""Summarization data for a recording."""
|
|
id: str
|
|
recording_id: str
|
|
summary: str
|
|
action_items: List[dict]
|
|
transcript: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
|
|
|
|
def _parse_datetime(dt_str: Optional[str]) -> Optional[datetime]:
|
|
if not dt_str:
|
|
return None
|
|
try:
|
|
for fmt in ["%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%fZ"]:
|
|
try:
|
|
return datetime.strptime(dt_str, fmt)
|
|
except ValueError:
|
|
continue
|
|
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
|
except:
|
|
return None
|
|
|
|
|
|
def get_token() -> Optional[str]:
|
|
"""Get Firebase token from cache file."""
|
|
if TOKEN_CACHE_FILE.exists():
|
|
try:
|
|
data = json.loads(TOKEN_CACHE_FILE.read_text())
|
|
if data.get('expires_at', 0) > datetime.now().timestamp():
|
|
return data.get('access_token')
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
|
|
def save_token(access_token: str, refresh_token: str = None, expires_in: int = 3600):
|
|
"""Save token to cache file."""
|
|
data = {
|
|
'access_token': access_token,
|
|
'refresh_token': refresh_token,
|
|
'expires_at': (datetime.now() + timedelta(seconds=expires_in)).timestamp()
|
|
}
|
|
TOKEN_CACHE_FILE.write_text(json.dumps(data))
|
|
print(f"Token saved to {TOKEN_CACHE_FILE}")
|
|
|
|
|
|
def _api_request(endpoint: str, token: str, params: dict = None) -> dict:
|
|
"""Make authenticated API request."""
|
|
import urllib.request
|
|
import urllib.parse
|
|
|
|
url = f"{API_BASE}{endpoint}"
|
|
if params:
|
|
url += '?' + urllib.parse.urlencode(params)
|
|
|
|
req = urllib.request.Request(url)
|
|
req.add_header('Authorization', f'Bearer {token}')
|
|
req.add_header('Content-Type', 'application/json')
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as response:
|
|
return json.loads(response.read().decode())
|
|
except Exception as e:
|
|
raise Exception(f"API error: {e}")
|
|
|
|
|
|
def get_recordings(days: int = 30, limit: int = 50, token: str = None) -> List[PocketRecording]:
|
|
"""Get recent recordings."""
|
|
token = token or get_token()
|
|
if not token:
|
|
raise Exception("No token. Run: python3 reader.py extract")
|
|
|
|
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
params = {
|
|
'limit': limit,
|
|
'include_empty': 'false',
|
|
'sort_by': 'recording_at',
|
|
'sort_order': 'desc',
|
|
'start_date': start_date,
|
|
}
|
|
|
|
data = _api_request('/recordings', token, params)
|
|
return [PocketRecording.from_api(r) for r in data.get('data', [])]
|
|
|
|
|
|
def get_recording_full(recording_id: str, token: str = None) -> dict:
|
|
"""Get recording with transcript, summary, and action items."""
|
|
token = token or get_token()
|
|
if not token:
|
|
raise Exception("No token available.")
|
|
|
|
data = _api_request(f'/recordings/{recording_id}', token, {'include': 'all'})
|
|
|
|
recording = PocketRecording.from_api(data.get('recording', {}))
|
|
|
|
transcription = data.get('transcription', {})
|
|
transcript = transcription.get('transcription', {}).get('text', '') if transcription else ''
|
|
|
|
summarizations = data.get('summarizations', {})
|
|
summary = ''
|
|
action_items = []
|
|
if summarizations:
|
|
first_key = next(iter(summarizations.keys()), None)
|
|
if first_key:
|
|
v2 = summarizations[first_key].get('v2', {})
|
|
summary = v2.get('summary', {}).get('markdown', '')
|
|
action_items = v2.get('actionItems', {}).get('items', [])
|
|
|
|
return {
|
|
'recording': recording,
|
|
'transcript': transcript,
|
|
'summary': summary,
|
|
'action_items': action_items,
|
|
'speakers': data.get('speakers', []),
|
|
}
|
|
|
|
|
|
def get_transcript(recording_id: str, token: str = None) -> Optional[str]:
|
|
"""Get raw transcript text."""
|
|
token = token or get_token()
|
|
if not token:
|
|
raise Exception("No token available.")
|
|
|
|
data = _api_request(f'/recordings/{recording_id}', token, {'include': 'all'})
|
|
transcription = data.get('transcription', {})
|
|
if not transcription:
|
|
return None
|
|
return transcription.get('transcription', {}).get('text', None)
|
|
|
|
|
|
def get_summarization(recording_id: str, token: str = None) -> Optional[PocketSummarization]:
|
|
"""Get summary for a recording."""
|
|
token = token or get_token()
|
|
if not token:
|
|
raise Exception("No token available.")
|
|
|
|
data = _api_request(f'/recordings/{recording_id}', token, {'include': 'all'})
|
|
|
|
summarizations = data.get('summarizations', {})
|
|
if not summarizations:
|
|
return None
|
|
|
|
first_key = next(iter(summarizations.keys()), None)
|
|
if not first_key:
|
|
return None
|
|
|
|
summ = summarizations[first_key]
|
|
v2 = summ.get('v2', {})
|
|
|
|
return PocketSummarization(
|
|
id=summ.get('id', ''),
|
|
recording_id=recording_id,
|
|
summary=v2.get('summary', {}).get('markdown', ''),
|
|
action_items=v2.get('actionItems', {}).get('items', []),
|
|
transcript=None,
|
|
created_at=None,
|
|
)
|
|
|
|
|
|
def search_recordings(query: str, days: int = 90, limit: int = 20, token: str = None) -> List[PocketRecording]:
|
|
"""Search recordings by text."""
|
|
token = token or get_token()
|
|
if not token:
|
|
raise Exception("No token available.")
|
|
|
|
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
|
params = {
|
|
'limit': limit,
|
|
'search': query,
|
|
'start_date': start_date,
|
|
'sort_by': 'recording_at',
|
|
'sort_order': 'desc',
|
|
}
|
|
|
|
data = _api_request('/recordings', token, params)
|
|
return [PocketRecording.from_api(r) for r in data.get('data', [])]
|
|
|
|
|
|
def extract_token_from_browser() -> Optional[str]:
|
|
"""
|
|
Extract Firebase token from Chrome browser.
|
|
|
|
Requires Chrome running with remote debugging on port 9222,
|
|
logged into app.heypocket.com.
|
|
"""
|
|
js_code = '''(async () => {
|
|
const idbRequest = indexedDB.open("firebaseLocalStorageDb");
|
|
return new Promise((resolve) => {
|
|
idbRequest.onsuccess = () => {
|
|
const db = idbRequest.result;
|
|
const tx = db.transaction("firebaseLocalStorage", "readonly");
|
|
const store = tx.objectStore("firebaseLocalStorage");
|
|
const getAll = store.getAll();
|
|
getAll.onsuccess = () => {
|
|
const item = getAll.result[0];
|
|
if(item && item.value && item.value.stsTokenManager) {
|
|
const tm = item.value.stsTokenManager;
|
|
resolve(JSON.stringify({
|
|
accessToken: tm.accessToken,
|
|
refreshToken: tm.refreshToken,
|
|
expirationTime: tm.expirationTime
|
|
}));
|
|
} else {
|
|
resolve(JSON.stringify({error: "No token found"}));
|
|
}
|
|
};
|
|
};
|
|
});
|
|
})()'''
|
|
|
|
browser_eval = Path.home() / '.factory/skills/browser/eval.js'
|
|
if not browser_eval.exists():
|
|
browser_eval = Path.home() / '.claude/skills/browser/eval.js'
|
|
|
|
if not browser_eval.exists():
|
|
print("Browser skill not found. Install from: github.com/anthropics/skills")
|
|
return None
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[str(browser_eval), js_code],
|
|
capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
data = json.loads(result.stdout.strip())
|
|
if 'accessToken' in data:
|
|
save_token(data['accessToken'], data.get('refreshToken'), expires_in=3600)
|
|
return data['accessToken']
|
|
else:
|
|
print(f"Token extraction failed: {data.get('error', 'unknown error')}")
|
|
else:
|
|
print(f"Browser eval failed: {result.stderr}")
|
|
except Exception as e:
|
|
print(f"Token extraction error: {e}")
|
|
|
|
return None
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if len(sys.argv) > 1 and sys.argv[1] == 'extract':
|
|
print("Extracting token from browser...")
|
|
print("Requirements:")
|
|
print(" 1. Chrome running with: browser/start.js --profile")
|
|
print(" 2. Logged into app.heypocket.com")
|
|
print()
|
|
token = extract_token_from_browser()
|
|
if token:
|
|
print(f"Success! Token saved (first 50 chars: {token[:50]}...)")
|
|
sys.exit(0)
|
|
|
|
token = get_token()
|
|
if not token:
|
|
print("No token. Run: python3 reader.py extract")
|
|
sys.exit(1)
|
|
|
|
days = int(sys.argv[1]) if len(sys.argv) > 1 else 30
|
|
|
|
print(f"=== Pocket Recordings (last {days} days) ===\n")
|
|
recordings = get_recordings(days=days, limit=20, token=token)
|
|
|
|
for r in recordings:
|
|
date = r.recorded_at.strftime('%Y-%m-%d') if r.recorded_at else '?'
|
|
status = '✓' if r.has_summarization else '○'
|
|
speakers = f"({r.num_speakers}sp)" if r.num_speakers else ""
|
|
print(f" {date} | {r.duration_str:>6} | {status} {r.title[:45]} {speakers}")
|