#!/usr/bin/env python3 """SIExpo pull-feed example: upsert JSON files and persist a change cursor. Set SIEXPO_API_KEY in the server environment; never place it in a public script. Usage: python api_client.py --output ./siexpo-import --lang both Replace apply_article() with your CMS's upsert/removal implementation as needed. """ import argparse, json, os, re, sys, tempfile from pathlib import Path from urllib.request import Request, build_opener, HTTPRedirectHandler from urllib.error import HTTPError, URLError from urllib.parse import urlencode class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None # Never forward a private key to another URL. def atomic_json(path, value): fd, temp=tempfile.mkstemp(prefix='.'+path.name+'.',dir=path.parent) try: with os.fdopen(fd,'w',encoding='utf-8') as f: json.dump(value,f,ensure_ascii=False,indent=2);f.flush();os.fsync(f.fileno()) os.replace(temp,path) finally: if os.path.exists(temp):os.unlink(temp) def apply_article(folder, item): """Upsert the full translation set; tombstones remove previously imported text. CMS integrations should use (id, language) as their external key and preserve source links, attribution and AI disclosures. Do not advance the cursor if their transaction fails. A deleted=true object contains no publishable text. """ if not isinstance(item.get('id'),int) or item['id']<1:raise ValueError('Invalid article id') if not isinstance(item.get('revision'),int) or not isinstance(item.get('deleted'),bool):raise ValueError('Invalid article change') atomic_json(folder/(str(item['id'])+'.json'),item) def sync(output, lang, key, fetch=None): output=Path(output);output.mkdir(parents=True,exist_ok=True) state_path=output/'cursor.json' state=json.loads(state_path.read_text(encoding='utf-8')) if state_path.exists() else {'after':0,'lang':lang} if state.get('lang')!=lang:raise ValueError('Use a separate output directory when changing language.') after=state['after'];through=None;count=0 opener=build_opener(NoRedirect()) def request(parameters): req=Request('https://siexpo.bg/api/v1/articles?'+urlencode(parameters), headers={'Authorization':'Bearer '+key,'Accept':'application/json','User-Agent':'SIExpo-syndication-client/1.0'}) with opener.open(req,timeout=30) as resp: if resp.status!=200:raise ValueError('Unexpected response') raw=resp.read(12*1024*1024+1) if len(raw)>12*1024*1024:raise ValueError('Response too large') return json.loads(raw) fetch=fetch or request while True: params={'after':after,'limit':25,'lang':lang} if through is not None:params['through']=through payload=fetch(params) end=payload.get('next_after');high=payload.get('through') if not isinstance(end,int) or not isinstance(high,int) or not after<=end<=high or not isinstance(payload.get('items'),list) or not isinstance(payload.get('has_more'),bool): raise ValueError('Invalid feed response') if through is not None and high!=through:raise ValueError('Snapshot changed during pagination') if payload['has_more'] and end<=after:raise ValueError('Cursor did not advance') for item in payload['items']:apply_article(output,item);count+=1 # Only after all imports on this page succeeded. Replays are idempotent. after=end;through=high;atomic_json(state_path,{'after':after,'lang':lang}) if not payload['has_more']:break return {'changes_applied':count,'cursor':after,'output':str(output)} def main(): p=argparse.ArgumentParser(description=__doc__);p.add_argument('--output',default='./siexpo-import');p.add_argument('--lang',choices=['bg','en','both'],default='both');args=p.parse_args() key=os.environ.get('SIEXPO_API_KEY','') if not re.fullmatch(r'sie_live_[A-Za-z0-9_-]{43}',key):sys.exit('Set a valid SIEXPO_API_KEY in the private server environment.') try:print(json.dumps(sync(args.output,args.lang,key),ensure_ascii=False)) except HTTPError as e: retry=e.headers.get('Retry-After','') sys.exit('SIExpo API returned HTTP '+str(e.code)+('. Retry after '+retry+' seconds.' if retry else '. Check the key, subscription and limits.')) except (URLError,ValueError,OSError):sys.exit('Import failed. The cursor was kept at the last successfully imported page; retry after resolving the connection or storage problem.') if __name__=='__main__':main()