Files
Investidor-V2/tools/legacy-correspondence-audit.py
2026-10-22 23:55:57 +02:00

197 lines
15 KiB
Python

"""Reconcile the 50 captured identities; public GETs only, no DB/publication writes."""
from pathlib import Path
from html.parser import HTMLParser
from concurrent.futures import ThreadPoolExecutor
import collections, csv, datetime, hashlib, json, re, subprocess, sys, urllib.request
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'outputs/legacy-correspondence-20261022'
OUT.mkdir(parents=True, exist_ok=True)
AUDIT = ROOT / 'docs/investmoz/v2/auditoria-portal'
reconciliation = json.loads((ROOT / 'outputs/investmoz-legado-inventario-reconciliado-20261015/RECONCILIACAO-LEGADO.json').read_text(encoding='utf-8'))
inventory = json.loads((ROOT / 'storage/app/private/investmoz-v2/portal-audit/legacy-discovery/inventory.json').read_text(encoding='utf-8'))
config = json.loads(subprocess.check_output(['php', '-r', "$c=require 'config/legacy_content.php';echo json_encode($c);"], cwd=ROOT))
mapped = {int(re.search(r'post-(\d+)-', item['external_id'])[1]): (slug, item) for slug, item in config['articles'].items() if item['publishable']}
assert len(mapped) == 20 and len(reconciliation['articles']) == 50
class Page(HTMLParser):
data = None
reading = False
chunks = None
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'script' and attrs.get('type') == 'application/json' and 'data-page' in attrs:
self.reading = True; self.chunks = []
elif 'data-page' in attrs and attrs['data-page'].startswith('{'):
self.data = json.loads(attrs['data-page'])
def handle_data(self, value):
if self.reading: self.chunks.append(value)
def handle_endtag(self, tag):
if tag == 'script' and self.reading:
self.data = json.loads(''.join(self.chunks)); self.reading = False
def fetch(pair):
legacy_id, (slug, identity) = pair
url = 'https://teste-investidor.tempo.co.mz/' + slug
try:
request = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 InvesteMoz-Legacy-Correspondence', 'Cache-Control': 'no-cache'})
with urllib.request.urlopen(request, timeout=35) as response:
raw = response.read(); status = response.status
page = Page(); page.feed(raw.decode('utf-8'))
article = page.data['props']['article']
assert page.data['component'] == 'portal/legacy' and article['legacy_id'] == legacy_id
assert article['source_url'] == identity['source_url'] and article['html'].strip()
return legacy_id, {'url': url, 'status': status, 'identity_verified': True,
'resource_id': article['id'], 'document_sha256': article['sha256'],
'current_title': article['title'], 'image_url': article['image']['href'] if article.get('image') else None,
'response_sha256': hashlib.sha256(raw).hexdigest()}
except Exception as error:
return legacy_id, {'url': url, 'identity_verified': False, 'error': type(error).__name__, 'status': getattr(error, 'code', None)}
if '--reuse-http' in sys.argv:
cached = json.loads((OUT / 'public-pages.json').read_text(encoding='utf-8'))
checks = {int(k): v for k, v in cached['pages'].items()}; stamp = cached['checked_utc']
assert set(checks) == set(mapped)
else:
with ThreadPoolExecutor(max_workers=4) as pool:
checks = dict(pool.map(fetch, mapped.items()))
stamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
(OUT / 'public-pages.json').write_text(json.dumps({'checked_utc': stamp, 'method': 'Anonymous HTTP GET; Inertia article identity/source/content checked; no visual acceptance or new publication', 'pages': checks}, ensure_ascii=False, indent=2), encoding='utf-8')
captures = {}
for record in inventory['records']:
post = record.get('public_props', {}).get('post')
if post:
captures.setdefault(int(post['id']), []).append(record)
staged = collections.defaultdict(list)
prepared = {}
for article in reconciliation['articles']:
legacy_id = article['legacy_id']
if legacy_id in mapped or article['group'] in ['mercado_financeiro', 'identidade_incompleta']:
continue
record = next(r for r in captures[legacy_id] if hashlib.sha256(r['public_props']['post']['content'].encode()).hexdigest() == article['body_sha256'])
original = ROOT / record['private_file']
assert original.is_file() and hashlib.sha256(original.read_bytes()).hexdigest() == record['sha256']
post = record['public_props']['post']; seo = record['public_props']['seo']
group = article['group']; directory = OUT / 'private-preparation' / group; directory.mkdir(parents=True, exist_ok=True)
payload = {'format': 'investmoz-legacy-article-v1', 'legacy_id': legacy_id, 'title': article['title'],
'author': seo.get('author', 'tempo'), 'legacy_category': article['category'],
'source_url': record['url'], 'published_at': seo.get('publishedTime', ''),
'modified_at': article['modified_at'], 'content_html': post['content'],
'image_source_url': post.get('thumbnail'), 'image_sha256': None,
'references': article['references']}
data = (json.dumps(payload, ensure_ascii=False, indent=2) + '\n').encode()
filename = f"post-{legacy_id}-{article['modified_at'][:10].replace('-', '')}.txt"
path = directory / filename
if path.exists():
assert path.read_bytes() == data, 'Prepared version changed; preserve prior bytes'
else:
path.write_bytes(data)
item = {'legacy_id': legacy_id, 'title': article['title'], 'source_url': record['url'],
'external_id': f"legacy-tempo-post-{legacy_id}-{article['modified_at'][:10].replace('-', '')}",
'file_name': filename, 'relative_path': f'{group}/{filename}',
'sha256': hashlib.sha256(data).hexdigest(), 'source_capture_sha256': record['sha256'],
'original_body_sha256': article['body_sha256'], 'bytes': len(data), 'published': False,
'scan': 'not_run_on_prepared_derivative', 'dependencies': article['dependencies'],
'note': 'Private preparation only. Not imported, scanned, published or assigned to a current opportunity. Image unresolved, never substituted by generic image.'}
staged[group].append(item); prepared[legacy_id] = item
assert len(prepared) == 28
qa_receipt_path = OUT / 'qa-import-receipt.json'
qa_rows = {}
if qa_receipt_path.exists():
proof = json.loads(qa_receipt_path.read_text(encoding='utf-8'))
assert proof['qa_database'] == 'simulator_fiscal_delta_20261021_test' and proof['port'] == 55441
assert proof['imported_private'] == proof['clean_actual_scans'] == proof['idempotent_replay_reused'] == 28
qa_rows = {int(re.search(r'post-(\d+)-', r['external_id'])[1]): r for r in proof['resources']}
assert set(qa_rows) == set(prepared)
assert all(not r['published'] and r['malware_scan_status'] == 'clean' and r['sha256'] == prepared[i]['sha256'] for i, r in qa_rows.items())
for group, items in staged.items():
(OUT / 'private-preparation' / group / 'preparation.json').write_text(json.dumps({'importable_manifest': False, 'group': group, 'items': items}, ensure_ascii=False, indent=2), encoding='utf-8')
path = AUDIT / 'legado-conteudos-reconciliados.csv'
with path.open(encoding='utf-8-sig', newline='') as f:
reader = csv.DictReader(f); columns = reader.fieldnames; rows = list(reader)
editorial_receipt = AUDIT / 'legado-editorial-qa-20261022.json'
editorial_rows = {}
if editorial_receipt.exists():
editorial = json.loads(editorial_receipt.read_text(encoding='utf-8'))
assert editorial['qa_only'] and not editorial['destination_imported'] and not editorial['destination_published']
editorial_rows = {int(re.search(r'post-(\d+)-', r['external_id'])[1]): r for r in editorial['rows']}
assert len(editorial_rows) == 28 and all(r['private'] for r in editorial_rows.values())
extra = ['Endereço no novo portal', 'Estado actual consolidado', 'Pendência actual', 'Conferência HTTP UTC', 'Grupo de preparação QA']
destination_receipt = ROOT / 'docs/investmoz/unification/legacy-editorial-installation-receipt-20261022.json'
destination_rows = {}
if destination_receipt.exists():
destination = json.loads(destination_receipt.read_text(encoding='utf-8'))
assert destination['new_publications'] == 0
destination_rows = {r['legacy_id']: r for r in destination['rows']}
assert len(destination_rows) == 28 and all(r['private'] for r in destination_rows.values())
for col in extra:
if col not in columns: columns.append(col)
articles = {a['legacy_id']: a for a in reconciliation['articles']}
for row in rows:
legacy_id = int(row['ID legado']); article = articles[legacy_id]; check = checks.get(legacy_id)
row['Endereço no novo portal'] = check['url'] if check else ''
row['Conferência HTTP UTC'] = stamp if check else ''
row['Grupo de preparação QA'] = article['group'] if legacy_id in prepared else ''
if check:
row['Estado actual consolidado'] = 'Publicado; identidade e conteúdo consultáveis na homologação' if check['identity_verified'] else 'Publicação documentada; consulta actual não confirmada'
row['Pendência actual'] = 'Não foi feita aceitação visual ou conferência pela Tempo nesta passagem.' if check['identity_verified'] else str(check)
if check['identity_verified']:
row['Situação'] = 'publicado'; row['Recurso ativo'] = str(check['resource_id'])
row['Grupo restante'] = 'publicados'
row['Dependência concreta'] = 'Sem pendência de integração desta identidade; mantém-se a revisão editorial corrente.'
row['Próxima ação'] = 'Preservar a publicação; gerir edições e versões pela biblioteca existente.'
row['Imagem atual'] = json.dumps({'scope': 'homologação', 'href': check['image_url'], 'status': 'referência na ficha; binário não reensaiado'}, ensure_ascii=False)
elif legacy_id in prepared:
row['Estado actual consolidado'] = 'Importado privado em QA; scan real limpo; por integrar na homologação' if legacy_id in qa_rows else 'Por integrar; exemplar derivado preparado privadamente, sem importação'
if legacy_id in qa_rows: row['Recurso QA'] = str(qa_rows[legacy_id]['id'])
row['Pendência actual'] = 'Fechar edição/destino de leitura e publicação por lote na homologação. Dependências históricas de 15/10, por confrontar com o estado actual (não são novos bloqueios): ' + ' | '.join(article['dependencies'])
if legacy_id in qa_rows:
row['Dependência concreta'] = 'Rascunho QA analisado; falta fechar leitor/edição e entrega/publicação na homologação. Referências externas e imagens não recolhidas continuam discriminadas no inventário.'
row['Próxima ação'] = 'Continuar por lote, preservando o carácter histórico/editorial; não converter em oportunidade vigente nem em regra fiscal.'
elif article['group'] == 'mercado_financeiro':
row['Estado actual consolidado'] = 'Original vazio'; row['Pendência actual'] = 'Fornecer corpo editorial pela Tempo; imagem e Fintech não substituem o original. Sem novo endereço público.'
else:
row['Estado actual consolidado'] = 'Original sem título'; row['Pendência actual'] = 'Identificação editorial pela Tempo para o post 74364; corpo com 21 caracteres úteis preservado. Sem título ou endereço novo inventado.'
row['Evidência atual'] = 'outputs/legacy-correspondence-20261022/public-pages.json; qa-import-receipt.json; editorial-gaps-current.json; inventário histórico de 15/10 preservado'
if legacy_id in editorial_rows:
assert editorial_rows[legacy_id]['original_sha256'] == prepared[legacy_id]['sha256']
row['Estado actual consolidado'] = 'Privado em QA; edição, leitura e prévia disponíveis; entrega privada preparada'
row['Pendência actual'] = 'Revisão editorial pela equipa; integração Git e transporte privado autenticado para homologação; publicação separada pelo portal. Imagens não verificadas mantêm referência/aviso.'
if legacy_id == 62413:
row['Pendência actual'] += ' Rever/corrigir a nota de demonstração QA antes de publicar.'
row['Dependência concreta'] = row['Pendência actual']
row['Próxima ação'] = 'Seguir o guia e a sequência de entrega privada do manual; não repetir importação QA ou provas dos 20 aceites.'
row['Evidência atual'] = 'legado-editorial-qa-20261022.json; manual: Legado — edição e leitura dos 28 rascunhos em QA; inventário e referências históricas preservados'
if legacy_id in destination_rows:
item = destination_rows[legacy_id]
assert item['sha256'] == prepared[legacy_id]['sha256']
row['Endereço no novo portal'] = f"https://teste-investidor.tempo.co.mz/management/portal-services/resources/{item['resource_id']}/article/preview"
row['Recurso ativo'] = str(item['resource_id'])
row['Estado actual consolidado'] = 'Rascunho privado na homologação; pesquisa, editor, prévia e recuperação conferidos'
row['Pendência actual'] = 'Revisão editorial e publicação separada pela equipa no portal. As referências sem exemplar de imagem verificado permanecem explícitas.'
if legacy_id == 62413:
row['Pendência actual'] += ' Rever/corrigir a nota de demonstração QA antes de publicar.'
row['Dependência concreta'] = row['Pendência actual']
row['Próxima ação'] = 'Rever pela gestão; corrigir e pré-visualizar; publicar expressamente quando autorizado, sem comandos.'
row['Evidência atual'] = 'docs/investmoz/unification/legacy-editorial-installation-receipt-20261022.json; preservação dos registos anteriores conferida; provas QA reutilizadas'
assert len(rows) == 50 and len({row['ID legado'] for row in rows}) == 50
with path.open('w', encoding='utf-8-sig', newline='') as f:
writer = csv.DictWriter(f, columns); writer.writeheader(); writer.writerows(rows)
def esc(value): return str(value).replace('|', '\\|').replace('\n', ' ')
table = ['# Correspondência integral dos 50 artigos do legado', '',
'Conferência HTTP individual em ' + stamp + '. Lista por identidade editorial; imagens, recursos da biblioteca e variantes de URL não são artigos adicionais. Não altera publicações. Endereço em branco significa que não foi demonstrada uma página nova, não uma ligação a criar por suposição.', '',
'| ID | Título original | Endereço original | Endereço no novo portal | Estado | Pendência |', '| --- | --- | --- | --- | --- | --- |']
for row in rows:
table.append('| ' + ' | '.join(esc(row[k]) for k in ['ID legado', 'Título', 'URLs observadas', 'Endereço no novo portal', 'Estado actual consolidado', 'Pendência actual']) + ' |')
(AUDIT / 'legado-correspondencia-integral.md').write_text('\n'.join(table) + '\n', encoding='utf-8')
summary = {'checked_utc': stamp, 'distinct_articles': 50, 'publications_documented': 20,
'public_pages_identity_verified': sum(p['identity_verified'] for p in checks.values()),
'pending_content': 28, 'private_preparation': {k: len(v) for k, v in staged.items()},
'empty': 1, 'untitled': 1, 'database_writes_by_correspondence_script': 0, 'active_database_writes': 0, 'new_publications': 0,
'qa_imports': len(qa_rows), 'qa_clean_scans': len(qa_rows), 'remaining_integration_complete': False}
(OUT / 'receipt.json').write_text(json.dumps(summary, indent=2), encoding='utf-8')
print(json.dumps(summary))