Files
obsidian-wiki/scripts/deep_extract_ppap_archive_with_tools.py
nac 777607dc29 chore(repo): establish project baseline
Track the source-of-truth code, skills, bootstrap rules, docs, and packaging files before adding new features.

Exclude local vault config, generated skill mirrors, temporary exports, virtual environments, and business data files from version control.

Validation: inspected staged file list and git ignored status before commit.
2026-07-05 11:03:40 +00:00

287 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import csv
import hashlib
import json
import os
import re
import subprocess
import tempfile
import zipfile
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path('/home/nac/smb/品质部/品质管理资料夹2009.11.3/PPAP资料')
VAULT = Path('/home/nac/repo/obsidian-wiki-linux/kb/bjd-vault')
META = VAULT / '_meta'
OUT = VAULT / 'projects/bjd/references'
RUN_DATE = '20260611'
MAX_TEXT = 20000
MAX_FILES = int(os.environ.get('PPAP_DEEP_MAX_FILES', '0')) or None
QUALITY_TERMS = [
'PPAP','APQP','PSW','零件提交保证书','全尺寸','寸法','MSA','GR&R','GRR','CPK','PPK','CMK',
'可靠性','DV','PV','接触电阻','绝缘','耐压','保持力','可焊性','耐焊热','重复插拔','耐湿','盐雾',
'PFMEA','DFMEA','控制计划','过程流程','特殊特性','规格书','SGS','RoHS','Halogen','判定标准','测试结果','合格','OK',
]
SERIES_RE = re.compile(r'(?<!\d)(?:0\.3|0\.5|0\.8|1\.0|1\.25|1\.8|2\.0|2\.2|3\.5|5\.0)-\d+(?:B)?|28\+4P|10PIN|18B966|VPKSJF|VPHNMF|VPDNNMF', re.I)
def now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00','Z')
def clean(s: str) -> str:
s = (s or '').replace('\x00', ' ')
s = re.sub(r'[\t\r\f\v]+', ' ', s)
s = re.sub(r'\n{3,}', '\n\n', s)
s = re.sub(r' {2,}', ' ', s)
return s.strip()
def run(cmd: list[str], timeout=30) -> str:
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False)
data = p.stdout or b''
if not data and p.stderr:
data = p.stderr[:500]
return data.decode('utf-8', errors='ignore')
except Exception:
return ''
def extract_pdf(path: Path) -> str:
return run(['pdftotext', '-layout', str(path), '-'], timeout=45)
def extract_doc(path: Path) -> str:
txt = run(['antiword', str(path)], timeout=30)
if len(txt.strip()) < 50:
txt = run(['catdoc', str(path)], timeout=30)
return txt
def extract_unrtf(path: Path) -> str:
return run(['unrtf', '--text', str(path)], timeout=30)
def extract_xlsx(path: Path) -> str:
# Dependency-free OOXML shared strings/cell extraction; handles xlsx without openpyxl.
try:
with zipfile.ZipFile(path) as z:
names=set(z.namelist())
shared=[]
if 'xl/sharedStrings.xml' in names:
root=ET.fromstring(z.read('xl/sharedStrings.xml'))
ns={'a':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
for si in root.findall('.//a:si', ns):
parts=[t.text or '' for t in si.findall('.//a:t', ns)]
if parts: shared.append(''.join(parts))
lines=[]
for name in sorted(n for n in names if n.startswith('xl/worksheets/sheet') and n.endswith('.xml'))[:8]:
root=ET.fromstring(z.read(name))
ns={'a':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
vals=[]
for c in root.findall('.//a:c', ns)[:1000]:
v=c.find('a:v', ns)
if v is None or v.text is None: continue
val=v.text
if c.get('t') == 's':
try: val=shared[int(val)]
except Exception: pass
vals.append(val)
if vals:
lines.append(name + '\n' + ' '.join(vals))
return '\n'.join(lines)
except Exception:
return ''
def extract_xls_with_ssconvert(path: Path) -> str:
# Convert each workbook to csv in a temp dir. ssconvert may output one csv or multiple files depending workbook.
with tempfile.TemporaryDirectory(prefix='ppap-xls-') as td:
out = Path(td) / 'out.csv'
txt = run(['ssconvert', '--export-type=Gnumeric_stf:stf_csv', str(path), str(out)], timeout=60)
chunks=[]
for f in Path(td).glob('*'):
if f.is_file():
try:
chunks.append(f.read_text(encoding='utf-8', errors='ignore'))
except Exception:
pass
if chunks:
return '\n'.join(chunks)
return txt
def extract_libreoffice_text(path: Path) -> str:
with tempfile.TemporaryDirectory(prefix='ppap-lo-') as td:
run(['libreoffice', '--headless', '--convert-to', 'txt:Text', '--outdir', td, str(path)], timeout=90)
chunks=[]
for f in Path(td).glob('*.txt'):
try: chunks.append(f.read_text(encoding='utf-8', errors='ignore'))
except Exception: pass
return '\n'.join(chunks)
def extract_text(path: Path) -> tuple[str, str]:
ext=path.suffix.lower()
text=''; method=''
if ext == '.pdf':
text=extract_pdf(path); method='pdftotext'
elif ext == '.doc':
text=extract_doc(path); method='antiword/catdoc'
if len(clean(text)) < 50:
lo=extract_libreoffice_text(path)
if len(clean(lo)) > len(clean(text)):
text=lo; method='libreoffice-txt'
elif ext == '.rtf':
text=extract_unrtf(path); method='unrtf'
elif ext == '.xlsx':
text=extract_xlsx(path); method='xlsx-xml'
elif ext == '.xls':
text=extract_xls_with_ssconvert(path); method='ssconvert-csv'
if len(clean(text)) < 50:
lo=extract_libreoffice_text(path)
if len(clean(lo)) > len(clean(text)):
text=lo; method='libreoffice-txt'
elif ext in {'.txt','.log','.csv'}:
try: text=path.read_text(encoding='utf-8', errors='ignore'); method='plain'
except Exception: text=''
elif ext == '.ppt':
text=extract_libreoffice_text(path); method='libreoffice-txt'
return clean(text)[:MAX_TEXT], method
def snippets(text: str, max_snips=8) -> list[str]:
out=[]
for term in QUALITY_TERMS:
for m in re.finditer(re.escape(term), text, re.I):
start=max(0, m.start()-160); end=min(len(text), m.end()+320)
sn=clean(text[start:end])[:600]
if sn and sn not in out:
out.append(sn)
if len(out) >= max_snips:
return out
return out
def classify(text: str, path: Path) -> list[str]:
hay=(path.as_posix()+'\n'+text)
cats=[]
patterns={
'ppap_submission': r'PPAP提交清单|PPAP文件清单',
'psw': r'零件提交保证书|PSW',
'dimensional': r'全尺寸|寸法|尺寸测量|样品寸法',
'msa': r'MSA|GR&R|GRR|测量系统|量测系统',
'capability': r'CPK|PPK|CMK|过程能力|设备能力',
'reliability': r'可靠性|DV|PV|接触电阻|绝缘|耐压|保持力|可焊性|耐焊热|重复插拔|耐湿|盐雾',
'pfmea_dfmea': r'PFMEA|DFMEA|失效模式',
'control_plan': r'控制计划|Control Plan',
'process_flow': r'过程流程|工艺流程|流程图',
'material': r'SGS|RoHS|Halogen|C5210|C5191|C2680|LCP|PA9T',
'special_characteristics': r'特殊特性|关键特性|重要特性|Special Characteristics',
}
for k,pat in patterns.items():
if re.search(pat, hay, re.I): cats.append(k)
return cats or ['other']
def main():
records=[]
files=[]
for p in ROOT.rglob('*'):
if p.is_file() and p.suffix.lower() in {'.pdf','.doc','.rtf','.xls','.xlsx','.txt','.log','.csv','.ppt'}:
files.append(p)
if MAX_FILES:
files=files[:MAX_FILES]
for i,p in enumerate(files,1):
text, method = extract_text(p)
rel=p.relative_to(ROOT).as_posix()
cats=classify(text, p)
rec={
'relative_path': rel,
'path': str(p),
'top_package': rel.split('/')[0],
'filename': p.name,
'extension': p.suffix.lower().lstrip('.') or 'no_ext',
'method': method,
'text_length': len(text),
'categories': cats,
'series': sorted(set(SERIES_RE.findall(rel+'\n'+text))),
'snippets': snippets(text),
'text_sample': text[:1500],
}
records.append(rec)
if i % 100 == 0:
print(f'processed {i}/{len(files)}', flush=True)
META.mkdir(parents=True, exist_ok=True)
OUT.mkdir(parents=True, exist_ok=True)
jsonl=META/f'bjd-ppap-apqp-deep-text-index-{RUN_DATE}.jsonl'
with jsonl.open('w',encoding='utf-8') as f:
for r in records:
f.write(json.dumps(r,ensure_ascii=False)+'\n')
# Summary CSV
csvp=META/f'bjd-ppap-apqp-deep-text-index-{RUN_DATE}.csv'
with csvp.open('w',encoding='utf-8-sig',newline='') as f:
w=csv.DictWriter(f,fieldnames=['relative_path','top_package','extension','method','text_length','categories','series','snippet_count'])
w.writeheader()
for r in records:
w.writerow({
'relative_path':r['relative_path'], 'top_package':r['top_package'], 'extension':r['extension'], 'method':r['method'],
'text_length':r['text_length'], 'categories':'; '.join(r['categories']), 'series':'; '.join(r['series']), 'snippet_count':len(r['snippets'])
})
write_page(records,jsonl,csvp)
print(json.dumps({'files':len(records),'jsonl':str(jsonl),'csv':str(csvp)},ensure_ascii=False))
def esc(s): return str(s).replace('|','\\|').replace('\n',' ')
def write_page(records, jsonl, csvp):
ts=now_iso()
by_ext=Counter(r['extension'] for r in records)
by_method=Counter(r['method'] or 'none' for r in records)
by_cat=Counter(c for r in records for c in r['categories'])
by_pkg=defaultdict(list)
for r in records:
by_pkg[r['top_package']].append(r)
pkg_rows=[]
for pkg,rs in by_pkg.items():
pkg_rows.append((pkg,len(rs),sum(1 for r in rs if r['text_length']>50),Counter(c for r in rs for c in r['categories']).most_common(6)))
pkg_rows.sort(key=lambda x:(-x[1],x[0]))
page=OUT/f'bjd-ppap-apqp-deep-text-index-{RUN_DATE}.md'
lines=['---','title: BJD PPAP/APQP 深度文本索引','category: reference','project: bjd','tags: [bjd, quality, ppap, apqp, deep-extraction, visibility/internal]','visibility: internal','data_classification: technical-confidential',f'updated: {ts}','---','', '# BJD PPAP/APQP 深度文本索引','', f'本页基于已安装工具 `pdftotext`、`antiword/catdoc`、`ssconvert`、`libreoffice` 对 PPAP 目录做二次文本抽取。共处理 `{len(records)}` 个可解析文件。', '', '## 抽取覆盖','', '- 文件类型:' + ';'.join(f'{k}={v}' for k,v in by_ext.most_common()), '- 抽取方法:' + ';'.join(f'{k}={v}' for k,v in by_method.most_common()), '- 质量类别:' + ';'.join(f'{k}={v}' for k,v in by_cat.most_common()), '', '## 资料包覆盖', '', '| 资料包 | 文件数 | 抽到文本文件数 | 主要质量类别 |', '|---|---:|---:|---|']
for pkg,n,txtn,cats in pkg_rows[:80]:
lines.append(f'| {esc(pkg)} | {n} | {txtn} | {esc(";".join(f"{k}={v}" for k,v in cats))} |')
# selected snippets
interesting=[r for r in records if r['snippets'] and any(c in r['categories'] for c in ['reliability','capability','msa','pfmea_dfmea','control_plan','special_characteristics'])]
lines += ['', '## 代表性证据片段', '', '| 来源 | 类别 | 片段 |', '|---|---|---|']
for r in interesting[:80]:
lines.append(f'| {esc(r["relative_path"])} | {esc("; ".join(r["categories"]))} | {esc(r["snippets"][0][:500])} |')
lines += ['', '## 数据文件', '', f'- JSONL:`_meta/{jsonl.name}`', f'- CSV:`_meta/{csvp.name}`', '', '## 使用注意', '', '- 这是文本抽取索引,不替代原始 PPAP 文件。正式对外提交仍需复核原文件签核、版本、日期和客户适用性。', '- 扫描版 PDF 或图片型图纸如果没有文本层,仍需要 OCR 才能抽出正文。']
page.write_text('\n'.join(lines)+'\n',encoding='utf-8')
idx=VAULT/'index.md'
if idx.exists():
text=idx.read_text(encoding='utf-8')
rel=page.relative_to(VAULT).with_suffix('').as_posix()
if rel not in text:
text=text.replace('## 综合分析\n', f'## 综合分析\n- [[{rel}]] - PPAP/APQP 全目录深度文本索引,包含 PDF/DOC/XLS 二次抽取。 ( #bjd #quality #ppap #apqp )\n',1)
text=re.sub(r'最后更新:[^*]+', f'最后更新:{ts}', text)
idx.write_text(text,encoding='utf-8')
with (VAULT/'log.md').open('a',encoding='utf-8') as f:
f.write(f'\n- [{ts}] DATA_INGEST source="{ROOT}" project="bjd" mode="ppap-apqp-deep-text-index" files={len(records)} page="{page.relative_to(VAULT).as_posix()}" jsonl="{jsonl.relative_to(VAULT).as_posix()}" csv="{csvp.relative_to(VAULT).as_posix()}" bot_visible=false\n')
mp=VAULT/'.manifest.json'
data={}
if mp.exists():
try: data=json.loads(mp.read_text(encoding='utf-8'))
except Exception: data={}
data.setdefault('sources',{})[f'filesystem:ppap-apqp-deep-text-index-{RUN_DATE}']={'ingested_at':ts,'source_path':str(ROOT),'source_type':'filesystem_directory','doc_type':'ppap_apqp_deep_text_index','project':'bjd','files':len(records),'pages_created':[page.relative_to(VAULT).as_posix()],'data_paths':[jsonl.relative_to(VAULT).as_posix(),csvp.relative_to(VAULT).as_posix()],'bot_visible':False,'tools':'pdftotext, antiword/catdoc, ssconvert, libreoffice'}
mp.write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
if __name__ == '__main__':
main()