Files
sundynix-pets/pets-be/scripts/collect_breed_facts.py
Blizzard 9d7cf720a5
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped
feat: add multi-source breed encyclopedia and two-hour refresh sessions
2026-10-05 13:38:24 +08:00

205 lines
14 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.
"""Collect official factual metadata. Run from repo root; no third-party packages.
HTML is cached outside the repository. No source prose or photos are persisted.
Mappings use FCI numbers and exact English names, never fuzzy breed matching.
"""
import argparse
from datetime import date
import concurrent.futures
import hashlib
import html
from html.parser import HTMLParser
import json
from pathlib import Path
import re
import unicodedata
import urllib.request
from urllib.parse import urljoin
ROOT = Path(__file__).resolve().parents[1] / 'internal/breeds'
CACHE = Path('/private/tmp/pawprint-breed-sources')
CACHE.mkdir(exist_ok=True)
DATE = date.today().isoformat()
class Node:
def __init__(self, tag='', attrs=None):
self.tag, self.attrs, self.children = tag, dict(attrs or []), []
def text(self):
return ''.join(c if isinstance(c, str) else c.text() for c in self.children)
def all(self):
yield self
for c in self.children:
if isinstance(c, Node):
yield from c.all()
class DOM(HTMLParser):
def __init__(self, text):
super().__init__(convert_charrefs=True)
self.root = Node(); self.stack = [self.root]; self.feed(text)
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs); self.stack[-1].children.append(n)
if tag not in ('br','img','hr','meta','link','input','source','wbr','area','embed'):
self.stack.append(n)
def handle_startendtag(self, tag, attrs):
self.stack[-1].children.append(Node(tag,attrs))
def handle_endtag(self, tag):
for i in range(len(self.stack)-1,0,-1):
if self.stack[i].tag == tag:
self.stack = self.stack[:i]; return
def handle_data(self, data):
self.stack[-1].children.append(data)
def clean(value):
return re.sub(r'\s+', ' ', html.unescape(re.sub('<[^>]*>',' ',str(value or '')))).strip()
def norm(value):
return re.sub('[^a-z0-9]', '', unicodedata.normalize('NFKD', value).encode('ascii','ignore').decode().lower())
def fetch(url):
file = CACHE / (hashlib.sha256(url.encode()).hexdigest()+'.html')
if file.exists() and date.fromtimestamp(file.stat().st_mtime).isoformat()==DATE: return file.read_text()
req = urllib.request.Request(url, headers={'User-Agent':'PawprintPlan/1.0 (breed facts)'})
with urllib.request.urlopen(req, timeout=20) as r: text = r.read().decode('utf-8','replace')
file.write_text(text); return text
def references(provider, key, url, facts):
return {'provider':provider,'external_id':str(key),'url':url,'retrieved_at':DATE,
'facts':{k:clean(v) for k,v in facts.items() if v is not None and clean(v)}}
GROUPS=['牧羊犬与牧牛犬组','宾莎犬、雪纳瑞、獒犬与瑞士山地牧牛犬组','梗犬组','腊肠犬组','尖嘴犬与原始犬种组','嗅觉猎犬与相关犬种组','指示猎犬组','寻回犬、搜寻犬与水猎犬组','伴侣犬与玩具犬组','灵缇(视觉猎犬)组']
COUNTRIES={'AUSTRALIA':'澳大利亚','BELGIUM':'比利时','CROATIA':'克罗地亚','CZECHOSLOVAKIA':'捷克斯洛伐克','FRANCE':'法国','GERMANY':'德国','GREAT BRITAIN':'英国','HUNGARY':'匈牙利','ITALY':'意大利','POLAND':'波兰','PORTUGAL':'葡萄牙','ROMANIA':'罗马尼亚','RUSSIA':'俄罗斯','SLOVAKIA':'斯洛伐克','SPAIN':'西班牙','SWITZERLAND':'瑞士','THE NETHERLANDS':'荷兰','NETHERLANDS':'荷兰','AUSTRIA':'奥地利','DENMARK':'丹麦','FINLAND':'芬兰','IRELAND':'爱尔兰','NORWAY':'挪威','SWEDEN':'瑞典','USA':'美国','UNITED STATES OF AMERICA':'美国','CANADA':'加拿大','JAPAN':'日本','CHINA':'中国','KOREA':'韩国','ISRAEL':'以色列','MEXICO':'墨西哥','BRAZIL':'巴西','ARGENTINA':'阿根廷','AFGHANISTAN':'阿富汗','MIDDLE EAST':'中东','TURKEY':'土耳其','THAILAND':'泰国','SOUTH AFRICA':'南非','MOROCCO':'摩洛哥','MALI':'马里','ICELAND':'冰岛','GREENLAND':'格陵兰','MALTA':'马耳他','BOSNIA AND HERZEGOVINA':'波斯尼亚和黑塞哥维那','SERBIA':'塞尔维亚','MONTENEGRO':'黑山','GREECE':'希腊','URUGUAY':'乌拉圭','PERU':'秘鲁','INDONESIA':'印度尼西亚','LITHUANIA':'立陶宛','ESTONIA':'爱沙尼亚','BELGIUM, FRANCE':'比利时、法国','CENTRAL AFRICA':'非洲中部','CUBA':'古巴','CZECH REPUBLIC':'捷克','GERMANY, SWITZERLAND':'德国、瑞士','KOREA (REPUBLIC OF)':'韩国','NORTH MACEDONIA, SERBIA':'北马其顿、塞尔维亚','RUSSIAN FEDERATION':'俄罗斯','SLOVENIA':'斯洛文尼亚','TAIWAN':'中国台湾','TIBET (CHINA)':'中国西藏'}
def fci_group(link):
number, url=link
nodes=list(DOM(fetch(url)).root.all()); country=''; records={}
for n in nodes:
node_id=n.attrs.get('id','')
if re.search(r'_PaysLabel_\d+$',node_id): country=re.sub(r'^\d+\.\s*','',clean(n.text()))
if n.tag=='a' and 'RaceHyperLink' in node_id:
match=re.search(r'\((\d+)\)',n.text())
if not match: continue
key=match.group(1); title=clean(n.text()); en=re.findall(r'\(([^\d][^)]*)\)',title)
name = re.sub(r'-\d+\.html$', '', n.attrs['href'].split('/')[-1]).replace('-', ' ')
facts={'english_name':name.title(),'origin':COUNTRIES.get(country,country.title()),'breed_group':GROUPS[number-1],'fci_number':key}
records[key]=references('fci',key,urljoin(url,n.attrs['href']),facts)
return records
def akc_page(item):
name,url=item
nodes=list(DOM(fetch(url)).root.all()); props=None
for n in nodes:
if n.attrs.get('data-js-component')=='breedPage': props=json.loads(n.attrs['data-js-props']);break
if not props: return None
data=props['settings']['breed_data']; slug=props['settings']['current_breed']
basic=data.get('basics',{}).get(slug,{})
standard=data.get('standards',{}).get(slug,{})
traits=data.get('traits',{}).get(slug,{})
facts={'english_name':name,'life_span':basic.get('life_expectancy'),'height':standard.get('height_display'),'weight':standard.get('weight_display')}
scores={}
labels={'energy_level':'运动活力','trainability_level':'训练接受度','shedding_level':'掉毛程度','barking_level':'吠叫倾向','coat_grooming_frequency':'梳毛需求','affectionate_with_family':'亲近家人'}
for key, label in labels.items():
value=traits.get('traits',{}).get(key,{}).get('score')
if str(value) in ['1','2','3','4','5']:scores[label]=int(value)
if scores:facts['traits']=json.dumps(scores,ensure_ascii=False,separators=(',',':'))
words={'alert':'对环境敏锐','affectionate':'喜欢亲近家人','smart':'学习反应快','intelligent':'学习反应快','friendly':'亲人友善','active':'喜欢活动','energetic':'精力较充沛','playful':'喜欢玩耍','loyal':'重视与家人的联系','independent':'有自主性','gentle':'性情较温和','confident':'比较自信','courageous':'胆量较足','curious':'好奇心较强','calm':'较为沉着','outgoing':'乐于互动','devoted':'重视与家人的联系','lively':'喜欢活动','watchful':'对环境敏锐'}
tags=list(dict.fromkeys(words[w.strip().lower()] for w in traits.get('temperament','').split('/') if w.strip().lower() in words))
if tags:facts['temperament']='、'.join(tags)
return references('akc',slug,url,facts)
def localized(key, value):
value=clean(value).replace('(male)','(公犬)').replace('(female)','(母犬)').replace('up to','不超过').replace('years','年').replace('year','年')
unit, factor, label = ('inches',2.54,'cm') if key=='height' else ('pounds',0.45359237,'kg')
def convert(match):
low=f"{float(match.group(1))*factor:.1f}".rstrip('0').rstrip('.')
high=("–"+f"{float(match.group(2))*factor:.1f}".rstrip('0').rstrip('.')) if match.group(2) else ''
return low+high+' '+label
return re.sub(r'(\d+(?:\.\d+)?)(?:\s*[-–]\s*(\d+(?:\.\d+)?))?\s*'+unit,convert,value)
def main():
parser=argparse.ArgumentParser();parser.parse_args()
rows=json.loads((ROOT/'data.json').read_text())
home=list(DOM(fetch('https://www.fci.be/en/nomenclature/')).root.all())
links=[]
for n in home:
if n.tag=='a' and 'GroupesRepeater_GroupeHyperLink' in n.attrs.get('id',''):
links.append((int(clean(n.text()).split()[-1]),urljoin('https://www.fci.be',n.attrs['href'])))
fci={}
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
for result in pool.map(fci_group,links): fci.update(result)
print('FCI catalog:',len(fci),flush=True)
akc={}
for n in DOM(fetch('https://www.akc.org/dog-breeds/')).root.all():
if n.tag=='option' and re.fullmatch(r'https://www.akc.org/dog-breeds/[a-z0-9-]+/',n.attrs.get('value','')):
akc[norm(clean(n.text()))]=(clean(n.text()),n.attrs['value'])
matches={}; entries=[]
for row in rows:
refs=[];fields={};field_sources={}
existing={k:row.get(k,'') for k in ['english_name','origin','breed_group','fci_number','height','weight','temperament','traits'] if row.get(k)}
if row['source_key'].startswith('cku:'):
refs.append(references('cku',row['source_key'].split(':')[1],row['source_url'],{'name':row['name'],'aliases':row['aliases'],**existing}))
for k in existing:field_sources[k]='cku'
match=re.match(r'cku:(\d+)',row['source_key']); source=fci.get(match.group(1)) if match else None
if source:
source=json.loads(json.dumps(source))
if re.search(r'[A-Z]$',row['source_key']):source['facts']['scope']='犬种基础标准(包含多个变种)'
refs.append(source)
for k,v in source['facts'].items():
if k not in ['scope'] and not (k=='english_name' and re.search(r'[A-Z]$',row['source_key'])) and not row.get(k): fields[k]=v;field_sources[k]='fci'
candidates=[row.get('english_name',''),fields.get('english_name','')]
equivalents={'cku:39':'Pembroke Welsh Corgi','cku:38':'Cardigan Welsh Corgi','cku:167':'Cocker Spaniel'}
if row['source_key'] in equivalents:candidates.insert(0,equivalents[row['source_key']])
# Varieties are deliberately not collapsed into their parent breed.
for candidate in candidates:
if norm(candidate) in akc:
item=akc[norm(candidate)];matches[item]=None;break
entries.append({'source_key':row['source_key'],'name':row['name'],'fields':fields,'field_sources':field_sources,'references':refs})
print('Exact AKC matches:',len(matches),flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futures={pool.submit(akc_page,item):item for item in matches}
done=0
for job in concurrent.futures.as_completed(futures):
item=futures[job]
try:matches[item]=job.result()
except Exception as e: print('AKC unavailable:',item[0],type(e).__name__,flush=True)
done+=1
if done%20==0:print('AKC progress:',done,'/',len(matches),flush=True)
by_name={norm(name):ref for (name,_),ref in matches.items() if ref}
for row,entry in zip(rows,entries):
names=[row.get('english_name',''),entry['fields'].get('english_name','')]
if row['source_key'] in equivalents:names.insert(0,equivalents[row['source_key']])
ref=next((by_name[norm(n)] for n in names if norm(n) in by_name),None)
if not ref:continue
ref=json.loads(json.dumps(ref))
if re.search(r'[A-Z]$',row['source_key']):
ref['facts']['scope']='父犬种资料,变种差异以对应标准为准'
entry['references'].append(ref);continue
entry['references'].append(ref)
for k,v in ref['facts'].items():
if k in ['height','weight','life_span'] or (not row.get(k) and not entry['fields'].get(k)):entry['fields'][k]=localized(k,v);entry['field_sources'][k]='akc'
# Lifestyle ratings have an explicit AKC source, keeping CKU ratings in the reference.
if ref['facts'].get('traits'):entry['fields']['traits']=ref['facts']['traits'];entry['field_sources']['traits']='akc'
rkc={r['source_key']:r for r in json.loads((ROOT/'rkc_facts.json').read_text())}
for row,entry in zip(rows,entries):
if row['source_key'] in rkc:
r=rkc[row['source_key']]
rkc_ref=references('rkc',r['external_id'],r['url'],r['facts']);rkc_ref['retrieved_at']=r['retrieved_at'];entry['references'].append(rkc_ref)
for k in ['exercise','grooming','life_span']:
if k=='life_span' and entry['fields'].get(k):continue
entry['fields'][k]=r['facts'][k];entry['field_sources'][k]='rkc'
if not entry['fields'].get('summary') and not row.get('summary'):
facts={**row,**entry['fields']};bits=[]
if facts.get('origin'):bits.append('这一犬种起源于'+facts['origin'])
if facts.get('breed_group'):bits.append('在 FCI 体系中归入'+facts['breed_group'])
if facts.get('temperament'):bits.append('常见性格倾向包括'+facts['temperament'])
if bits:entry['fields']['summary']='。'.join(bits)+'。';entry['field_sources']['summary']='editorial'
if row.get('summary'):entry['field_sources']['summary']='editorial'
if row.get('care_notes'):entry['field_sources']['care_notes']='editorial'
if entry['references']:
scores={}
try:scores=json.loads(entry['fields'].get('traits') or row.get('traits') or '{}')
except ValueError:pass
if not entry['fields'].get('exercise') and scores.get('运动活力'):
entry['fields']['exercise']='运动活力指数 '+str(scores['运动活力'])+'/5,结合年龄与体力安排散步和互动';entry['field_sources']['exercise']='editorial'
if not entry['fields'].get('grooming') and scores.get('梳毛需求'):
entry['fields']['grooming']='梳毛需求指数 '+str(scores['梳毛需求'])+'/5,按被毛情况安排梳理';entry['field_sources']['grooming']='editorial'
(ROOT/'enrichment.json').write_text(json.dumps(entries,ensure_ascii=False,indent=2)+'\n')
print('Saved',len(entries),'breeds,',sum(len(e['references']) for e in entries),'references',flush=True)
if __name__=='__main__':main()