refactor: refactor proj
This commit is contained in:
parent
bd1de7ca46
commit
dc2d1bd336
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(pip install:*)",
|
||||||
|
"Bash(python3 -m pip install:*)",
|
||||||
|
"Bash(python3 -m pytest:*)",
|
||||||
|
"Bash(python3:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
118
addon_info.py
118
addon_info.py
@ -1,118 +0,0 @@
|
|||||||
class AddonInfoAuthor:
|
|
||||||
def __init__(self, name: str = None, url: str = None, avatar: str = None,
|
|
||||||
**kwargs):
|
|
||||||
self.name = name
|
|
||||||
self.url = url
|
|
||||||
self.avatar = avatar
|
|
||||||
[setattr(self, key, value) for (key, value) in kwargs.items()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __dict__(self):
|
|
||||||
result = {}
|
|
||||||
if self.name:
|
|
||||||
result["name"] = self.name
|
|
||||||
if self.url:
|
|
||||||
result["url"] = self.url
|
|
||||||
if self.avatar:
|
|
||||||
result["avatar"] = self.avatar
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class AddonInfoRelease:
|
|
||||||
def __init__(self, targetZoteroVersion: str, tagName: str,
|
|
||||||
xpiDownloadUrl: dict = None, releaseDate: str = None, id: str = None, xpiVersion: str = None,
|
|
||||||
name: str = None, description: str = None,
|
|
||||||
minZoteroVersion: str = None, maxZoteroVersion: str = None,
|
|
||||||
**kwargs):
|
|
||||||
self.targetZoteroVersion = targetZoteroVersion
|
|
||||||
self.tagName = tagName
|
|
||||||
self.xpiDownloadUrl = xpiDownloadUrl
|
|
||||||
self.releaseDate = releaseDate
|
|
||||||
self.id = id
|
|
||||||
self.xpiVersion = xpiVersion
|
|
||||||
self.name = name
|
|
||||||
self.description = description
|
|
||||||
self.minZoteroVersion = minZoteroVersion
|
|
||||||
self.maxZoteroVersion = maxZoteroVersion
|
|
||||||
[setattr(self, key, value) for (key, value) in kwargs.items()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def zotero_check_version(self) -> str:
|
|
||||||
if self.targetZoteroVersion == '6':
|
|
||||||
return '6.*'
|
|
||||||
elif self.targetZoteroVersion == '7':
|
|
||||||
return '7.*'
|
|
||||||
raise Exception(f'Invalid targetZoteroVersion({self.targetZoteroVersion})')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __dict__(self):
|
|
||||||
result = {}
|
|
||||||
if self.targetZoteroVersion:
|
|
||||||
result["targetZoteroVersion"] = self.targetZoteroVersion
|
|
||||||
if self.tagName:
|
|
||||||
result["tagName"] = self.tagName
|
|
||||||
if self.xpiDownloadUrl:
|
|
||||||
result["xpiDownloadUrl"] = self.xpiDownloadUrl
|
|
||||||
if self.releaseDate:
|
|
||||||
result["releaseDate"] = self.releaseDate
|
|
||||||
if self.id:
|
|
||||||
result["id"] = self.id
|
|
||||||
if self.xpiVersion:
|
|
||||||
result["xpiVersion"] = self.xpiVersion
|
|
||||||
if self.name:
|
|
||||||
result["name"] = self.name
|
|
||||||
if self.description:
|
|
||||||
result["description"] = self.description
|
|
||||||
if self.minZoteroVersion:
|
|
||||||
result["minZoteroVersion"] = self.minZoteroVersion
|
|
||||||
if self.maxZoteroVersion:
|
|
||||||
result["maxZoteroVersion"] = self.maxZoteroVersion
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class AddonInfo:
|
|
||||||
def __init__(self, repo: str,
|
|
||||||
releases: list[dict] | list[AddonInfoRelease],
|
|
||||||
name: str = None,
|
|
||||||
description: str = None,
|
|
||||||
stars: int = None,
|
|
||||||
author: dict | AddonInfoAuthor = None,
|
|
||||||
**kwargs):
|
|
||||||
self.repo = repo
|
|
||||||
self.releases = [e if isinstance(e, AddonInfoRelease) else AddonInfoRelease(**(e if e else {})) for e in releases]
|
|
||||||
self.name = name
|
|
||||||
self.description = description
|
|
||||||
self.stars = stars
|
|
||||||
self.author = author if isinstance(author, AddonInfoAuthor) else AddonInfoAuthor(**(author if author else {}))
|
|
||||||
[setattr(self, key, value) for (key, value) in kwargs.items()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def owner(self):
|
|
||||||
if len(self.repo.split('/')) != 2:
|
|
||||||
return None
|
|
||||||
return self.repo.split('/')[0]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def repository(self):
|
|
||||||
if len(self.repo.split('/')) != 2:
|
|
||||||
return None
|
|
||||||
return self.repo.split('/')[1]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __dict__(self):
|
|
||||||
result = {}
|
|
||||||
if self.repo:
|
|
||||||
result["repo"] = self.repo
|
|
||||||
if self.releases:
|
|
||||||
result["releases"] = [release.__dict__ for release in self.releases]
|
|
||||||
if self.name:
|
|
||||||
result["name"] = self.name
|
|
||||||
if self.description:
|
|
||||||
result["description"] = self.description
|
|
||||||
if self.stars:
|
|
||||||
result["stars"] = self.stars
|
|
||||||
# todo: Deprecation, used for z7 < 1.5.3 and z6 < 0.6.7
|
|
||||||
result["star"] = self.stars
|
|
||||||
if self.author:
|
|
||||||
result["author"] = self.author.__dict__
|
|
||||||
return result
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
# convert addon from zotero-chinese
|
|
||||||
|
|
||||||
repo = 'repo'
|
|
||||||
releases = 'releases'
|
|
||||||
targetZoteroVersion = 'targetZoteroVersion'
|
|
||||||
tagName = 'tagName'
|
|
||||||
|
|
||||||
PluginInfoBase = []
|
|
||||||
|
|
||||||
|
|
||||||
for addon in PluginInfoBase:
|
|
||||||
filename = addon[repo].replace('/', '#') + '.json'
|
|
||||||
with open(os.path.join('addons', filename), 'w') as f:
|
|
||||||
json.dump(addon, f, ensure_ascii=False, indent=' ')
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
fallback_associated_unique_id = 'p_unique_associated_id##80502300-9DE6-4510-8768-EC42B0EF14E6'
|
|
||||||
fallback_associated_exclude_ids = 'p_exclude_associated_ids##073F5543-C70A-46AA-8529-E05168852D8F'
|
|
||||||
|
|
||||||
fallback_associated_map = {
|
|
||||||
fallback_associated_unique_id: 'repo',
|
|
||||||
fallback_associated_exclude_ids: ['id'],
|
|
||||||
'repo': {
|
|
||||||
'releases': {
|
|
||||||
fallback_associated_unique_id: 'tagName',
|
|
||||||
fallback_associated_exclude_ids: ['currentVersion'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fallback_if_need(current, previous, associate_map=None):
|
|
||||||
if associate_map is None:
|
|
||||||
associate_map = fallback_associated_map
|
|
||||||
if previous and current and isinstance(previous, type(current)):
|
|
||||||
if isinstance(current, dict):
|
|
||||||
for key, value in previous.items():
|
|
||||||
if key in associate_map.get(fallback_associated_exclude_ids, []):
|
|
||||||
continue
|
|
||||||
if key in current and (isinstance(value, (list, dict))):
|
|
||||||
if current[key]:
|
|
||||||
fallback_if_need(current[key], value, associate_map.get(key, {}))
|
|
||||||
else:
|
|
||||||
print(f'fallback {key}')
|
|
||||||
current[key] = value
|
|
||||||
elif key not in current:
|
|
||||||
print(f'fallback {key}')
|
|
||||||
current[key] = value
|
|
||||||
|
|
||||||
elif isinstance(current, list) and (uid := associate_map.get(fallback_associated_unique_id)):
|
|
||||||
for value in previous:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
continue
|
|
||||||
if cur := next((x for x in current if isinstance(x, dict) and x[uid] == value[uid]), None):
|
|
||||||
fallback_if_need(cur, value, associate_map.get(uid, {}))
|
|
||||||
return current
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
|
||||||
def folder_filename_hash(dir):
|
|
||||||
filenames = sorted(os.listdir(dir))
|
|
||||||
folder_hash = hashlib.new('sha256')
|
|
||||||
for filename in filenames:
|
|
||||||
hash_obj = hashlib.new('sha256')
|
|
||||||
hash_obj.update(filename.encode('utf-8'))
|
|
||||||
folder_hash.update(hash_obj.hexdigest().encode('utf-8'))
|
|
||||||
return folder_hash.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def update_cache(cache_directory, runtime_xpi_directory, cache_hash_filename):
|
|
||||||
if not cache_directory or not runtime_xpi_directory or cache_directory == runtime_xpi_directory:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
shutil.rmtree(cache_directory)
|
|
||||||
shutil.move(runtime_xpi_directory, cache_directory)
|
|
||||||
folder_hash = folder_filename_hash(cache_directory)
|
|
||||||
with open(os.path.join(cache_directory, cache_hash_filename), 'w') as file:
|
|
||||||
file.write(folder_hash)
|
|
||||||
print(folder_hash)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'update cache failed: {e}')
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
import json
|
|
||||||
import time
|
|
||||||
import requests
|
|
||||||
from datetime import datetime, timedelta, UTC
|
|
||||||
|
|
||||||
|
|
||||||
def github_api_headers(**kwargs):
|
|
||||||
result = {
|
|
||||||
'Accept': 'application/vnd.github+json',
|
|
||||||
'X-GitHub-Api-Version': '2022-11-28',
|
|
||||||
}
|
|
||||||
if github_token := kwargs.get('github_token'):
|
|
||||||
result['Authorization'] = f'token {github_token}'
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def report_issue(github_repository: str, title: str, body: str, **kwargs):
|
|
||||||
if not github_repository:
|
|
||||||
print('report issue repository not found')
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if issue_check_id := kwargs.get('id'):
|
|
||||||
body = f'{body}\n----{issue_check_id}'
|
|
||||||
try:
|
|
||||||
response = requests.get(f'https://api.github.com/repos/{github_repository}/issues',
|
|
||||||
headers=github_api_headers(github_token=kwargs.get('github_token')),
|
|
||||||
params={
|
|
||||||
'state': 'open',
|
|
||||||
'since': (datetime.now(UTC) - timedelta(days=10)).strftime('%Y-%m-%dT%H:%M:%S') + 'Z',
|
|
||||||
'per_page': 100,
|
|
||||||
})
|
|
||||||
exist_issues = response.json()
|
|
||||||
for issue in exist_issues:
|
|
||||||
if issue.get('body').endswith(f'----{issue_check_id}'):
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
print(f'fetch from exist issues failed: {e}')
|
|
||||||
|
|
||||||
response = requests.post(f'https://api.github.com/repos/{github_repository}/issues',
|
|
||||||
headers=github_api_headers(github_token=kwargs.get('github_token')),
|
|
||||||
json={
|
|
||||||
'title': title,
|
|
||||||
'body': body
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code == 201:
|
|
||||||
print('Issue created successfully.')
|
|
||||||
print('Issue URL:', response.json()['html_url'])
|
|
||||||
else:
|
|
||||||
print('Failed to create issue.')
|
|
||||||
print('Response:', response.content)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'report issue failed: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def upload_json_to_release(github_repository, release_id, upload_file_name, upload_file, **kwargs):
|
|
||||||
headers = github_api_headers(github_token=kwargs.get('github_token'))
|
|
||||||
headers["Content-Type"] = "application/octet-stream"
|
|
||||||
upload_url = (f'https://uploads.github.com/repos/{github_repository}'
|
|
||||||
f'/releases/{release_id}/assets?name={upload_file_name}')
|
|
||||||
try:
|
|
||||||
with open(upload_file, "rb") as file:
|
|
||||||
upload_resp = requests.post(upload_url, data=file, headers=headers)
|
|
||||||
if upload_resp.status_code != 201:
|
|
||||||
print(f'upload release assets code: {upload_resp.status_code}')
|
|
||||||
else:
|
|
||||||
print('upload release assets succeed')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'upload release assets failed: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def create_release(github_repository, **kwargs):
|
|
||||||
create_release_url = f'https://api.github.com/repos/{github_repository}/releases'
|
|
||||||
cur_time = int(time.time())
|
|
||||||
param = {
|
|
||||||
'tag_name': f'{cur_time}',
|
|
||||||
'target_commitish': 'publish',
|
|
||||||
'name': f'{cur_time}',
|
|
||||||
'body': f'\n'
|
|
||||||
f'publish addon_infos.json',
|
|
||||||
'draft': False,
|
|
||||||
'prerelease': False,
|
|
||||||
'generate_release_notes': False,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
create_resp = requests.post(create_release_url, json=param,
|
|
||||||
headers=github_api_headers(github_token=kwargs.get('github_token')))
|
|
||||||
if create_resp.status_code == 201:
|
|
||||||
create_release_info = json.loads(create_resp.content)
|
|
||||||
release_id = create_release_info['id']
|
|
||||||
return release_id
|
|
||||||
else:
|
|
||||||
print(f'create release code: {create_resp.status_code}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'create release failed: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def delete_cache(github_repository, github_token, remain_count=2):
|
|
||||||
headers = github_api_headers(github_token=github_token)
|
|
||||||
get_caches_url = (f'https://api.github.com/repos/{github_repository}'
|
|
||||||
f'/actions/caches?per_page=100&sort=last_accessed_at&direction=desc')
|
|
||||||
try:
|
|
||||||
caches_resp = requests.get(get_caches_url, headers=headers)
|
|
||||||
caches = json.loads(caches_resp.content)
|
|
||||||
if caches.get('total_count', 0) < remain_count:
|
|
||||||
return
|
|
||||||
delete_cache_url = f'https://api.github.com/repos/{github_repository}/actions/caches/'
|
|
||||||
for cache in caches.get('actions_caches', [])[remain_count:]:
|
|
||||||
cache_key = cache.get('key')
|
|
||||||
if cache_id := cache.get('id'):
|
|
||||||
try:
|
|
||||||
delete_cache_resp = requests.delete(f'{delete_cache_url}{cache_id}', headers=headers)
|
|
||||||
if delete_cache_resp.status_code == 204:
|
|
||||||
print(f'delete {cache_key} succeed')
|
|
||||||
else:
|
|
||||||
print(f'delete {cache_key} failed: {delete_cache_resp.text}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'delete cache for {cache_key} failed: {e}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'get caches failed: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def delete_release(github_repository, github_token, remain_count=2):
|
|
||||||
headers = github_api_headers(github_token=github_token)
|
|
||||||
get_release_url = (f'https://api.github.com/repos/{github_repository}'
|
|
||||||
f'/releases?per_page=100&page=1')
|
|
||||||
try:
|
|
||||||
releases_resp = requests.get(get_release_url, headers=headers)
|
|
||||||
releases = json.loads(releases_resp.content)
|
|
||||||
if len(releases) < remain_count:
|
|
||||||
return
|
|
||||||
releases.sort(key=lambda release: release['tag_name'], reverse=True)
|
|
||||||
delete_release_url = f'https://api.github.com/repos/{github_repository}/releases/'
|
|
||||||
for release in releases[remain_count:]:
|
|
||||||
release_tag = release.get('tag_name')
|
|
||||||
if release_id := release.get('id'):
|
|
||||||
try:
|
|
||||||
delete_release_resp = requests.delete(f'{delete_release_url}{release_id}', headers=headers)
|
|
||||||
if delete_release_resp.status_code == 204:
|
|
||||||
print(f'delete release {release_tag} succeed')
|
|
||||||
else:
|
|
||||||
print(f'delete release {release_tag} failed: {delete_release_resp.text}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'delete release for {release_tag} failed: {e}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'get releases failed: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def delete_tag(github_repository, github_token, remain_count=2):
|
|
||||||
headers = github_api_headers(github_token=github_token)
|
|
||||||
get_tags_url = (f'https://api.github.com/repos/{github_repository}'
|
|
||||||
f'/git/refs/tags')
|
|
||||||
try:
|
|
||||||
tags_response = requests.get(get_tags_url, headers=headers)
|
|
||||||
tags = json.loads(tags_response.content)
|
|
||||||
if len(tags) < remain_count:
|
|
||||||
return
|
|
||||||
tags.sort(key=lambda tag: tag['ref'], reverse=True)
|
|
||||||
delete_tag_url = f'https://api.github.com/repos/{github_repository}/git/refs/tags/'
|
|
||||||
for tag in tags[remain_count:]:
|
|
||||||
if ref := tag.get('ref').replace('refs/tags/', ''):
|
|
||||||
if len(ref) < 10:
|
|
||||||
continue
|
|
||||||
try: # automatically tag name is timestamp
|
|
||||||
int(ref)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
delete_tag_resp = requests.delete(f'{delete_tag_url}{ref}', headers=headers)
|
|
||||||
if delete_tag_resp.status_code == 204:
|
|
||||||
print(f'delete tag {ref} succeed')
|
|
||||||
else:
|
|
||||||
print(f'delete tag {ref} failed: {delete_tag_resp.text}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'delete tag for {ref} failed: {e}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'get tags failed: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def rate_limit(github_token):
|
|
||||||
try:
|
|
||||||
resp = requests.get('https://api.github.com/rate_limit', headers=github_api_headers(github_token=github_token))
|
|
||||||
rate = json.loads(resp.content)
|
|
||||||
print(f'token rate {rate.get("rate")}')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'get rate limit failed: {e}')
|
|
||||||
336
main.py
336
main.py
@ -1,320 +1,30 @@
|
|||||||
import concurrent.futures
|
#!/usr/bin/env python3
|
||||||
import urllib.parse
|
"""
|
||||||
import argparse
|
Backward compatible entry point for Zotero addons scraper.
|
||||||
from addon_info import *
|
|
||||||
from fallback_infos import fallback_if_need
|
|
||||||
from moz_addons import addon_details, compare_versions
|
|
||||||
from github_operations import *
|
|
||||||
from file_cache import *
|
|
||||||
|
|
||||||
|
This file maintains backward compatibility with existing CI/CD pipelines
|
||||||
|
and command-line usage. It delegates to the new modular implementation.
|
||||||
|
|
||||||
def download_xpi(xpi_url: str, download_dir: str, unique_name: str, force_download: bool, **kwargs):
|
Usage:
|
||||||
try:
|
python main.py -i addons -o addon_infos.json \\
|
||||||
download_filepath = os.path.join(download_dir, unique_name)
|
--github_repository owner/repo \\
|
||||||
folder = os.path.dirname(download_filepath)
|
--github_token TOKEN \\
|
||||||
if folder and not os.path.exists(folder):
|
--cache_directory caches \\
|
||||||
os.makedirs(folder)
|
--runtime_xpi_directory xpis \\
|
||||||
|
--previous_info_urls URL1 URL2 \\
|
||||||
|
--create_release True
|
||||||
|
"""
|
||||||
|
|
||||||
if cache_dir := kwargs.get('cache_dir'):
|
import sys
|
||||||
cache_filepath = os.path.join(cache_dir, unique_name)
|
|
||||||
if not force_download and os.path.isfile(cache_filepath):
|
|
||||||
shutil.copy(cache_filepath, download_filepath) # copy to download filepath
|
|
||||||
return download_filepath
|
|
||||||
|
|
||||||
if not force_download and os.path.isfile(download_filepath):
|
# Add src to path for development
|
||||||
return download_filepath
|
from pathlib import Path
|
||||||
|
|
||||||
response = requests.get(xpi_url, stream=True)
|
src_path = Path(__file__).parent / "src"
|
||||||
if response.status_code == 200:
|
if src_path.exists():
|
||||||
print(f'download {unique_name} from {xpi_url}')
|
sys.path.insert(0, str(src_path))
|
||||||
with open(download_filepath, "wb") as file:
|
|
||||||
for chunk in response.iter_content(chunk_size=1024):
|
|
||||||
file.write(chunk)
|
|
||||||
return download_filepath
|
|
||||||
except Exception as e:
|
|
||||||
print(f'download {unique_name} from {xpi_url} failed: {e}')
|
|
||||||
|
|
||||||
|
from zotero_scraper.__main__ import main
|
||||||
|
|
||||||
# 输出格式参考 [zotero-chinese/zotero-plugins](https://github.com/zotero-chinese/zotero-plugins)
|
if __name__ == "__main__":
|
||||||
def parse(plugin: AddonInfo, **kwargs):
|
sys.exit(main())
|
||||||
if not plugin.owner or not plugin.releases or len(plugin.releases) <= 0:
|
|
||||||
return
|
|
||||||
plugin.name = plugin.repository
|
|
||||||
headers = github_api_headers(github_token=kwargs.get('github_token'))
|
|
||||||
|
|
||||||
# fetch author info
|
|
||||||
author_url = f"https://api.github.com/users/{plugin.owner}"
|
|
||||||
try:
|
|
||||||
author_resp = requests.get(author_url, headers=headers)
|
|
||||||
author_resp_info = json.loads(author_resp.content)
|
|
||||||
|
|
||||||
plugin.author.name = author_resp_info['name'] if 'name' in author_resp_info and author_resp_info[
|
|
||||||
'name'] else plugin.owner
|
|
||||||
if 'html_url' in author_resp_info:
|
|
||||||
plugin.author.url = author_resp_info['html_url']
|
|
||||||
if 'avatar_url' in author_resp_info:
|
|
||||||
plugin.author.avatar = author_resp_info['avatar_url']
|
|
||||||
except Exception as e:
|
|
||||||
print(f'request {author_url} failed: {e}')
|
|
||||||
|
|
||||||
# fetch repo info
|
|
||||||
repo_url = f'https://api.github.com/repos/{plugin.repo}'
|
|
||||||
try:
|
|
||||||
repos_resp = requests.get(repo_url, headers=headers)
|
|
||||||
repos_info = json.loads(repos_resp.content)
|
|
||||||
if 'description' in repos_info and repos_info['description'] and not plugin.description:
|
|
||||||
plugin.description = repos_info['description']
|
|
||||||
if 'stargazers_count' in repos_info and repos_info['stargazers_count'] is not None:
|
|
||||||
plugin.stars = repos_info['stargazers_count']
|
|
||||||
except Exception as e:
|
|
||||||
print(f'request {repo_url} failed: {e}')
|
|
||||||
|
|
||||||
# fetch release info
|
|
||||||
invalid_releases = []
|
|
||||||
for release in plugin.releases:
|
|
||||||
if not release.tagName:
|
|
||||||
continue
|
|
||||||
release_url = f'https://api.github.com/repos/{plugin.repo}/releases'
|
|
||||||
if release.tagName == 'latest':
|
|
||||||
release_url += '/latest'
|
|
||||||
elif release.tagName != 'pre':
|
|
||||||
release_url += f'/tags/{release.tagName}'
|
|
||||||
|
|
||||||
try:
|
|
||||||
release_resp = requests.get(release_url, headers=headers)
|
|
||||||
release_info = json.loads(release_resp.content)
|
|
||||||
|
|
||||||
if release.tagName == 'pre':
|
|
||||||
release_info = [info for info in release_info if info['prerelease']]
|
|
||||||
if release_info:
|
|
||||||
release_info = release_info[0]
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
if 'tag_name' in release_info:
|
|
||||||
release.tagName = release_info['tag_name']
|
|
||||||
|
|
||||||
if 'assets' not in release_info:
|
|
||||||
continue
|
|
||||||
all_release_assets = release_info['assets']
|
|
||||||
all_release_assets.sort(key=lambda item: item['updated_at'] if 'updated_at' in item else '', reverse=True)
|
|
||||||
release_assets = [asset for asset in all_release_assets if asset['content_type'] == 'application/x-xpinstall']
|
|
||||||
if not release_assets:
|
|
||||||
release_assets = [asset for asset in all_release_assets if asset['content_type'] == 'application/x-zip-compressed']
|
|
||||||
if not release_assets:
|
|
||||||
continue
|
|
||||||
release_asset = release_assets[0]
|
|
||||||
if 'browser_download_url' not in release_asset:
|
|
||||||
continue
|
|
||||||
details = parse_xpi_detail(
|
|
||||||
plugin=plugin, release=release, release_asset=release_asset,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
if details:
|
|
||||||
if detail_id := details.id:
|
|
||||||
release.id = detail_id
|
|
||||||
if detail_name := details.name:
|
|
||||||
release.name = detail_name
|
|
||||||
if detail_version := details.version:
|
|
||||||
release.xpiVersion = detail_version
|
|
||||||
if detail_desc := details.description:
|
|
||||||
release.description = detail_desc
|
|
||||||
if zotero_min_version := details.min_version:
|
|
||||||
release.minZoteroVersion = zotero_min_version
|
|
||||||
if zotero_max_version := details.max_version:
|
|
||||||
release.maxZoteroVersion = zotero_max_version
|
|
||||||
|
|
||||||
if not details.check_compatible_for_zotero_version(release.zotero_check_version):
|
|
||||||
invalid_releases.append(release)
|
|
||||||
|
|
||||||
if ((not release.minZoteroVersion or release.minZoteroVersion == '*') and
|
|
||||||
(not release.maxZoteroVersion or release.maxZoteroVersion == '*')):
|
|
||||||
report_issue(kwargs.get('github_repository'),
|
|
||||||
title=f'Parse {plugin.repo} of zotero version failed',
|
|
||||||
body=f'xpi: https://github.com/{plugin.repo} @{release.tagName} on {release.targetZoteroVersion}\n',
|
|
||||||
github_token=kwargs.get('github_token'),
|
|
||||||
id=f'Parse min/max version failed: {plugin.repo}+{release.tagName}@{release.targetZoteroVersion}')
|
|
||||||
else:
|
|
||||||
report_issue(kwargs.get('github_repository'),
|
|
||||||
title=f'Parse {plugin.repo} addon details failed',
|
|
||||||
body=f'xpi: https://github.com/{plugin.repo} @{release.tagName} on {release.targetZoteroVersion}\n',
|
|
||||||
github_token=kwargs.get('github_token'),
|
|
||||||
id=f'Parse details failed: {plugin.repo}+{release.tagName}@{release.targetZoteroVersion}')
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f'handle {plugin.repo} request {release_url} failed: {e}')
|
|
||||||
|
|
||||||
for invalid_release in invalid_releases:
|
|
||||||
print(plugin.repo, 'invalid', invalid_release.zotero_check_version)
|
|
||||||
report_issue(kwargs.get('github_repository'),
|
|
||||||
title=f'Invalid {plugin.repo} xpi with zotero version {invalid_release.zotero_check_version}',
|
|
||||||
body=f'xpi: https://github.com/{plugin.repo} @{invalid_release.tagName}\n'
|
|
||||||
f'min zotero Version:{invalid_release.minZoteroVersion}\n'
|
|
||||||
f'max Zotero version:{invalid_release.maxZoteroVersion}'
|
|
||||||
f'expect Zotero version:{invalid_release.zotero_check_version}\n',
|
|
||||||
github_token=kwargs.get('github_token'),
|
|
||||||
id=f'Target zotero version not match: {plugin.repo}+{invalid_release.tagName}@{release.targetZoteroVersion}')
|
|
||||||
plugin.releases.remove(invalid_release)
|
|
||||||
return plugin
|
|
||||||
|
|
||||||
|
|
||||||
def parse_xpi_detail(plugin: AddonInfo, release: AddonInfoRelease, release_asset, **kwargs):
|
|
||||||
priority_sources = ['rdf', 'json']
|
|
||||||
if release.targetZoteroVersion == '6':
|
|
||||||
priority_sources = ['json', 'rdf']
|
|
||||||
|
|
||||||
def xpi_detail(xpi_url, xpi_filename):
|
|
||||||
try:
|
|
||||||
xpi_filepath = download_xpi(xpi_url=xpi_url,
|
|
||||||
download_dir=kwargs.get('runtime_xpi_directory'),
|
|
||||||
unique_name=xpi_filename,
|
|
||||||
force_download=False,
|
|
||||||
cache_dir=kwargs.get('cache_directory'))
|
|
||||||
details = addon_details(xpi_filepath, priority_sources=priority_sources)
|
|
||||||
return details
|
|
||||||
except:
|
|
||||||
try:
|
|
||||||
xpi_filepath = download_xpi(xpi_url=xpi_url,
|
|
||||||
download_dir=kwargs.get('runtime_xpi_directory'),
|
|
||||||
unique_name=xpi_filename,
|
|
||||||
force_download=True)
|
|
||||||
details = addon_details(xpi_filepath, priority_sources=priority_sources)
|
|
||||||
return details
|
|
||||||
except Exception as e:
|
|
||||||
print(f'fetch addon detail of {plugin.repo} with {xpi_url} failed: {e}')
|
|
||||||
|
|
||||||
github_xpi_url = release_asset['browser_download_url']
|
|
||||||
release.releaseDate = release_asset['updated_at']
|
|
||||||
release.xpiDownloadUrl = {
|
|
||||||
'github': github_xpi_url,
|
|
||||||
'ghProxy': 'https://gh-proxy.com/' + urllib.parse.quote(github_xpi_url),
|
|
||||||
'kgithub': github_xpi_url.replace('github.com', 'kkgithub.com'),
|
|
||||||
}
|
|
||||||
details = xpi_detail(xpi_url=github_xpi_url,
|
|
||||||
xpi_filename=f'{plugin.owner}#{plugin.repository}+{release.tagName}@{release_asset["id"]}.xpi')
|
|
||||||
|
|
||||||
if details and details.update_url and details.id and details.version:
|
|
||||||
try:
|
|
||||||
update_json_resp = requests.get(details.update_url,
|
|
||||||
headers=github_api_headers(github_token=kwargs.get('github_token')))
|
|
||||||
update_json_info = json.loads(update_json_resp.content)
|
|
||||||
|
|
||||||
updates = update_json_info.get('addons', {}).get(details.id, {}).get('updates', [])
|
|
||||||
xpi_urls = [e.get('update_link') for e in updates if compare_versions(e.get('version'), details.version) > 0]
|
|
||||||
|
|
||||||
for xpi_url in xpi_urls:
|
|
||||||
update_details = xpi_detail(xpi_url=xpi_url,
|
|
||||||
xpi_filename=f'{plugin.owner}#{plugin.repository}+update{details.version}.xpi')
|
|
||||||
if update_details and update_details.id and update_details.check_compatible_for_zotero_version(release.zotero_check_version):
|
|
||||||
details = update_details
|
|
||||||
release.xpiDownloadUrl = {
|
|
||||||
'github': xpi_url,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
except: pass
|
|
||||||
return details
|
|
||||||
|
|
||||||
|
|
||||||
def fallback(addon_infos, previous_info_url, **kwargs):
|
|
||||||
try:
|
|
||||||
repos_resp = requests.get(previous_info_url,
|
|
||||||
headers=github_api_headers(github_token=kwargs.get('github_token')))
|
|
||||||
repos_info = json.loads(repos_resp.content)
|
|
||||||
addon_infos = fallback_if_need(addon_infos, repos_info)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'request previous addon_info.json to merge failed: {e}')
|
|
||||||
return addon_infos
|
|
||||||
|
|
||||||
|
|
||||||
def parse_addon_infos(input_dir, output_filepath, **kwargs):
|
|
||||||
plugins = []
|
|
||||||
for addon_json_filename in os.listdir(input_dir):
|
|
||||||
if not addon_json_filename.endswith('.json'):
|
|
||||||
continue
|
|
||||||
addon_json_filepath = os.path.join(input_dir, addon_json_filename)
|
|
||||||
with open(addon_json_filepath, 'r') as file:
|
|
||||||
try:
|
|
||||||
plugins.append(AddonInfo(**json.load(file)))
|
|
||||||
except Exception as e:
|
|
||||||
print(f'parse initial addon info from {addon_json_filepath} failed: {e}')
|
|
||||||
|
|
||||||
addon_infos = []
|
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
||||||
futures = [executor.submit(parse,
|
|
||||||
plugin,
|
|
||||||
github_token=kwargs.get('github_token'),
|
|
||||||
cache_directory=kwargs.get('cache_directory'),
|
|
||||||
runtime_xpi_directory=kwargs.get('runtime_xpi_directory'),
|
|
||||||
github_repository=kwargs.get('github_repository'))
|
|
||||||
for plugin in plugins]
|
|
||||||
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
|
||||||
if addon_info := future.result():
|
|
||||||
addon_infos.append(addon_info)
|
|
||||||
|
|
||||||
addon_infos = [info.__dict__ for info in addon_infos]
|
|
||||||
for previous_info_url in kwargs.get('previous_info_urls', []):
|
|
||||||
addon_infos = fallback(addon_infos, previous_info_url, github_token=kwargs.get('github_token'))
|
|
||||||
|
|
||||||
addon_infos.sort(key=lambda item: item.get('stars') if item.get('stars') else 0, reverse=True)
|
|
||||||
|
|
||||||
dir = os.path.dirname(output_filepath)
|
|
||||||
if dir and not os.path.exists(dir):
|
|
||||||
os.makedirs(dir)
|
|
||||||
with open(output_filepath, "w") as json_file:
|
|
||||||
json.dump(addon_infos, json_file, ensure_ascii=False)
|
|
||||||
|
|
||||||
return addon_infos
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
parser = argparse.ArgumentParser(description='params')
|
|
||||||
parser.add_argument('--github_repository', nargs='?', type=str, required=True, help='github repository')
|
|
||||||
parser.add_argument('--github_token', nargs='?', type=str, help='github token')
|
|
||||||
parser.add_argument('-i', '--input', nargs='?', type=str, default="addons", help='input addon dir')
|
|
||||||
parser.add_argument('-o', '--output', nargs='?', type=str, default="addon_infos.json", help='output addon filepath')
|
|
||||||
|
|
||||||
parser.add_argument('--cache_directory', nargs='?', default="caches", type=str, help='folder for caches')
|
|
||||||
parser.add_argument('--cache_lockfile', nargs='?', default="caches_lockfile", type=str, help='hashfile for caches')
|
|
||||||
parser.add_argument('--runtime_xpi_directory', nargs='?', default="xpis", type=str, help='folder for download xpi')
|
|
||||||
parser.add_argument('--previous_info_urls', nargs='+', default=[], help='previous published info json to fallback')
|
|
||||||
parser.add_argument('--create_release', nargs='?', default=True, type=bool, help='create release in github')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if not args.github_repository:
|
|
||||||
raise 'Need specific github repository'
|
|
||||||
|
|
||||||
if args.github_token:
|
|
||||||
rate_limit(args.github_token)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not os.path.isdir(args.cache_directory):
|
|
||||||
os.makedirs(args.cache_directory, exist_ok=True)
|
|
||||||
if not os.path.isdir(args.runtime_xpi_directory):
|
|
||||||
os.makedirs(args.runtime_xpi_directory, exist_ok=True)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'create cache_directory failed: {e}')
|
|
||||||
|
|
||||||
parse_addon_infos(args.input,
|
|
||||||
args.output,
|
|
||||||
github_repository=args.github_repository,
|
|
||||||
github_token=args.github_token,
|
|
||||||
cache_directory=args.cache_directory,
|
|
||||||
runtime_xpi_directory=args.runtime_xpi_directory,
|
|
||||||
previous_info_urls=args.previous_info_urls)
|
|
||||||
|
|
||||||
delete_release(args.github_repository, github_token=args.github_token)
|
|
||||||
delete_tag(args.github_repository, github_token=args.github_token)
|
|
||||||
if args.create_release and (release_id := create_release(args.github_repository, github_token=args.github_token)):
|
|
||||||
upload_json_to_release(args.github_repository,
|
|
||||||
release_id,
|
|
||||||
upload_file_name='addon_infos.json',
|
|
||||||
upload_file=args.output,
|
|
||||||
github_token=args.github_token)
|
|
||||||
|
|
||||||
update_cache(args.cache_directory, args.runtime_xpi_directory, args.cache_lockfile)
|
|
||||||
|
|
||||||
if args.github_token:
|
|
||||||
delete_cache(args.github_repository, args.github_token, remain_count=1)
|
|
||||||
|
|||||||
283
moz_addons.py
283
moz_addons.py
@ -1,283 +0,0 @@
|
|||||||
# modify from `https://github.com/
|
|
||||||
# mozilla/gecko-dev/blob/ac19e2c0d7c09a2deaedbe6afc4cdcf1a4561456/testing/mozbase/mozprofile/mozprofile/addons.py#L213`
|
|
||||||
|
|
||||||
import zipfile
|
|
||||||
import commentjson as json
|
|
||||||
import os
|
|
||||||
from xml.dom import minidom
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def compare_versions(version1, version2):
|
|
||||||
parts1 = version1.replace('-', '.').split('.')
|
|
||||||
parts2 = version2.replace('-', '.').split('.')
|
|
||||||
|
|
||||||
for i in range(max(len(parts1), len(parts2))):
|
|
||||||
v1 = parts1[i] if i < len(parts1) else '0'
|
|
||||||
v2 = parts2[i] if i < len(parts2) else '0'
|
|
||||||
try:
|
|
||||||
num1 = int(v1)
|
|
||||||
except ValueError:
|
|
||||||
num1 = v1
|
|
||||||
try:
|
|
||||||
num2 = int(v2)
|
|
||||||
except ValueError:
|
|
||||||
num2 = v2
|
|
||||||
|
|
||||||
if isinstance(num1, int) and isinstance(num2, int):
|
|
||||||
if num1 < num2:
|
|
||||||
return -1
|
|
||||||
if num1 > num2:
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
if v1 < v2:
|
|
||||||
return -1
|
|
||||||
if v1 > v2:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
class XpiDetail:
|
|
||||||
def __init__(self):
|
|
||||||
self.id = None
|
|
||||||
self.name = None
|
|
||||||
self.version = None
|
|
||||||
self.description = None
|
|
||||||
self.update_url = None
|
|
||||||
self.min_version = "*"
|
|
||||||
self.max_version = "*"
|
|
||||||
|
|
||||||
def _append_info(self, details: dict):
|
|
||||||
if id := details.get('id'):
|
|
||||||
if self.id and self.id != id:
|
|
||||||
print(f'Xpi ID not match? {self.id} <==> {id}')
|
|
||||||
return
|
|
||||||
self.id = id
|
|
||||||
if name := details.get('name'):
|
|
||||||
self.name = name
|
|
||||||
if version := details.get('version'):
|
|
||||||
self.version = version
|
|
||||||
if description := details.get('description'):
|
|
||||||
self.description = description
|
|
||||||
if update_url := details.get('updateURL'):
|
|
||||||
self.update_url = update_url
|
|
||||||
if update_url := details.get('update_url'):
|
|
||||||
self.update_url = update_url
|
|
||||||
if (min_version := details.get('min_version')) and (max_version := details.get('max_version')):
|
|
||||||
if compare_versions(min_version.replace('*', '0'), self.min_version.replace('*', '999')) <= 0:
|
|
||||||
self.min_version = min_version
|
|
||||||
if compare_versions(max_version.replace('*', '999'), self.max_version.replace('*', '0')) >= 0:
|
|
||||||
self.max_version = max_version
|
|
||||||
|
|
||||||
def check_compatible_for_zotero_version(self, version: str | int):
|
|
||||||
if isinstance(version, int):
|
|
||||||
version = str(version) + '.*'
|
|
||||||
if (min_version := self.min_version.replace('*', '0')) and (
|
|
||||||
max_version := self.max_version.replace('*', '999')):
|
|
||||||
return (compare_versions(min_version, version.replace('*', '999')) <= 0
|
|
||||||
<= compare_versions(max_version, version.replace('*', '0')))
|
|
||||||
|
|
||||||
|
|
||||||
def get_namespace_id(doc, url):
|
|
||||||
attributes = doc.documentElement.attributes
|
|
||||||
for i in range(attributes.length):
|
|
||||||
if attributes.item(i).value == url and ":" in attributes.item(i).name:
|
|
||||||
return attributes.item(i).name.split(":")[1] + ":"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def get_text(element):
|
|
||||||
"""Retrieve the text value of a given node"""
|
|
||||||
rc = []
|
|
||||||
for node in element.childNodes:
|
|
||||||
if node.nodeType == node.TEXT_NODE:
|
|
||||||
rc.append(node.data)
|
|
||||||
return "".join(rc).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def manifest_from_json(addon_path):
|
|
||||||
try:
|
|
||||||
if zipfile.is_zipfile(addon_path):
|
|
||||||
with zipfile.ZipFile(addon_path, "r") as compressed_file:
|
|
||||||
filenames = [f.filename for f in compressed_file.filelist]
|
|
||||||
if "manifest.json" in filenames:
|
|
||||||
manifest = compressed_file.read("manifest.json").decode()
|
|
||||||
manifest = json.loads(manifest)
|
|
||||||
return manifest
|
|
||||||
elif os.path.isdir(addon_path):
|
|
||||||
with open(os.path.join(addon_path, "manifest.json")) as f:
|
|
||||||
manifest = json.loads(f.read())
|
|
||||||
return manifest
|
|
||||||
except Exception as e:
|
|
||||||
print(f'Invalid Addon Path {addon_path}: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def manifest_from_rdf(addon_path):
|
|
||||||
try:
|
|
||||||
if zipfile.is_zipfile(addon_path):
|
|
||||||
with zipfile.ZipFile(addon_path, "r") as compressed_file:
|
|
||||||
filenames = [f.filename for f in compressed_file.filelist]
|
|
||||||
if "install.rdf" in filenames:
|
|
||||||
manifest = compressed_file.read("install.rdf")
|
|
||||||
return manifest
|
|
||||||
elif os.path.isdir(addon_path):
|
|
||||||
with open(os.path.join(addon_path, "install.rdf")) as f:
|
|
||||||
manifest = json.loads(f.read())
|
|
||||||
return manifest
|
|
||||||
except Exception as e:
|
|
||||||
print(f'Invalid Addon Path {addon_path}: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
def detail_from_manifest_json(addon_path, manifest):
|
|
||||||
details = {
|
|
||||||
"name": manifest.get("name"),
|
|
||||||
"version": manifest.get("version"),
|
|
||||||
"description": manifest.get("description"),
|
|
||||||
"id": None,
|
|
||||||
"min_version": None,
|
|
||||||
"max_version": None,
|
|
||||||
"update_url": None,
|
|
||||||
}
|
|
||||||
for location in ("applications", "browser_specific_settings"):
|
|
||||||
if details["id"]:
|
|
||||||
break
|
|
||||||
for app in ("zotero", "gecko"):
|
|
||||||
try:
|
|
||||||
details["id"] = manifest[location][app].get("id")
|
|
||||||
details["min_version"] = manifest[location][app].get("strict_min_version")
|
|
||||||
details["max_version"] = manifest[location][app].get("strict_max_version")
|
|
||||||
details["update_url"] = manifest[location][app].get("update_url")
|
|
||||||
break
|
|
||||||
except KeyError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# handler for __MSG_{}__ items
|
|
||||||
def extract_msg_placeholder(text):
|
|
||||||
if match := re.search(r'__MSG_(.*?)__', text):
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
def load_locale_for_msg():
|
|
||||||
default_locale = manifest.get('default_locale')
|
|
||||||
locale_filename = f"_locales/{default_locale}/messages.json"
|
|
||||||
try:
|
|
||||||
if zipfile.is_zipfile(addon_path):
|
|
||||||
with zipfile.ZipFile(addon_path, "r") as compressed_file:
|
|
||||||
filenames = [f.filename for f in compressed_file.filelist]
|
|
||||||
if locale_filename in filenames:
|
|
||||||
locale = compressed_file.read(locale_filename).decode()
|
|
||||||
return json.loads(locale)
|
|
||||||
elif os.path.isdir(addon_path):
|
|
||||||
with open(os.path.join(addon_path, locale_filename)) as f:
|
|
||||||
return json.loads(f.read())
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
locale_for_msg = None
|
|
||||||
for key in details:
|
|
||||||
if isinstance(details[key], str) and (placeholder := extract_msg_placeholder(details[key])):
|
|
||||||
if not locale_for_msg:
|
|
||||||
locale_for_msg = load_locale_for_msg()
|
|
||||||
if not locale_for_msg:
|
|
||||||
break
|
|
||||||
if value := locale_for_msg.get(placeholder, {}).get('message'):
|
|
||||||
details[key] = value
|
|
||||||
|
|
||||||
return details
|
|
||||||
|
|
||||||
|
|
||||||
def detail_from_manifest_rdf(manifest):
|
|
||||||
details = {
|
|
||||||
"id": None,
|
|
||||||
"name": None,
|
|
||||||
"version": None,
|
|
||||||
"description": None,
|
|
||||||
"min_version": None,
|
|
||||||
"max_version": None,
|
|
||||||
"updateURL": None,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
doc = minidom.parseString(manifest)
|
|
||||||
|
|
||||||
# Get the namespaces abbreviations
|
|
||||||
em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")
|
|
||||||
rdf = get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
|
|
||||||
|
|
||||||
description = doc.getElementsByTagName(rdf + "Description").item(0)
|
|
||||||
try:
|
|
||||||
descriptions = [e for e in doc.getElementsByTagName(rdf + "Description")
|
|
||||||
if len(e.getElementsByTagName(em + "targetApplication")) > 0]
|
|
||||||
if descriptions:
|
|
||||||
description = descriptions[0]
|
|
||||||
except Exception as e:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def extract_info(node, result):
|
|
||||||
try:
|
|
||||||
for entry, value in node.attributes.items():
|
|
||||||
entry = entry.replace(em, "")
|
|
||||||
if entry in result.keys():
|
|
||||||
result.update({entry: value})
|
|
||||||
for child_node in node.childNodes:
|
|
||||||
entry = child_node.nodeName.replace(em, "")
|
|
||||||
if entry in result.keys():
|
|
||||||
result.update({entry: get_text(child_node)})
|
|
||||||
except:
|
|
||||||
return
|
|
||||||
|
|
||||||
extract_info(description, details)
|
|
||||||
if not details.get('id') or (details.get('id').startswith("__") and details.get('id').endswith("__")):
|
|
||||||
return
|
|
||||||
|
|
||||||
def update_details(version_info):
|
|
||||||
if version_info['id'] != 'zotero@chnm.gmu.edu':
|
|
||||||
return
|
|
||||||
if (min_version := version_info['minVersion']) and (max_version := version_info['maxVersion']):
|
|
||||||
if exist_min_version := details['min_version']:
|
|
||||||
if compare_versions(min_version.replace('*', '0'), exist_min_version.replace('*', '999')) <= 0:
|
|
||||||
details['min_version'] = min_version
|
|
||||||
else:
|
|
||||||
details['min_version'] = min_version
|
|
||||||
|
|
||||||
if compare_versions(max_version.replace('*', '999'), '6.*') > 0:
|
|
||||||
# rdf not support z7
|
|
||||||
max_version = '6.*'
|
|
||||||
if exist_max_version := details['max_version']:
|
|
||||||
if compare_versions(max_version.replace('*', '999'), exist_max_version.replace('*', '0')) >= 0:
|
|
||||||
details['max_version'] = max_version
|
|
||||||
else:
|
|
||||||
details['max_version'] = max_version
|
|
||||||
|
|
||||||
for targetApplication in description.getElementsByTagName(em + "targetApplication"):
|
|
||||||
version_info = {'id': None, 'minVersion': None, 'maxVersion': None}
|
|
||||||
extract_info(targetApplication, version_info)
|
|
||||||
update_details(version_info)
|
|
||||||
|
|
||||||
for node in targetApplication.childNodes:
|
|
||||||
version_info = {'id': None, 'minVersion': None, 'maxVersion': None}
|
|
||||||
extract_info(node, version_info)
|
|
||||||
update_details(version_info)
|
|
||||||
|
|
||||||
if update_url := details.get('updateURL'):
|
|
||||||
details['update_url'] = update_url
|
|
||||||
|
|
||||||
return details
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def addon_details(addon_path, priority_sources=None) -> XpiDetail:
|
|
||||||
if priority_sources is None:
|
|
||||||
priority_sources = ['json', 'rdf']
|
|
||||||
if not os.path.exists(addon_path):
|
|
||||||
raise IOError(f"Add-on path does not exist: {addon_path}")
|
|
||||||
xpi_detail = XpiDetail()
|
|
||||||
for source in priority_sources:
|
|
||||||
if source == 'json':
|
|
||||||
if ((manifest := manifest_from_json(addon_path)) and
|
|
||||||
(details := detail_from_manifest_json(addon_path, manifest))):
|
|
||||||
xpi_detail._append_info(details)
|
|
||||||
if source == 'rdf':
|
|
||||||
if ((manifest := manifest_from_rdf(addon_path)) and
|
|
||||||
(details := detail_from_manifest_rdf(manifest))):
|
|
||||||
xpi_detail._append_info(details)
|
|
||||||
return xpi_detail
|
|
||||||
48
pyproject.toml
Normal file
48
pyproject.toml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "zotero-addons-scraper"
|
||||||
|
version = "2.0.0"
|
||||||
|
description = "Scrape Zotero addon information from GitHub"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.28.0",
|
||||||
|
"commentjson>=0.9.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"pytest-mock>=3.10.0",
|
||||||
|
"requests-mock>=1.11.0",
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
zotero-scraper = "zotero_scraper.__main__:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
addopts = "-v --cov=src/zotero_scraper --cov-report=term-missing"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py310"
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
13
requirements-dev.txt
Normal file
13
requirements-dev.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=7.0.0
|
||||||
|
pytest-cov>=4.0.0
|
||||||
|
pytest-mock>=3.10.0
|
||||||
|
requests-mock>=1.11.0
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy>=1.0.0
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
ruff>=0.1.0
|
||||||
@ -1,3 +1,2 @@
|
|||||||
requests
|
requests>=2.28.0
|
||||||
argparse
|
commentjson>=0.9.0
|
||||||
commentjson
|
|
||||||
|
|||||||
3
src/zotero_scraper/__init__.py
Normal file
3
src/zotero_scraper/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Zotero Addons Scraper - Scrape Zotero addon information from GitHub."""
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
148
src/zotero_scraper/__main__.py
Normal file
148
src/zotero_scraper/__main__.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""Command-line interface for Zotero addons scraper."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .cache.file_cache import FileCache
|
||||||
|
from .config.settings import ScraperConfig
|
||||||
|
from .services.scraper import AddonScraper
|
||||||
|
from .utils.logging import setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command line arguments."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Scrape Zotero addon information from GitHub"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Required arguments
|
||||||
|
parser.add_argument(
|
||||||
|
"--github_repository",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="GitHub repository (owner/repo)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional arguments
|
||||||
|
parser.add_argument(
|
||||||
|
"--github_token",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="GitHub API token",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-i",
|
||||||
|
"--input",
|
||||||
|
type=str,
|
||||||
|
default="addons",
|
||||||
|
help="Input addon config directory (default: addons)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
default="addon_infos.json",
|
||||||
|
help="Output JSON file path (default: addon_infos.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache_directory",
|
||||||
|
type=str,
|
||||||
|
default="caches",
|
||||||
|
help="Cache directory (default: caches)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache_lockfile",
|
||||||
|
type=str,
|
||||||
|
default="caches_lockfile",
|
||||||
|
help="Cache lockfile name (default: caches_lockfile)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--runtime_xpi_directory",
|
||||||
|
type=str,
|
||||||
|
default="xpis",
|
||||||
|
help="Runtime XPI directory (default: xpis)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--previous_info_urls",
|
||||||
|
nargs="+",
|
||||||
|
default=[],
|
||||||
|
help="URLs to previous addon_infos.json for fallback",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--create_release",
|
||||||
|
type=lambda x: x.lower() in ("true", "1", "yes"),
|
||||||
|
default=True,
|
||||||
|
help="Create GitHub release (default: True)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log_level",
|
||||||
|
type=str,
|
||||||
|
default="INFO",
|
||||||
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||||
|
help="Logging level (default: INFO)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main entry point."""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log_level = getattr(logging, args.log_level.upper())
|
||||||
|
setup_logging(level=log_level)
|
||||||
|
|
||||||
|
logger = logging.getLogger("zotero_scraper")
|
||||||
|
|
||||||
|
# Validate arguments
|
||||||
|
if not args.github_repository:
|
||||||
|
logger.error("--github_repository is required")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Create configuration
|
||||||
|
config = ScraperConfig.from_args(args)
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
config.cache.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config.cache.runtime_xpi_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize scraper
|
||||||
|
scraper = AddonScraper(config)
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
if config.github.token:
|
||||||
|
scraper.github.get_rate_limit()
|
||||||
|
|
||||||
|
# Run scraper
|
||||||
|
logger.info("Starting addon scraper...")
|
||||||
|
addon_infos = scraper.scrape_all()
|
||||||
|
logger.info(f"Scraped {len(addon_infos)} addons")
|
||||||
|
|
||||||
|
# Publish if requested
|
||||||
|
if args.create_release:
|
||||||
|
logger.info("Publishing to GitHub release...")
|
||||||
|
scraper.publisher.publish(config.output_file)
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
logger.info("Updating cache...")
|
||||||
|
cache = FileCache(
|
||||||
|
cache_dir=config.cache.cache_dir,
|
||||||
|
runtime_dir=config.cache.runtime_xpi_dir,
|
||||||
|
lockfile_name=config.cache.lockfile_name,
|
||||||
|
)
|
||||||
|
cache.update_cache()
|
||||||
|
|
||||||
|
# Clean up old caches
|
||||||
|
if config.github.token:
|
||||||
|
scraper.publisher.cleanup_caches(keep_count=1)
|
||||||
|
|
||||||
|
logger.info("Done!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
5
src/zotero_scraper/cache/__init__.py
vendored
Normal file
5
src/zotero_scraper/cache/__init__.py
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Cache module."""
|
||||||
|
|
||||||
|
from .file_cache import FileCache
|
||||||
|
|
||||||
|
__all__ = ["FileCache"]
|
||||||
129
src/zotero_scraper/cache/file_cache.py
vendored
Normal file
129
src/zotero_scraper/cache/file_cache.py
vendored
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
"""File cache management."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("cache")
|
||||||
|
|
||||||
|
|
||||||
|
class FileCache:
|
||||||
|
"""File cache manager for XPI files."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cache_dir: Path,
|
||||||
|
runtime_dir: Path,
|
||||||
|
lockfile_name: str = "caches_lockfile",
|
||||||
|
):
|
||||||
|
"""Initialize file cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_dir: Directory for cached files.
|
||||||
|
runtime_dir: Directory for runtime files.
|
||||||
|
lockfile_name: Name of the hash lockfile.
|
||||||
|
"""
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
self.runtime_dir = runtime_dir
|
||||||
|
self.lockfile_name = lockfile_name
|
||||||
|
|
||||||
|
def folder_hash(self, directory: Path) -> str:
|
||||||
|
"""Calculate SHA256 hash of filenames in a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hex digest of the hash.
|
||||||
|
"""
|
||||||
|
if not directory.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
filenames = sorted(f.name for f in directory.iterdir() if f.is_file())
|
||||||
|
folder_hash = hashlib.sha256()
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
file_hash = hashlib.sha256(filename.encode("utf-8")).hexdigest()
|
||||||
|
folder_hash.update(file_hash.encode("utf-8"))
|
||||||
|
|
||||||
|
return folder_hash.hexdigest()
|
||||||
|
|
||||||
|
def update_cache(self) -> Optional[str]:
|
||||||
|
"""Update cache from runtime directory.
|
||||||
|
|
||||||
|
Moves runtime directory to cache directory and generates hash.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash of the cached files, or None if failed.
|
||||||
|
"""
|
||||||
|
if not self.cache_dir or not self.runtime_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.cache_dir == self.runtime_dir:
|
||||||
|
logger.warning("Cache and runtime directories are the same")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Remove old cache
|
||||||
|
if self.cache_dir.exists():
|
||||||
|
shutil.rmtree(self.cache_dir)
|
||||||
|
|
||||||
|
# Move runtime to cache
|
||||||
|
if self.runtime_dir.exists():
|
||||||
|
shutil.move(str(self.runtime_dir), str(self.cache_dir))
|
||||||
|
|
||||||
|
# Calculate and save hash
|
||||||
|
folder_hash = self.folder_hash(self.cache_dir)
|
||||||
|
lockfile_path = self.cache_dir / self.lockfile_name
|
||||||
|
|
||||||
|
with open(lockfile_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(folder_hash)
|
||||||
|
|
||||||
|
logger.info(f"Cache updated: {folder_hash}")
|
||||||
|
return folder_hash
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Update cache failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def restore_from_cache(self) -> bool:
|
||||||
|
"""Restore runtime directory from cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.cache_dir.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.runtime_dir.exists():
|
||||||
|
shutil.rmtree(self.runtime_dir)
|
||||||
|
|
||||||
|
shutil.copytree(self.cache_dir, self.runtime_dir)
|
||||||
|
logger.info("Restored from cache")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Restore from cache failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_runtime(self) -> None:
|
||||||
|
"""Clear runtime directory."""
|
||||||
|
if self.runtime_dir.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(self.runtime_dir)
|
||||||
|
logger.info("Runtime directory cleared")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Clear runtime failed: {e}")
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear cache directory."""
|
||||||
|
if self.cache_dir.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(self.cache_dir)
|
||||||
|
logger.info("Cache directory cleared")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Clear cache failed: {e}")
|
||||||
11
src/zotero_scraper/clients/__init__.py
Normal file
11
src/zotero_scraper/clients/__init__.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""HTTP clients module."""
|
||||||
|
|
||||||
|
from .base import BaseHTTPClient
|
||||||
|
from .downloader import XPIDownloader
|
||||||
|
from .github import GitHubClient
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseHTTPClient",
|
||||||
|
"GitHubClient",
|
||||||
|
"XPIDownloader",
|
||||||
|
]
|
||||||
142
src/zotero_scraper/clients/base.py
Normal file
142
src/zotero_scraper/clients/base.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Base HTTP client with retry and timeout support."""
|
||||||
|
|
||||||
|
from abc import ABC
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
|
from ..exceptions import HTTPError, RateLimitError
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("clients.base")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHTTPClient(ABC):
|
||||||
|
"""Base HTTP client with retry and timeout support."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
timeout: int = 30,
|
||||||
|
max_retries: int = 3,
|
||||||
|
backoff_factor: float = 0.5,
|
||||||
|
):
|
||||||
|
"""Initialize HTTP client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Request timeout in seconds.
|
||||||
|
max_retries: Maximum number of retries for failed requests.
|
||||||
|
backoff_factor: Backoff factor for retry delays.
|
||||||
|
"""
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.backoff_factor = backoff_factor
|
||||||
|
self.session = self._create_session()
|
||||||
|
|
||||||
|
def _create_session(self) -> requests.Session:
|
||||||
|
"""Create a session with retry strategy."""
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
retry_strategy = Retry(
|
||||||
|
total=self.max_retries,
|
||||||
|
backoff_factor=self.backoff_factor,
|
||||||
|
status_forcelist=[429, 500, 502, 503, 504],
|
||||||
|
allowed_methods=["HEAD", "GET", "POST", "DELETE"],
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||||
|
session.mount("http://", adapter)
|
||||||
|
session.mount("https://", adapter)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Execute HTTP request with error handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, DELETE, etc.).
|
||||||
|
url: Request URL.
|
||||||
|
headers: Optional request headers.
|
||||||
|
**kwargs: Additional arguments passed to requests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response object.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPError: If request fails.
|
||||||
|
RateLimitError: If rate limit is exceeded.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.session.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for rate limiting
|
||||||
|
if response.status_code == 403:
|
||||||
|
if "rate limit" in response.text.lower():
|
||||||
|
logger.error(f"Rate limit exceeded for {url}")
|
||||||
|
raise RateLimitError(f"Rate limit exceeded for {url}")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"Request timeout: {url}")
|
||||||
|
raise HTTPError(f"Request timeout: {url}") from None
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
logger.error(f"Connection error: {url} - {e}")
|
||||||
|
raise HTTPError(f"Connection error: {url}") from e
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
logger.error(f"HTTP error: {url} - {e}")
|
||||||
|
raise HTTPError(f"HTTP error: {url} - {e}") from e
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request failed: {url} - {e}")
|
||||||
|
raise HTTPError(f"Request failed: {url}") from e
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Execute GET request."""
|
||||||
|
return self._request("GET", url, headers=headers, **kwargs)
|
||||||
|
|
||||||
|
def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Execute POST request."""
|
||||||
|
return self._request("POST", url, headers=headers, **kwargs)
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: Optional[dict[str, str]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> requests.Response:
|
||||||
|
"""Execute DELETE request."""
|
||||||
|
return self._request("DELETE", url, headers=headers, **kwargs)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the session."""
|
||||||
|
self.session.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "BaseHTTPClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
self.close()
|
||||||
149
src/zotero_scraper/clients/downloader.py
Normal file
149
src/zotero_scraper/clients/downloader.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""XPI file downloader."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..config.settings import CacheConfig
|
||||||
|
from ..exceptions import DownloadError
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from .base import BaseHTTPClient
|
||||||
|
|
||||||
|
logger = get_logger("clients.downloader")
|
||||||
|
|
||||||
|
|
||||||
|
class XPIDownloader(BaseHTTPClient):
|
||||||
|
"""XPI file downloader with caching support."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cache_config: CacheConfig,
|
||||||
|
timeout: int = 60,
|
||||||
|
max_retries: int = 3,
|
||||||
|
):
|
||||||
|
"""Initialize XPI downloader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_config: Cache configuration.
|
||||||
|
timeout: Download timeout in seconds.
|
||||||
|
max_retries: Maximum download retries.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
timeout=timeout,
|
||||||
|
max_retries=max_retries,
|
||||||
|
)
|
||||||
|
self.cache_dir = cache_config.cache_dir
|
||||||
|
self.runtime_dir = cache_config.runtime_xpi_dir
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
filename: str,
|
||||||
|
force_download: bool = False,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Download XPI file with caching.
|
||||||
|
|
||||||
|
First checks cache, then downloads if not found or force_download=True.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Download URL.
|
||||||
|
filename: Target filename.
|
||||||
|
force_download: Force download even if cached.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to downloaded file or None if failed.
|
||||||
|
"""
|
||||||
|
runtime_path = self.runtime_dir / filename
|
||||||
|
cache_path = self.cache_dir / filename
|
||||||
|
|
||||||
|
# Check if file exists in runtime directory
|
||||||
|
if not force_download and runtime_path.exists():
|
||||||
|
logger.debug(f"Using existing file: {filename}")
|
||||||
|
return runtime_path
|
||||||
|
|
||||||
|
# Check if file exists in cache
|
||||||
|
if not force_download and cache_path.exists():
|
||||||
|
try:
|
||||||
|
shutil.copy2(cache_path, runtime_path)
|
||||||
|
logger.debug(f"Copied from cache: {filename}")
|
||||||
|
return runtime_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to copy from cache: {e}")
|
||||||
|
|
||||||
|
# Download file
|
||||||
|
try:
|
||||||
|
return self._download_file(url, runtime_path, cache_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download failed for {filename}: {e}")
|
||||||
|
|
||||||
|
# Try once more with force
|
||||||
|
if not force_download:
|
||||||
|
logger.info(f"Retrying download for {filename}")
|
||||||
|
try:
|
||||||
|
return self._download_file(url, runtime_path, cache_path)
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f"Retry failed for {filename}: {e2}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download_file(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
runtime_path: Path,
|
||||||
|
cache_path: Path,
|
||||||
|
) -> Path:
|
||||||
|
"""Download file from URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Download URL.
|
||||||
|
runtime_path: Target path in runtime directory.
|
||||||
|
cache_path: Target path in cache directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to downloaded file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DownloadError: If download fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.get(url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Write to runtime directory
|
||||||
|
with open(runtime_path, "wb") as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Copy to cache
|
||||||
|
try:
|
||||||
|
shutil.copy2(runtime_path, cache_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cache file: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Downloaded: {runtime_path.name}")
|
||||||
|
return runtime_path
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up partial file
|
||||||
|
if runtime_path.exists():
|
||||||
|
try:
|
||||||
|
runtime_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise DownloadError(f"Download failed: {url}") from e
|
||||||
|
|
||||||
|
def clear_runtime(self) -> None:
|
||||||
|
"""Clear runtime directory."""
|
||||||
|
if self.runtime_dir.exists():
|
||||||
|
for file in self.runtime_dir.iterdir():
|
||||||
|
if file.is_file():
|
||||||
|
try:
|
||||||
|
file.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete {file}: {e}")
|
||||||
476
src/zotero_scraper/clients/github.py
Normal file
476
src/zotero_scraper/clients/github.py
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
"""GitHub API client."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from ..config.constants import GitHubAPI
|
||||||
|
from ..config.settings import GitHubConfig
|
||||||
|
from ..exceptions import GitHubError
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from .base import BaseHTTPClient
|
||||||
|
|
||||||
|
logger = get_logger("clients.github")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserInfo:
|
||||||
|
"""GitHub user information."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
html_url: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepoInfo:
|
||||||
|
"""GitHub repository information."""
|
||||||
|
|
||||||
|
description: Optional[str]
|
||||||
|
stargazers_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReleaseAsset:
|
||||||
|
"""GitHub release asset information."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
browser_download_url: str
|
||||||
|
content_type: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReleaseInfo:
|
||||||
|
"""GitHub release information."""
|
||||||
|
|
||||||
|
tag_name: str
|
||||||
|
prerelease: bool
|
||||||
|
published_at: str
|
||||||
|
assets: list[ReleaseAsset]
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubClient(BaseHTTPClient):
|
||||||
|
"""GitHub API client with typed responses."""
|
||||||
|
|
||||||
|
def __init__(self, config: GitHubConfig):
|
||||||
|
"""Initialize GitHub client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: GitHub configuration.
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
timeout=config.timeout,
|
||||||
|
max_retries=config.max_retries,
|
||||||
|
backoff_factor=config.retry_delay,
|
||||||
|
)
|
||||||
|
self.config = config
|
||||||
|
self._headers = self._build_headers()
|
||||||
|
|
||||||
|
def _build_headers(self) -> dict[str, str]:
|
||||||
|
"""Build request headers."""
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": self.config.api_version,
|
||||||
|
}
|
||||||
|
if self.config.token:
|
||||||
|
headers["Authorization"] = f"token {self.config.token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def get_user(self, username: str) -> Optional[UserInfo]:
|
||||||
|
"""Get user information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: GitHub username.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserInfo or None if failed.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.user(username)
|
||||||
|
try:
|
||||||
|
response = self.get(url, headers=self._headers)
|
||||||
|
data = response.json()
|
||||||
|
return UserInfo(
|
||||||
|
name=data.get("name") or username,
|
||||||
|
html_url=data.get("html_url", ""),
|
||||||
|
avatar_url=data.get("avatar_url"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get user {username}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_repo(self, owner: str, repo: str) -> Optional[RepoInfo]:
|
||||||
|
"""Get repository information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RepoInfo or None if failed.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.repo(owner, repo)
|
||||||
|
try:
|
||||||
|
response = self.get(url, headers=self._headers)
|
||||||
|
data = response.json()
|
||||||
|
return RepoInfo(
|
||||||
|
description=data.get("description"),
|
||||||
|
stargazers_count=data.get("stargazers_count", 0),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get repo {owner}/{repo}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_releases(self, owner: str, repo: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all releases for a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of release data dictionaries.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.releases(owner, repo)
|
||||||
|
try:
|
||||||
|
response = self.get(url, headers=self._headers)
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get releases for {owner}/{repo}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_release(
|
||||||
|
self, owner: str, repo: str, tag: str
|
||||||
|
) -> Optional[ReleaseInfo]:
|
||||||
|
"""Get release by tag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
tag: Tag name ("latest", "pre", or specific tag).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ReleaseInfo or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if tag == "latest":
|
||||||
|
url = GitHubAPI.release_latest(owner, repo)
|
||||||
|
response = self.get(url, headers=self._headers)
|
||||||
|
data = response.json()
|
||||||
|
elif tag == "pre":
|
||||||
|
# Find first prerelease
|
||||||
|
releases = self.get_releases(owner, repo)
|
||||||
|
prereleases = [r for r in releases if r.get("prerelease")]
|
||||||
|
if not prereleases:
|
||||||
|
return None
|
||||||
|
data = prereleases[0]
|
||||||
|
else:
|
||||||
|
url = GitHubAPI.release_by_tag(owner, repo, tag)
|
||||||
|
response = self.get(url, headers=self._headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assets = [
|
||||||
|
ReleaseAsset(
|
||||||
|
id=a["id"],
|
||||||
|
name=a["name"],
|
||||||
|
browser_download_url=a["browser_download_url"],
|
||||||
|
content_type=a.get("content_type", ""),
|
||||||
|
updated_at=a.get("updated_at", ""),
|
||||||
|
)
|
||||||
|
for a in data.get("assets", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return ReleaseInfo(
|
||||||
|
tag_name=data.get("tag_name", tag),
|
||||||
|
prerelease=data.get("prerelease", False),
|
||||||
|
published_at=data.get("published_at", ""),
|
||||||
|
assets=assets,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get release {owner}/{repo}@{tag}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_release(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
tag_name: Optional[str] = None,
|
||||||
|
target_commitish: str = "publish",
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Create a new release.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
tag_name: Tag name (defaults to current timestamp).
|
||||||
|
target_commitish: Target branch/commit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Release ID or None if failed.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.releases(owner, repo)
|
||||||
|
cur_time = int(time.time())
|
||||||
|
tag = tag_name or str(cur_time)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": target_commitish,
|
||||||
|
"name": tag,
|
||||||
|
"body": (
|
||||||
|
f"\npublish addon_infos.json"
|
||||||
|
),
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
"generate_release_notes": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.post(url, headers=self._headers, json=payload)
|
||||||
|
if response.status_code == 201:
|
||||||
|
release_id = response.json().get("id")
|
||||||
|
logger.info(f"Created release {tag} with ID {release_id}")
|
||||||
|
return release_id
|
||||||
|
logger.error(f"Create release failed: {response.status_code}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Create release failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upload_release_asset(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
release_id: int,
|
||||||
|
filename: str,
|
||||||
|
filepath: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Upload asset to release.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
release_id: Release ID.
|
||||||
|
filename: Asset filename.
|
||||||
|
filepath: Local file path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.upload_asset(owner, repo, release_id, filename)
|
||||||
|
headers = {**self._headers, "Content-Type": "application/octet-stream"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
response = self.post(url, headers=headers, data=f)
|
||||||
|
if response.status_code == 201:
|
||||||
|
logger.info(f"Uploaded asset {filename}")
|
||||||
|
return True
|
||||||
|
logger.error(f"Upload asset failed: {response.status_code}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Upload asset failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_issue(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
check_duplicate_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Create an issue with optional duplicate check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
title: Issue title.
|
||||||
|
body: Issue body.
|
||||||
|
check_duplicate_id: Optional ID to check for duplicates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if created (or skipped as duplicate).
|
||||||
|
"""
|
||||||
|
if check_duplicate_id:
|
||||||
|
body = f"{body}\n----{check_duplicate_id}"
|
||||||
|
if self._issue_exists(owner, repo, check_duplicate_id):
|
||||||
|
logger.debug(f"Issue already exists: {check_duplicate_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
url = GitHubAPI.issues(owner, repo)
|
||||||
|
try:
|
||||||
|
response = self.post(
|
||||||
|
url,
|
||||||
|
headers=self._headers,
|
||||||
|
json={"title": title, "body": body},
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
issue_url = response.json().get("html_url")
|
||||||
|
logger.info(f"Issue created: {issue_url}")
|
||||||
|
return True
|
||||||
|
logger.error(f"Create issue failed: {response.status_code}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Create issue failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _issue_exists(self, owner: str, repo: str, check_id: str) -> bool:
|
||||||
|
"""Check if an issue with given ID already exists."""
|
||||||
|
url = GitHubAPI.issues(owner, repo)
|
||||||
|
try:
|
||||||
|
since = (datetime.now(UTC) - timedelta(days=10)).isoformat() + "Z"
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
headers=self._headers,
|
||||||
|
params={"state": "open", "since": since, "per_page": 100},
|
||||||
|
)
|
||||||
|
issues = response.json()
|
||||||
|
return any(
|
||||||
|
issue.get("body", "").endswith(f"----{check_id}")
|
||||||
|
for issue in issues
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_old_releases(
|
||||||
|
self, owner: str, repo: str, keep_count: int = 2
|
||||||
|
) -> None:
|
||||||
|
"""Delete old releases, keeping the most recent ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
keep_count: Number of releases to keep.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.releases(owner, repo)
|
||||||
|
try:
|
||||||
|
response = self.get(
|
||||||
|
url, headers=self._headers, params={"per_page": 100}
|
||||||
|
)
|
||||||
|
releases = response.json()
|
||||||
|
if len(releases) <= keep_count:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sort by tag_name descending
|
||||||
|
releases.sort(key=lambda r: r.get("tag_name", ""), reverse=True)
|
||||||
|
|
||||||
|
for release in releases[keep_count:]:
|
||||||
|
release_id = release.get("id")
|
||||||
|
tag = release.get("tag_name")
|
||||||
|
if release_id:
|
||||||
|
delete_url = f"{url}/{release_id}"
|
||||||
|
try:
|
||||||
|
resp = self.delete(delete_url, headers=self._headers)
|
||||||
|
if resp.status_code == 204:
|
||||||
|
logger.info(f"Deleted release: {tag}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete release {tag}: {resp.text}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete release {tag}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Delete old releases failed: {e}")
|
||||||
|
|
||||||
|
def delete_old_tags(
|
||||||
|
self, owner: str, repo: str, keep_count: int = 2
|
||||||
|
) -> None:
|
||||||
|
"""Delete old timestamp-based tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
keep_count: Number of tags to keep.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.tags(owner, repo)
|
||||||
|
try:
|
||||||
|
response = self.get(url, headers=self._headers)
|
||||||
|
tags = response.json()
|
||||||
|
if len(tags) <= keep_count:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sort by ref descending
|
||||||
|
tags.sort(key=lambda t: t.get("ref", ""), reverse=True)
|
||||||
|
|
||||||
|
for tag in tags[keep_count:]:
|
||||||
|
ref = tag.get("ref", "").replace("refs/tags/", "")
|
||||||
|
# Only delete timestamp tags (numeric, >= 10 digits)
|
||||||
|
if len(ref) < 10:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
int(ref) # Verify it's a timestamp
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
delete_url = f"{url}/{ref}"
|
||||||
|
try:
|
||||||
|
resp = self.delete(delete_url, headers=self._headers)
|
||||||
|
if resp.status_code == 204:
|
||||||
|
logger.info(f"Deleted tag: {ref}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to delete tag {ref}: {resp.text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete tag {ref}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Delete old tags failed: {e}")
|
||||||
|
|
||||||
|
def delete_old_caches(
|
||||||
|
self, owner: str, repo: str, keep_count: int = 2
|
||||||
|
) -> None:
|
||||||
|
"""Delete old action caches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
owner: Repository owner.
|
||||||
|
repo: Repository name.
|
||||||
|
keep_count: Number of caches to keep.
|
||||||
|
"""
|
||||||
|
url = GitHubAPI.caches(owner, repo)
|
||||||
|
try:
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
headers=self._headers,
|
||||||
|
params={
|
||||||
|
"per_page": 100,
|
||||||
|
"sort": "last_accessed_at",
|
||||||
|
"direction": "desc",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
if data.get("total_count", 0) <= keep_count:
|
||||||
|
return
|
||||||
|
|
||||||
|
for cache in data.get("actions_caches", [])[keep_count:]:
|
||||||
|
cache_id = cache.get("id")
|
||||||
|
cache_key = cache.get("key")
|
||||||
|
if cache_id:
|
||||||
|
delete_url = f"{url}/{cache_id}"
|
||||||
|
try:
|
||||||
|
resp = self.delete(delete_url, headers=self._headers)
|
||||||
|
if resp.status_code == 204:
|
||||||
|
logger.info(f"Deleted cache: {cache_key}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete cache {cache_key}: {resp.text}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete cache {cache_key}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Delete old caches failed: {e}")
|
||||||
|
|
||||||
|
def get_rate_limit(self) -> Optional[dict[str, Any]]:
|
||||||
|
"""Get current rate limit status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rate limit info or None if failed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.get(GitHubAPI.rate_limit(), headers=self._headers)
|
||||||
|
rate = response.json().get("rate")
|
||||||
|
logger.info(f"Rate limit: {rate}")
|
||||||
|
return rate
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get rate limit failed: {e}")
|
||||||
|
return None
|
||||||
13
src/zotero_scraper/config/__init__.py
Normal file
13
src/zotero_scraper/config/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Configuration module."""
|
||||||
|
|
||||||
|
from .constants import GitHubAPI, XPIProxy, ZoteroApp
|
||||||
|
from .settings import CacheConfig, GitHubConfig, ScraperConfig
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GitHubAPI",
|
||||||
|
"XPIProxy",
|
||||||
|
"ZoteroApp",
|
||||||
|
"CacheConfig",
|
||||||
|
"GitHubConfig",
|
||||||
|
"ScraperConfig",
|
||||||
|
]
|
||||||
113
src/zotero_scraper/config/constants.py
Normal file
113
src/zotero_scraper/config/constants.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""Constants and API endpoints for the Zotero addons scraper."""
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubAPI:
|
||||||
|
"""GitHub API endpoints."""
|
||||||
|
|
||||||
|
BASE = "https://api.github.com"
|
||||||
|
UPLOADS_BASE = "https://uploads.github.com"
|
||||||
|
DEFAULT_API_VERSION = "2022-11-28"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def user(username: str) -> str:
|
||||||
|
"""Get user info endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/users/{username}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def repo(owner: str, repo: str) -> str:
|
||||||
|
"""Get repository info endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/repos/{owner}/{repo}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def releases(owner: str, repo: str) -> str:
|
||||||
|
"""Get releases list endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/repos/{owner}/{repo}/releases"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def release_latest(owner: str, repo: str) -> str:
|
||||||
|
"""Get latest release endpoint."""
|
||||||
|
return f"{GitHubAPI.releases(owner, repo)}/latest"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def release_by_tag(owner: str, repo: str, tag: str) -> str:
|
||||||
|
"""Get release by tag endpoint."""
|
||||||
|
return f"{GitHubAPI.releases(owner, repo)}/tags/{tag}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def issues(owner: str, repo: str) -> str:
|
||||||
|
"""Get issues endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/repos/{owner}/{repo}/issues"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def caches(owner: str, repo: str) -> str:
|
||||||
|
"""Get actions caches endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/repos/{owner}/{repo}/actions/caches"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tags(owner: str, repo: str) -> str:
|
||||||
|
"""Get git tags endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/repos/{owner}/{repo}/git/refs/tags"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def upload_asset(owner: str, repo: str, release_id: int, name: str) -> str:
|
||||||
|
"""Get upload asset endpoint."""
|
||||||
|
return (
|
||||||
|
f"{GitHubAPI.UPLOADS_BASE}/repos/{owner}/{repo}"
|
||||||
|
f"/releases/{release_id}/assets?name={name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rate_limit() -> str:
|
||||||
|
"""Get rate limit endpoint."""
|
||||||
|
return f"{GitHubAPI.BASE}/rate_limit"
|
||||||
|
|
||||||
|
|
||||||
|
class XPIProxy:
|
||||||
|
"""XPI download proxy URLs."""
|
||||||
|
|
||||||
|
GHPROXY_BASE = "https://gh-proxy.com/"
|
||||||
|
KKGITHUB_DOMAIN = "kkgithub.com"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ghproxy_url(github_url: str) -> str:
|
||||||
|
"""Get ghProxy URL for a GitHub download URL."""
|
||||||
|
return f"{XPIProxy.GHPROXY_BASE}?q={quote(github_url, safe='')}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def kkgithub_url(github_url: str) -> str:
|
||||||
|
"""Get kkgithub URL for a GitHub download URL."""
|
||||||
|
return github_url.replace("github.com", XPIProxy.KKGITHUB_DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
class ZoteroApp:
|
||||||
|
"""Zotero application constants."""
|
||||||
|
|
||||||
|
APP_ID = "zotero@chnm.gmu.edu"
|
||||||
|
SUPPORTED_VERSIONS = ("6", "7")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_version(target_version: str) -> str:
|
||||||
|
"""Get Zotero version check string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_version: Target Zotero version ("6" or "7").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Version check string (e.g., "6.*" or "7.*").
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If target_version is not supported.
|
||||||
|
"""
|
||||||
|
if target_version not in ZoteroApp.SUPPORTED_VERSIONS:
|
||||||
|
raise ValueError(f"Unsupported Zotero version: {target_version}")
|
||||||
|
return f"{target_version}.*"
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTypes:
|
||||||
|
"""MIME content types."""
|
||||||
|
|
||||||
|
XPI = "application/x-xpinstall"
|
||||||
|
ZIP = "application/x-zip-compressed"
|
||||||
|
OCTET_STREAM = "application/octet-stream"
|
||||||
77
src/zotero_scraper/config/settings.py
Normal file
77
src/zotero_scraper/config/settings.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Configuration settings for the Zotero addons scraper."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitHubConfig:
|
||||||
|
"""GitHub API configuration."""
|
||||||
|
|
||||||
|
token: Optional[str] = None
|
||||||
|
repository: str = ""
|
||||||
|
api_version: str = "2022-11-28"
|
||||||
|
timeout: int = 30
|
||||||
|
max_retries: int = 3
|
||||||
|
retry_delay: float = 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def owner(self) -> Optional[str]:
|
||||||
|
"""Get repository owner."""
|
||||||
|
parts = self.repository.split("/")
|
||||||
|
return parts[0] if len(parts) == 2 else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo_name(self) -> Optional[str]:
|
||||||
|
"""Get repository name."""
|
||||||
|
parts = self.repository.split("/")
|
||||||
|
return parts[1] if len(parts) == 2 else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CacheConfig:
|
||||||
|
"""Cache configuration."""
|
||||||
|
|
||||||
|
cache_dir: Path = field(default_factory=lambda: Path("caches"))
|
||||||
|
runtime_xpi_dir: Path = field(default_factory=lambda: Path("xpis"))
|
||||||
|
lockfile_name: str = "caches_lockfile"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScraperConfig:
|
||||||
|
"""Main scraper configuration."""
|
||||||
|
|
||||||
|
input_dir: Path = field(default_factory=lambda: Path("addons"))
|
||||||
|
output_file: Path = field(default_factory=lambda: Path("addon_infos.json"))
|
||||||
|
github: GitHubConfig = field(default_factory=GitHubConfig)
|
||||||
|
cache: CacheConfig = field(default_factory=CacheConfig)
|
||||||
|
previous_info_urls: list[str] = field(default_factory=list)
|
||||||
|
create_release: bool = True
|
||||||
|
max_workers: int = 10
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_args(cls, args: Any) -> "ScraperConfig":
|
||||||
|
"""Create configuration from argparse namespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Parsed command line arguments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScraperConfig instance.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
input_dir=Path(args.input),
|
||||||
|
output_file=Path(args.output),
|
||||||
|
github=GitHubConfig(
|
||||||
|
token=args.github_token,
|
||||||
|
repository=args.github_repository or "",
|
||||||
|
),
|
||||||
|
cache=CacheConfig(
|
||||||
|
cache_dir=Path(args.cache_directory),
|
||||||
|
runtime_xpi_dir=Path(args.runtime_xpi_directory),
|
||||||
|
lockfile_name=args.cache_lockfile,
|
||||||
|
),
|
||||||
|
previous_info_urls=args.previous_info_urls or [],
|
||||||
|
create_release=args.create_release,
|
||||||
|
)
|
||||||
49
src/zotero_scraper/exceptions.py
Normal file
49
src/zotero_scraper/exceptions.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""Custom exceptions for the Zotero addons scraper."""
|
||||||
|
|
||||||
|
|
||||||
|
class ScraperError(Exception):
|
||||||
|
"""Base exception for all scraper errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(ScraperError):
|
||||||
|
"""Configuration error."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPError(ScraperError):
|
||||||
|
"""HTTP request error."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(HTTPError):
|
||||||
|
"""API rate limit exceeded error."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GitHubError(ScraperError):
|
||||||
|
"""GitHub API error."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class XPIParseError(ScraperError):
|
||||||
|
"""XPI file parsing error."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadError(ScraperError):
|
||||||
|
"""File download error."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CacheError(ScraperError):
|
||||||
|
"""Cache operation error."""
|
||||||
|
|
||||||
|
pass
|
||||||
12
src/zotero_scraper/models/__init__.py
Normal file
12
src/zotero_scraper/models/__init__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""Data models module."""
|
||||||
|
|
||||||
|
from .addon import AddonInfo, AddonRelease, Author, XpiDownloadUrls
|
||||||
|
from .xpi import XpiDetail
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AddonInfo",
|
||||||
|
"AddonRelease",
|
||||||
|
"Author",
|
||||||
|
"XpiDownloadUrls",
|
||||||
|
"XpiDetail",
|
||||||
|
]
|
||||||
218
src/zotero_scraper/models/addon.py
Normal file
218
src/zotero_scraper/models/addon.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""Addon data models."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Author:
|
||||||
|
"""Addon author information."""
|
||||||
|
|
||||||
|
name: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
avatar: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
"""Convert to dictionary, excluding None values."""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
if self.name:
|
||||||
|
result["name"] = self.name
|
||||||
|
if self.url:
|
||||||
|
result["url"] = self.url
|
||||||
|
if self.avatar:
|
||||||
|
result["avatar"] = self.avatar
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Optional[dict[str, Any]]) -> "Author":
|
||||||
|
"""Create Author from dictionary."""
|
||||||
|
if not data:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
name=data.get("name"),
|
||||||
|
url=data.get("url"),
|
||||||
|
avatar=data.get("avatar"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XpiDownloadUrls:
|
||||||
|
"""XPI download URLs from multiple sources."""
|
||||||
|
|
||||||
|
github: str
|
||||||
|
ghProxy: Optional[str] = None
|
||||||
|
kgithub: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
"""Convert to dictionary, excluding None values."""
|
||||||
|
result: dict[str, str] = {"github": self.github}
|
||||||
|
if self.ghProxy:
|
||||||
|
result["ghProxy"] = self.ghProxy
|
||||||
|
if self.kgithub:
|
||||||
|
result["kgithub"] = self.kgithub
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Optional[dict[str, str]]) -> Optional["XpiDownloadUrls"]:
|
||||||
|
"""Create XpiDownloadUrls from dictionary."""
|
||||||
|
if not data or "github" not in data:
|
||||||
|
return None
|
||||||
|
return cls(
|
||||||
|
github=data["github"],
|
||||||
|
ghProxy=data.get("ghProxy"),
|
||||||
|
kgithub=data.get("kgithub"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddonRelease:
|
||||||
|
"""Addon release version information."""
|
||||||
|
|
||||||
|
targetZoteroVersion: str
|
||||||
|
tagName: str
|
||||||
|
xpiDownloadUrl: Optional[XpiDownloadUrls] = None
|
||||||
|
releaseDate: Optional[str] = None
|
||||||
|
id: Optional[str] = None
|
||||||
|
xpiVersion: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
minZoteroVersion: Optional[str] = None
|
||||||
|
maxZoteroVersion: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def zotero_check_version(self) -> str:
|
||||||
|
"""Get Zotero version check string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Version check string (e.g., "6.*" or "7.*").
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If targetZoteroVersion is not valid.
|
||||||
|
"""
|
||||||
|
if self.targetZoteroVersion == "6":
|
||||||
|
return "6.*"
|
||||||
|
elif self.targetZoteroVersion == "7":
|
||||||
|
return "7.*"
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid targetZoteroVersion: {self.targetZoteroVersion}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary, excluding None values."""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
if self.targetZoteroVersion:
|
||||||
|
result["targetZoteroVersion"] = self.targetZoteroVersion
|
||||||
|
if self.tagName:
|
||||||
|
result["tagName"] = self.tagName
|
||||||
|
if self.xpiDownloadUrl:
|
||||||
|
result["xpiDownloadUrl"] = self.xpiDownloadUrl.to_dict()
|
||||||
|
if self.releaseDate:
|
||||||
|
result["releaseDate"] = self.releaseDate
|
||||||
|
if self.id:
|
||||||
|
result["id"] = self.id
|
||||||
|
if self.xpiVersion:
|
||||||
|
result["xpiVersion"] = self.xpiVersion
|
||||||
|
if self.name:
|
||||||
|
result["name"] = self.name
|
||||||
|
if self.description:
|
||||||
|
result["description"] = self.description
|
||||||
|
if self.minZoteroVersion:
|
||||||
|
result["minZoteroVersion"] = self.minZoteroVersion
|
||||||
|
if self.maxZoteroVersion:
|
||||||
|
result["maxZoteroVersion"] = self.maxZoteroVersion
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "AddonRelease":
|
||||||
|
"""Create AddonRelease from dictionary."""
|
||||||
|
xpi_url_data = data.get("xpiDownloadUrl")
|
||||||
|
xpi_download_url = (
|
||||||
|
XpiDownloadUrls.from_dict(xpi_url_data)
|
||||||
|
if isinstance(xpi_url_data, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
targetZoteroVersion=data.get("targetZoteroVersion", ""),
|
||||||
|
tagName=data.get("tagName", ""),
|
||||||
|
xpiDownloadUrl=xpi_download_url,
|
||||||
|
releaseDate=data.get("releaseDate"),
|
||||||
|
id=data.get("id"),
|
||||||
|
xpiVersion=data.get("xpiVersion"),
|
||||||
|
name=data.get("name"),
|
||||||
|
description=data.get("description"),
|
||||||
|
minZoteroVersion=data.get("minZoteroVersion"),
|
||||||
|
maxZoteroVersion=data.get("maxZoteroVersion"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddonInfo:
|
||||||
|
"""Complete addon information."""
|
||||||
|
|
||||||
|
repo: str
|
||||||
|
releases: list[AddonRelease] = field(default_factory=list)
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
stars: Optional[int] = None
|
||||||
|
author: Author = field(default_factory=Author)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def owner(self) -> Optional[str]:
|
||||||
|
"""Get repository owner."""
|
||||||
|
parts = self.repo.split("/")
|
||||||
|
return parts[0] if len(parts) == 2 else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repository(self) -> Optional[str]:
|
||||||
|
"""Get repository name."""
|
||||||
|
parts = self.repo.split("/")
|
||||||
|
return parts[1] if len(parts) == 2 else None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary for JSON serialization.
|
||||||
|
|
||||||
|
Maintains backward compatibility with existing output format.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
if self.repo:
|
||||||
|
result["repo"] = self.repo
|
||||||
|
if self.releases:
|
||||||
|
result["releases"] = [release.to_dict() for release in self.releases]
|
||||||
|
if self.name:
|
||||||
|
result["name"] = self.name
|
||||||
|
if self.description:
|
||||||
|
result["description"] = self.description
|
||||||
|
if self.stars:
|
||||||
|
result["stars"] = self.stars
|
||||||
|
# Backward compatibility: keep "star" field for older versions
|
||||||
|
result["star"] = self.stars
|
||||||
|
if self.author:
|
||||||
|
author_dict = self.author.to_dict()
|
||||||
|
if author_dict:
|
||||||
|
result["author"] = author_dict
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "AddonInfo":
|
||||||
|
"""Create AddonInfo from dictionary."""
|
||||||
|
releases_data = data.get("releases", [])
|
||||||
|
releases = [
|
||||||
|
AddonRelease.from_dict(r) if isinstance(r, dict) else r
|
||||||
|
for r in releases_data
|
||||||
|
]
|
||||||
|
|
||||||
|
author_data = data.get("author")
|
||||||
|
author = (
|
||||||
|
Author.from_dict(author_data)
|
||||||
|
if isinstance(author_data, dict)
|
||||||
|
else Author()
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
repo=data.get("repo", ""),
|
||||||
|
releases=releases,
|
||||||
|
name=data.get("name"),
|
||||||
|
description=data.get("description"),
|
||||||
|
stars=data.get("stars"),
|
||||||
|
author=author,
|
||||||
|
)
|
||||||
103
src/zotero_scraper/models/xpi.py
Normal file
103
src/zotero_scraper/models/xpi.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""XPI detail model."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from ..utils.version import compare_versions
|
||||||
|
|
||||||
|
logger = get_logger("models.xpi")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class XpiDetail:
|
||||||
|
"""XPI addon details extracted from manifest files."""
|
||||||
|
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
version: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
update_url: Optional[str] = None
|
||||||
|
min_version: str = "*"
|
||||||
|
max_version: str = "*"
|
||||||
|
|
||||||
|
def append_info(self, details: dict) -> None:
|
||||||
|
"""Append information from parsed manifest details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
details: Dictionary containing parsed manifest data.
|
||||||
|
"""
|
||||||
|
# Check ID consistency
|
||||||
|
if addon_id := details.get("id"):
|
||||||
|
if self.id and self.id != addon_id:
|
||||||
|
logger.warning(f"XPI ID mismatch: {self.id} != {addon_id}")
|
||||||
|
return
|
||||||
|
self.id = addon_id
|
||||||
|
|
||||||
|
# Update basic info
|
||||||
|
if name := details.get("name"):
|
||||||
|
self.name = name
|
||||||
|
if version := details.get("version"):
|
||||||
|
self.version = version
|
||||||
|
if description := details.get("description"):
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
# Update URL (support both keys)
|
||||||
|
if update_url := details.get("updateURL") or details.get("update_url"):
|
||||||
|
self.update_url = update_url
|
||||||
|
|
||||||
|
# Update version range (take the widest range)
|
||||||
|
min_ver = details.get("min_version")
|
||||||
|
max_ver = details.get("max_version")
|
||||||
|
if min_ver and max_ver:
|
||||||
|
self._update_version_range(min_ver, max_ver)
|
||||||
|
|
||||||
|
def _update_version_range(self, min_ver: str, max_ver: str) -> None:
|
||||||
|
"""Update version range to include the new range.
|
||||||
|
|
||||||
|
Takes the widest range between existing and new values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_ver: New minimum version.
|
||||||
|
max_ver: New maximum version.
|
||||||
|
"""
|
||||||
|
# For min_version, take the smaller one
|
||||||
|
min_normalized = min_ver.replace("*", "0")
|
||||||
|
existing_min_normalized = self.min_version.replace("*", "999")
|
||||||
|
if compare_versions(min_normalized, existing_min_normalized) <= 0:
|
||||||
|
self.min_version = min_ver
|
||||||
|
|
||||||
|
# For max_version, take the larger one
|
||||||
|
max_normalized = max_ver.replace("*", "999")
|
||||||
|
existing_max_normalized = self.max_version.replace("*", "0")
|
||||||
|
if compare_versions(max_normalized, existing_max_normalized) >= 0:
|
||||||
|
self.max_version = max_ver
|
||||||
|
|
||||||
|
def check_compatible(self, version: str) -> bool:
|
||||||
|
"""Check if addon is compatible with a Zotero version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version: Zotero version string (e.g., "7.*" or "6.*").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if compatible, False otherwise.
|
||||||
|
"""
|
||||||
|
# Normalize versions for comparison
|
||||||
|
min_normalized = self.min_version.replace("*", "0")
|
||||||
|
max_normalized = self.max_version.replace("*", "999")
|
||||||
|
version_normalized = version.replace("*", "999")
|
||||||
|
version_base = version.replace("*", "0")
|
||||||
|
|
||||||
|
# Check if version is within range
|
||||||
|
return (
|
||||||
|
compare_versions(min_normalized, version_normalized) <= 0
|
||||||
|
and compare_versions(max_normalized, version_base) >= 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""Check if XPI detail has minimum required information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if has at least ID, False otherwise.
|
||||||
|
"""
|
||||||
|
return bool(self.id)
|
||||||
10
src/zotero_scraper/parsers/__init__.py
Normal file
10
src/zotero_scraper/parsers/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""XPI parsers module."""
|
||||||
|
|
||||||
|
from .manifest import JsonManifestParser, RdfManifestParser
|
||||||
|
from .xpi_parser import XPIParser
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"JsonManifestParser",
|
||||||
|
"RdfManifestParser",
|
||||||
|
"XPIParser",
|
||||||
|
]
|
||||||
349
src/zotero_scraper/parsers/manifest.py
Normal file
349
src/zotero_scraper/parsers/manifest.py
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
"""Manifest file parsers for XPI addons."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
from xml.dom import minidom
|
||||||
|
|
||||||
|
import commentjson as json
|
||||||
|
|
||||||
|
from ..config.constants import ZoteroApp
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from ..utils.version import compare_versions
|
||||||
|
|
||||||
|
logger = get_logger("parsers.manifest")
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestParser(ABC):
|
||||||
|
"""Abstract base class for manifest parsers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse(self, addon_path: Union[str, Path]) -> Optional[dict[str, Any]]:
|
||||||
|
"""Parse manifest from addon path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addon_path: Path to XPI file or directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with manifest data or None if not found.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract_details(
|
||||||
|
self, addon_path: Union[str, Path], manifest: Any
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Extract addon details from manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addon_path: Path to XPI file or directory.
|
||||||
|
manifest: Parsed manifest data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with addon details.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class JsonManifestParser(ManifestParser):
|
||||||
|
"""Parser for manifest.json (WebExtensions format)."""
|
||||||
|
|
||||||
|
def parse(self, addon_path: Union[str, Path]) -> Optional[dict[str, Any]]:
|
||||||
|
"""Parse manifest.json from XPI file."""
|
||||||
|
addon_path = Path(addon_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if zipfile.is_zipfile(addon_path):
|
||||||
|
with zipfile.ZipFile(addon_path, "r") as zf:
|
||||||
|
if "manifest.json" in zf.namelist():
|
||||||
|
content = zf.read("manifest.json").decode("utf-8")
|
||||||
|
return json.loads(content)
|
||||||
|
elif addon_path.is_dir():
|
||||||
|
manifest_path = addon_path / "manifest.json"
|
||||||
|
if manifest_path.exists():
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.loads(f.read())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse manifest.json from {addon_path}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_details(
|
||||||
|
self, addon_path: Union[str, Path], manifest: dict[str, Any]
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Extract addon details from manifest.json."""
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
"name": manifest.get("name"),
|
||||||
|
"version": manifest.get("version"),
|
||||||
|
"description": manifest.get("description"),
|
||||||
|
"id": None,
|
||||||
|
"min_version": None,
|
||||||
|
"max_version": None,
|
||||||
|
"update_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to get addon info from applications/browser_specific_settings
|
||||||
|
for location in ("applications", "browser_specific_settings"):
|
||||||
|
if details["id"]:
|
||||||
|
break
|
||||||
|
for app in ("zotero", "gecko"):
|
||||||
|
try:
|
||||||
|
app_settings = manifest[location][app]
|
||||||
|
details["id"] = app_settings.get("id")
|
||||||
|
details["min_version"] = app_settings.get("strict_min_version")
|
||||||
|
details["max_version"] = app_settings.get("strict_max_version")
|
||||||
|
details["update_url"] = app_settings.get("update_url")
|
||||||
|
break
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle __MSG_{}__ placeholders
|
||||||
|
self._resolve_message_placeholders(addon_path, manifest, details)
|
||||||
|
|
||||||
|
return details
|
||||||
|
|
||||||
|
def _resolve_message_placeholders(
|
||||||
|
self,
|
||||||
|
addon_path: Union[str, Path],
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
details: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Resolve __MSG_xxx__ placeholders from locale files."""
|
||||||
|
locale_data: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
for key, value in details.items():
|
||||||
|
if not isinstance(value, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
match = re.search(r"__MSG_(.*?)__", value)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
placeholder = match.group(1)
|
||||||
|
|
||||||
|
# Load locale data on first need
|
||||||
|
if locale_data is None:
|
||||||
|
locale_data = self._load_locale(addon_path, manifest)
|
||||||
|
if locale_data is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Resolve placeholder
|
||||||
|
msg = locale_data.get(placeholder, {}).get("message")
|
||||||
|
if msg:
|
||||||
|
details[key] = msg
|
||||||
|
|
||||||
|
def _load_locale(
|
||||||
|
self, addon_path: Union[str, Path], manifest: dict[str, Any]
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Load locale messages file."""
|
||||||
|
default_locale = manifest.get("default_locale")
|
||||||
|
if not default_locale:
|
||||||
|
return None
|
||||||
|
|
||||||
|
locale_filename = f"_locales/{default_locale}/messages.json"
|
||||||
|
addon_path = Path(addon_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if zipfile.is_zipfile(addon_path):
|
||||||
|
with zipfile.ZipFile(addon_path, "r") as zf:
|
||||||
|
if locale_filename in zf.namelist():
|
||||||
|
content = zf.read(locale_filename).decode("utf-8")
|
||||||
|
return json.loads(content)
|
||||||
|
elif addon_path.is_dir():
|
||||||
|
locale_path = addon_path / locale_filename
|
||||||
|
if locale_path.exists():
|
||||||
|
with open(locale_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.loads(f.read())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load locale file: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RdfManifestParser(ManifestParser):
|
||||||
|
"""Parser for install.rdf (legacy XUL format)."""
|
||||||
|
|
||||||
|
def parse(self, addon_path: Union[str, Path]) -> Optional[bytes]:
|
||||||
|
"""Parse install.rdf from XPI file."""
|
||||||
|
addon_path = Path(addon_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if zipfile.is_zipfile(addon_path):
|
||||||
|
with zipfile.ZipFile(addon_path, "r") as zf:
|
||||||
|
if "install.rdf" in zf.namelist():
|
||||||
|
return zf.read("install.rdf")
|
||||||
|
elif addon_path.is_dir():
|
||||||
|
rdf_path = addon_path / "install.rdf"
|
||||||
|
if rdf_path.exists():
|
||||||
|
with open(rdf_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse install.rdf from {addon_path}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_details(
|
||||||
|
self, addon_path: Union[str, Path], manifest: bytes
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Extract addon details from install.rdf."""
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
"id": None,
|
||||||
|
"name": None,
|
||||||
|
"version": None,
|
||||||
|
"description": None,
|
||||||
|
"min_version": None,
|
||||||
|
"max_version": None,
|
||||||
|
"update_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = minidom.parseString(manifest)
|
||||||
|
|
||||||
|
# Get namespace prefixes
|
||||||
|
em = self._get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")
|
||||||
|
rdf = self._get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
|
||||||
|
|
||||||
|
# Find the main Description element
|
||||||
|
description = self._find_main_description(doc, rdf, em)
|
||||||
|
if description is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract basic info
|
||||||
|
self._extract_info(description, details, em)
|
||||||
|
|
||||||
|
# Validate ID
|
||||||
|
addon_id = details.get("id")
|
||||||
|
if not addon_id or (addon_id.startswith("__") and addon_id.endswith("__")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract version info from targetApplication
|
||||||
|
self._extract_target_application_info(description, details, em)
|
||||||
|
|
||||||
|
# Handle updateURL
|
||||||
|
if update_url := details.get("updateURL"):
|
||||||
|
details["update_url"] = update_url
|
||||||
|
|
||||||
|
return details
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract details from install.rdf: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_namespace_id(self, doc: minidom.Document, url: str) -> str:
|
||||||
|
"""Get namespace prefix for a given URL."""
|
||||||
|
attributes = doc.documentElement.attributes
|
||||||
|
for i in range(attributes.length):
|
||||||
|
attr = attributes.item(i)
|
||||||
|
if attr.value == url and ":" in attr.name:
|
||||||
|
return attr.name.split(":")[1] + ":"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _find_main_description(
|
||||||
|
self, doc: minidom.Document, rdf: str, em: str
|
||||||
|
) -> Optional[minidom.Element]:
|
||||||
|
"""Find the main Description element."""
|
||||||
|
descriptions = doc.getElementsByTagName(rdf + "Description")
|
||||||
|
if not descriptions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try to find description with targetApplication
|
||||||
|
for desc in descriptions:
|
||||||
|
if desc.getElementsByTagName(em + "targetApplication"):
|
||||||
|
return desc
|
||||||
|
|
||||||
|
return descriptions.item(0)
|
||||||
|
|
||||||
|
def _extract_info(
|
||||||
|
self,
|
||||||
|
node: minidom.Element,
|
||||||
|
result: dict[str, Any],
|
||||||
|
em: str,
|
||||||
|
) -> None:
|
||||||
|
"""Extract info from node attributes and children."""
|
||||||
|
try:
|
||||||
|
# From attributes
|
||||||
|
for entry, value in node.attributes.items():
|
||||||
|
entry = entry.replace(em, "")
|
||||||
|
if entry in result:
|
||||||
|
result[entry] = value
|
||||||
|
|
||||||
|
# From child nodes
|
||||||
|
for child in node.childNodes:
|
||||||
|
if child.nodeType != child.ELEMENT_NODE:
|
||||||
|
continue
|
||||||
|
entry = child.nodeName.replace(em, "")
|
||||||
|
if entry in result:
|
||||||
|
result[entry] = self._get_text(child)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _get_text(self, element: minidom.Element) -> str:
|
||||||
|
"""Get text content of an element."""
|
||||||
|
rc = []
|
||||||
|
for node in element.childNodes:
|
||||||
|
if node.nodeType == node.TEXT_NODE:
|
||||||
|
rc.append(node.data)
|
||||||
|
return "".join(rc).strip()
|
||||||
|
|
||||||
|
def _extract_target_application_info(
|
||||||
|
self,
|
||||||
|
description: minidom.Element,
|
||||||
|
details: dict[str, Any],
|
||||||
|
em: str,
|
||||||
|
) -> None:
|
||||||
|
"""Extract version info from targetApplication elements."""
|
||||||
|
for target_app in description.getElementsByTagName(em + "targetApplication"):
|
||||||
|
version_info = {"id": None, "minVersion": None, "maxVersion": None}
|
||||||
|
self._extract_info(target_app, version_info, em)
|
||||||
|
self._update_version_from_target(details, version_info)
|
||||||
|
|
||||||
|
# Check child nodes as well
|
||||||
|
for child in target_app.childNodes:
|
||||||
|
if child.nodeType != child.ELEMENT_NODE:
|
||||||
|
continue
|
||||||
|
version_info = {"id": None, "minVersion": None, "maxVersion": None}
|
||||||
|
self._extract_info(child, version_info, em)
|
||||||
|
self._update_version_from_target(details, version_info)
|
||||||
|
|
||||||
|
def _update_version_from_target(
|
||||||
|
self,
|
||||||
|
details: dict[str, Any],
|
||||||
|
version_info: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Update version range from targetApplication info."""
|
||||||
|
# Only process Zotero application
|
||||||
|
if version_info.get("id") != ZoteroApp.APP_ID:
|
||||||
|
return
|
||||||
|
|
||||||
|
min_ver = version_info.get("minVersion")
|
||||||
|
max_ver = version_info.get("maxVersion")
|
||||||
|
|
||||||
|
if not min_ver or not max_ver:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update min version (take smaller)
|
||||||
|
existing_min = details.get("min_version")
|
||||||
|
if existing_min:
|
||||||
|
min_normalized = min_ver.replace("*", "0")
|
||||||
|
existing_normalized = existing_min.replace("*", "999")
|
||||||
|
if compare_versions(min_normalized, existing_normalized) <= 0:
|
||||||
|
details["min_version"] = min_ver
|
||||||
|
else:
|
||||||
|
details["min_version"] = min_ver
|
||||||
|
|
||||||
|
# Update max version (take larger, but cap at 6.* for RDF)
|
||||||
|
# RDF format doesn't support Zotero 7
|
||||||
|
if compare_versions(max_ver.replace("*", "999"), "6.*") > 0:
|
||||||
|
max_ver = "6.*"
|
||||||
|
|
||||||
|
existing_max = details.get("max_version")
|
||||||
|
if existing_max:
|
||||||
|
max_normalized = max_ver.replace("*", "999")
|
||||||
|
existing_normalized = existing_max.replace("*", "0")
|
||||||
|
if compare_versions(max_normalized, existing_normalized) >= 0:
|
||||||
|
details["max_version"] = max_ver
|
||||||
|
else:
|
||||||
|
details["max_version"] = max_ver
|
||||||
80
src/zotero_scraper/parsers/xpi_parser.py
Normal file
80
src/zotero_scraper/parsers/xpi_parser.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""XPI file parser."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from ..exceptions import XPIParseError
|
||||||
|
from ..models.xpi import XpiDetail
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from .manifest import JsonManifestParser, RdfManifestParser
|
||||||
|
|
||||||
|
logger = get_logger("parsers.xpi")
|
||||||
|
|
||||||
|
|
||||||
|
class XPIParser:
|
||||||
|
"""Parser for XPI addon files."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize XPI parser with manifest parsers."""
|
||||||
|
self.json_parser = JsonManifestParser()
|
||||||
|
self.rdf_parser = RdfManifestParser()
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
addon_path: Union[str, Path],
|
||||||
|
priority_sources: Optional[list[str]] = None,
|
||||||
|
) -> Optional[XpiDetail]:
|
||||||
|
"""Parse XPI file and extract addon details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addon_path: Path to XPI file or directory.
|
||||||
|
priority_sources: List of sources to try, in order.
|
||||||
|
Options: "json", "rdf".
|
||||||
|
Default: ["json", "rdf"].
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
XpiDetail with extracted information, or None if parsing failed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
XPIParseError: If addon path doesn't exist.
|
||||||
|
"""
|
||||||
|
addon_path = Path(addon_path)
|
||||||
|
|
||||||
|
if not addon_path.exists():
|
||||||
|
raise XPIParseError(f"Addon path does not exist: {addon_path}")
|
||||||
|
|
||||||
|
if priority_sources is None:
|
||||||
|
priority_sources = ["json", "rdf"]
|
||||||
|
|
||||||
|
xpi_detail = XpiDetail()
|
||||||
|
|
||||||
|
for source in priority_sources:
|
||||||
|
if source == "json":
|
||||||
|
self._parse_json(addon_path, xpi_detail)
|
||||||
|
elif source == "rdf":
|
||||||
|
self._parse_rdf(addon_path, xpi_detail)
|
||||||
|
|
||||||
|
return xpi_detail if xpi_detail.is_valid() else None
|
||||||
|
|
||||||
|
def _parse_json(self, addon_path: Path, xpi_detail: XpiDetail) -> None:
|
||||||
|
"""Parse manifest.json and update XpiDetail."""
|
||||||
|
try:
|
||||||
|
manifest = self.json_parser.parse(addon_path)
|
||||||
|
if manifest:
|
||||||
|
details = self.json_parser.extract_details(addon_path, manifest)
|
||||||
|
if details:
|
||||||
|
xpi_detail.append_info(details)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"JSON parsing failed for {addon_path}: {e}")
|
||||||
|
|
||||||
|
def _parse_rdf(self, addon_path: Path, xpi_detail: XpiDetail) -> None:
|
||||||
|
"""Parse install.rdf and update XpiDetail."""
|
||||||
|
try:
|
||||||
|
manifest = self.rdf_parser.parse(addon_path)
|
||||||
|
if manifest:
|
||||||
|
details = self.rdf_parser.extract_details(addon_path, manifest)
|
||||||
|
if details:
|
||||||
|
xpi_detail.append_info(details)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"RDF parsing failed for {addon_path}: {e}")
|
||||||
11
src/zotero_scraper/services/__init__.py
Normal file
11
src/zotero_scraper/services/__init__.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""Business services module."""
|
||||||
|
|
||||||
|
from .fallback import FallbackService
|
||||||
|
from .publisher import PublisherService
|
||||||
|
from .scraper import AddonScraper
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AddonScraper",
|
||||||
|
"FallbackService",
|
||||||
|
"PublisherService",
|
||||||
|
]
|
||||||
185
src/zotero_scraper/services/fallback.py
Normal file
185
src/zotero_scraper/services/fallback.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""Fallback service for merging addon data."""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("services.fallback")
|
||||||
|
|
||||||
|
|
||||||
|
# Constants for fallback association mapping
|
||||||
|
_UNIQUE_ID_KEY = "p_unique_associated_id##80502300-9DE6-4510-8768-EC42B0EF14E6"
|
||||||
|
_EXCLUDE_IDS_KEY = "p_exclude_associated_ids##073F5543-C70A-46AA-8529-E05168852D8F"
|
||||||
|
|
||||||
|
# Default association map for addon data structure
|
||||||
|
DEFAULT_ASSOCIATION_MAP: dict[str, Any] = {
|
||||||
|
_UNIQUE_ID_KEY: "repo",
|
||||||
|
_EXCLUDE_IDS_KEY: ["id"],
|
||||||
|
"repo": {
|
||||||
|
"releases": {
|
||||||
|
_UNIQUE_ID_KEY: "tagName",
|
||||||
|
_EXCLUDE_IDS_KEY: ["currentVersion"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackService:
|
||||||
|
"""Service for falling back to previous addon data."""
|
||||||
|
|
||||||
|
def __init__(self, association_map: Optional[dict[str, Any]] = None):
|
||||||
|
"""Initialize fallback service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
association_map: Custom association map for merging.
|
||||||
|
Defaults to DEFAULT_ASSOCIATION_MAP.
|
||||||
|
"""
|
||||||
|
self.association_map = association_map or DEFAULT_ASSOCIATION_MAP
|
||||||
|
|
||||||
|
def apply_fallback(
|
||||||
|
self,
|
||||||
|
current_data: list[dict[str, Any]],
|
||||||
|
previous_url: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Apply fallback from previous data URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_data: Current addon data list.
|
||||||
|
previous_url: URL to fetch previous data from.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged data list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(previous_url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
previous_data = response.json()
|
||||||
|
|
||||||
|
if isinstance(previous_data, list):
|
||||||
|
return self._merge_lists(
|
||||||
|
current_data, previous_data, self.association_map
|
||||||
|
)
|
||||||
|
return current_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch fallback data from {previous_url}: {e}")
|
||||||
|
return current_data
|
||||||
|
|
||||||
|
def _merge_if_needed(
|
||||||
|
self,
|
||||||
|
current: Any,
|
||||||
|
previous: Any,
|
||||||
|
association_map: Optional[dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Recursively merge previous data into current.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Current data (dict or list).
|
||||||
|
previous: Previous data to merge from.
|
||||||
|
association_map: Association mapping for this level.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged data.
|
||||||
|
"""
|
||||||
|
if association_map is None:
|
||||||
|
association_map = self.association_map
|
||||||
|
|
||||||
|
if not previous or not current:
|
||||||
|
return current
|
||||||
|
|
||||||
|
if not isinstance(previous, type(current)):
|
||||||
|
return current
|
||||||
|
|
||||||
|
if isinstance(current, dict):
|
||||||
|
return self._merge_dicts(current, previous, association_map)
|
||||||
|
elif isinstance(current, list):
|
||||||
|
return self._merge_lists(current, previous, association_map)
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _merge_dicts(
|
||||||
|
self,
|
||||||
|
current: dict[str, Any],
|
||||||
|
previous: dict[str, Any],
|
||||||
|
association_map: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge two dictionaries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Current dictionary.
|
||||||
|
previous: Previous dictionary.
|
||||||
|
association_map: Association mapping.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged dictionary.
|
||||||
|
"""
|
||||||
|
exclude_keys = association_map.get(_EXCLUDE_IDS_KEY, [])
|
||||||
|
|
||||||
|
for key, value in previous.items():
|
||||||
|
# Skip excluded keys
|
||||||
|
if key in exclude_keys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key in current:
|
||||||
|
# If current value exists and is a container, recurse
|
||||||
|
if isinstance(value, (list, dict)):
|
||||||
|
if current[key]:
|
||||||
|
nested_map = association_map.get(key, {})
|
||||||
|
self._merge_if_needed(current[key], value, nested_map)
|
||||||
|
else:
|
||||||
|
# Current is empty, use previous
|
||||||
|
logger.debug(f"Fallback: {key}")
|
||||||
|
current[key] = value
|
||||||
|
else:
|
||||||
|
# Key doesn't exist in current, add from previous
|
||||||
|
logger.debug(f"Fallback: {key}")
|
||||||
|
current[key] = value
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def _merge_lists(
|
||||||
|
self,
|
||||||
|
current: list[Any],
|
||||||
|
previous: list[Any],
|
||||||
|
association_map: dict[str, Any],
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Merge two lists using unique ID matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: Current list.
|
||||||
|
previous: Previous list.
|
||||||
|
association_map: Association mapping.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged list.
|
||||||
|
"""
|
||||||
|
unique_key = association_map.get(_UNIQUE_ID_KEY)
|
||||||
|
if not unique_key:
|
||||||
|
return current
|
||||||
|
|
||||||
|
for prev_item in previous:
|
||||||
|
if not isinstance(prev_item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev_id = prev_item.get(unique_key)
|
||||||
|
if not prev_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find matching item in current
|
||||||
|
current_item = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in current
|
||||||
|
if isinstance(item, dict) and item.get(unique_key) == prev_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_item:
|
||||||
|
# Merge matching items
|
||||||
|
nested_map = association_map.get(unique_key, {})
|
||||||
|
self._merge_if_needed(current_item, prev_item, nested_map)
|
||||||
|
|
||||||
|
return current
|
||||||
117
src/zotero_scraper/services/publisher.py
Normal file
117
src/zotero_scraper/services/publisher.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""Publisher service for releasing addon information."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..clients.github import GitHubClient
|
||||||
|
from ..config.settings import GitHubConfig
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("services.publisher")
|
||||||
|
|
||||||
|
|
||||||
|
class PublisherService:
|
||||||
|
"""Service for publishing addon information to GitHub releases."""
|
||||||
|
|
||||||
|
def __init__(self, github_client: GitHubClient, config: GitHubConfig):
|
||||||
|
"""Initialize publisher service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
github_client: GitHub API client.
|
||||||
|
config: GitHub configuration.
|
||||||
|
"""
|
||||||
|
self.github = github_client
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def publish(self, output_file: Path) -> bool:
|
||||||
|
"""Publish addon info file to GitHub release.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_file: Path to addon_infos.json file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful.
|
||||||
|
"""
|
||||||
|
if not self.config.owner or not self.config.repo_name:
|
||||||
|
logger.error("GitHub repository not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not output_file.exists():
|
||||||
|
logger.error(f"Output file not found: {output_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
owner = self.config.owner
|
||||||
|
repo = self.config.repo_name
|
||||||
|
|
||||||
|
# Clean up old releases and tags
|
||||||
|
logger.info("Cleaning up old releases and tags...")
|
||||||
|
self.github.delete_old_releases(owner, repo, keep_count=2)
|
||||||
|
self.github.delete_old_tags(owner, repo, keep_count=2)
|
||||||
|
|
||||||
|
# Create new release
|
||||||
|
logger.info("Creating new release...")
|
||||||
|
release_id = self.github.create_release(owner, repo)
|
||||||
|
if not release_id:
|
||||||
|
logger.error("Failed to create release")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
logger.info(f"Uploading {output_file.name}...")
|
||||||
|
success = self.github.upload_release_asset(
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
release_id=release_id,
|
||||||
|
filename="addon_infos.json",
|
||||||
|
filepath=str(output_file),
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info("Published successfully")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to upload release asset")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def cleanup_caches(self, keep_count: int = 1) -> None:
|
||||||
|
"""Clean up old GitHub Actions caches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keep_count: Number of caches to keep.
|
||||||
|
"""
|
||||||
|
if not self.config.owner or not self.config.repo_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Cleaning up old caches...")
|
||||||
|
self.github.delete_old_caches(
|
||||||
|
self.config.owner,
|
||||||
|
self.config.repo_name,
|
||||||
|
keep_count=keep_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def report_issue(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
check_id: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Report an issue to GitHub.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Issue title.
|
||||||
|
body: Issue body.
|
||||||
|
check_id: Optional ID for duplicate checking.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful or skipped as duplicate.
|
||||||
|
"""
|
||||||
|
if not self.config.owner or not self.config.repo_name:
|
||||||
|
logger.warning("GitHub repository not configured for issue reporting")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.github.create_issue(
|
||||||
|
owner=self.config.owner,
|
||||||
|
repo=self.config.repo_name,
|
||||||
|
title=title,
|
||||||
|
body=body,
|
||||||
|
check_duplicate_id=check_id,
|
||||||
|
)
|
||||||
357
src/zotero_scraper/services/scraper.py
Normal file
357
src/zotero_scraper/services/scraper.py
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
"""Core scraper service for fetching addon information."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from ..clients.downloader import XPIDownloader
|
||||||
|
from ..clients.github import GitHubClient, ReleaseAsset
|
||||||
|
from ..config.constants import ContentTypes, XPIProxy
|
||||||
|
from ..config.settings import ScraperConfig
|
||||||
|
from ..models.addon import AddonInfo, AddonRelease, Author, XpiDownloadUrls
|
||||||
|
from ..parsers.xpi_parser import XPIParser
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from ..utils.version import compare_versions
|
||||||
|
from .fallback import FallbackService
|
||||||
|
from .publisher import PublisherService
|
||||||
|
|
||||||
|
logger = get_logger("services.scraper")
|
||||||
|
|
||||||
|
|
||||||
|
class AddonScraper:
|
||||||
|
"""Core service for scraping Zotero addon information."""
|
||||||
|
|
||||||
|
def __init__(self, config: ScraperConfig):
|
||||||
|
"""Initialize scraper service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Scraper configuration.
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.github = GitHubClient(config.github)
|
||||||
|
self.downloader = XPIDownloader(config.cache)
|
||||||
|
self.xpi_parser = XPIParser()
|
||||||
|
self.fallback = FallbackService()
|
||||||
|
self.publisher = PublisherService(self.github, config.github)
|
||||||
|
|
||||||
|
def scrape_all(self) -> list[dict[str, Any]]:
|
||||||
|
"""Scrape all addon information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of addon info dictionaries.
|
||||||
|
"""
|
||||||
|
# Load input configs
|
||||||
|
plugins = self._load_input_configs()
|
||||||
|
logger.info(f"Loaded {len(plugins)} addon configs")
|
||||||
|
|
||||||
|
# Scrape in parallel
|
||||||
|
addon_infos: list[AddonInfo] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=self.config.max_workers) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(self._scrape_addon, plugin): plugin
|
||||||
|
for plugin in plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
plugin = futures[future]
|
||||||
|
try:
|
||||||
|
if result := future.result():
|
||||||
|
addon_infos.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to scrape {plugin.repo}: {e}")
|
||||||
|
|
||||||
|
# Convert to dict
|
||||||
|
result = [info.to_dict() for info in addon_infos]
|
||||||
|
|
||||||
|
# Apply fallback from previous versions
|
||||||
|
for url in self.config.previous_info_urls:
|
||||||
|
result = self.fallback.apply_fallback(result, url)
|
||||||
|
|
||||||
|
# Sort by stars (descending)
|
||||||
|
result.sort(key=lambda x: x.get("stars") or 0, reverse=True)
|
||||||
|
|
||||||
|
# Save output
|
||||||
|
self._save_output(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _load_input_configs(self) -> list[AddonInfo]:
|
||||||
|
"""Load addon configs from input directory."""
|
||||||
|
plugins: list[AddonInfo] = []
|
||||||
|
|
||||||
|
if not self.config.input_dir.exists():
|
||||||
|
logger.error(f"Input directory not found: {self.config.input_dir}")
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
for config_file in self.config.input_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(config_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
plugins.append(AddonInfo.from_dict(data))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load {config_file}: {e}")
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
def _scrape_addon(self, plugin: AddonInfo) -> Optional[AddonInfo]:
|
||||||
|
"""Scrape information for a single addon."""
|
||||||
|
if not plugin.owner or not plugin.releases:
|
||||||
|
logger.warning(f"Invalid plugin config: {plugin.repo}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
plugin.name = plugin.repository
|
||||||
|
|
||||||
|
# Fetch author info
|
||||||
|
self._fetch_author_info(plugin)
|
||||||
|
|
||||||
|
# Fetch repository info
|
||||||
|
self._fetch_repo_info(plugin)
|
||||||
|
|
||||||
|
# Process releases
|
||||||
|
valid_releases: list[AddonRelease] = []
|
||||||
|
invalid_releases: list[AddonRelease] = []
|
||||||
|
|
||||||
|
for release in plugin.releases:
|
||||||
|
processed = self._process_release(plugin, release)
|
||||||
|
if processed:
|
||||||
|
if processed.get("valid"):
|
||||||
|
valid_releases.append(release)
|
||||||
|
else:
|
||||||
|
invalid_releases.append(release)
|
||||||
|
|
||||||
|
# Report invalid releases
|
||||||
|
for invalid_release in invalid_releases:
|
||||||
|
logger.warning(
|
||||||
|
f"{plugin.repo} invalid for Zotero {invalid_release.zotero_check_version}"
|
||||||
|
)
|
||||||
|
self.publisher.report_issue(
|
||||||
|
title=f"Invalid {plugin.repo} xpi with zotero version {invalid_release.zotero_check_version}",
|
||||||
|
body=(
|
||||||
|
f"xpi: https://github.com/{plugin.repo} @{invalid_release.tagName}\n"
|
||||||
|
f"min zotero Version: {invalid_release.minZoteroVersion}\n"
|
||||||
|
f"max Zotero version: {invalid_release.maxZoteroVersion}\n"
|
||||||
|
f"expect Zotero version: {invalid_release.zotero_check_version}\n"
|
||||||
|
),
|
||||||
|
check_id=f"Target zotero version not match: {plugin.repo}+{invalid_release.tagName}@{invalid_release.targetZoteroVersion}",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin.releases = valid_releases
|
||||||
|
return plugin if valid_releases else None
|
||||||
|
|
||||||
|
def _fetch_author_info(self, plugin: AddonInfo) -> None:
|
||||||
|
"""Fetch author info from GitHub."""
|
||||||
|
if user_info := self.github.get_user(plugin.owner):
|
||||||
|
plugin.author = Author(
|
||||||
|
name=user_info.name,
|
||||||
|
url=user_info.html_url,
|
||||||
|
avatar=user_info.avatar_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_repo_info(self, plugin: AddonInfo) -> None:
|
||||||
|
"""Fetch repository info from GitHub."""
|
||||||
|
if repo_info := self.github.get_repo(plugin.owner, plugin.repository):
|
||||||
|
if not plugin.description and repo_info.description:
|
||||||
|
plugin.description = repo_info.description
|
||||||
|
plugin.stars = repo_info.stargazers_count
|
||||||
|
|
||||||
|
def _process_release(
|
||||||
|
self, plugin: AddonInfo, release: AddonRelease
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Process a single release.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'valid' key indicating compatibility, or None if failed.
|
||||||
|
"""
|
||||||
|
if not release.tagName:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get release info from GitHub
|
||||||
|
release_info = self.github.get_release(
|
||||||
|
plugin.owner, plugin.repository, release.tagName
|
||||||
|
)
|
||||||
|
if not release_info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
release.tagName = release_info.tag_name
|
||||||
|
|
||||||
|
# Find XPI asset
|
||||||
|
xpi_asset = self._find_xpi_asset(release_info.assets)
|
||||||
|
if not xpi_asset:
|
||||||
|
logger.warning(f"No XPI found for {plugin.repo}@{release.tagName}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Set download URLs
|
||||||
|
release.xpiDownloadUrl = XpiDownloadUrls(
|
||||||
|
github=xpi_asset.browser_download_url,
|
||||||
|
ghProxy=XPIProxy.ghproxy_url(xpi_asset.browser_download_url),
|
||||||
|
kgithub=XPIProxy.kkgithub_url(xpi_asset.browser_download_url),
|
||||||
|
)
|
||||||
|
release.releaseDate = xpi_asset.updated_at
|
||||||
|
|
||||||
|
# Download and parse XPI
|
||||||
|
xpi_filename = (
|
||||||
|
f"{plugin.owner}#{plugin.repository}+{release.tagName}@{xpi_asset.id}.xpi"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine priority sources based on Zotero version
|
||||||
|
priority_sources = ["rdf", "json"]
|
||||||
|
if release.targetZoteroVersion == "6":
|
||||||
|
priority_sources = ["json", "rdf"]
|
||||||
|
|
||||||
|
xpi_path = self.downloader.download(
|
||||||
|
xpi_asset.browser_download_url, xpi_filename
|
||||||
|
)
|
||||||
|
|
||||||
|
if not xpi_path:
|
||||||
|
self.publisher.report_issue(
|
||||||
|
title=f"Parse {plugin.repo} addon details failed",
|
||||||
|
body=f"xpi: https://github.com/{plugin.repo} @{release.tagName} on {release.targetZoteroVersion}\n",
|
||||||
|
check_id=f"Parse details failed: {plugin.repo}+{release.tagName}@{release.targetZoteroVersion}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
details = self.xpi_parser.parse(xpi_path, priority_sources)
|
||||||
|
|
||||||
|
if details:
|
||||||
|
# Update release with XPI details
|
||||||
|
if details.id:
|
||||||
|
release.id = details.id
|
||||||
|
if details.name:
|
||||||
|
release.name = details.name
|
||||||
|
if details.version:
|
||||||
|
release.xpiVersion = details.version
|
||||||
|
if details.description:
|
||||||
|
release.description = details.description
|
||||||
|
release.minZoteroVersion = details.min_version
|
||||||
|
release.maxZoteroVersion = details.max_version
|
||||||
|
|
||||||
|
# Check for updates via update_url
|
||||||
|
self._check_for_updates(plugin, release, details, priority_sources)
|
||||||
|
|
||||||
|
# Validate compatibility
|
||||||
|
if not details.check_compatible(release.zotero_check_version):
|
||||||
|
return {"valid": False}
|
||||||
|
|
||||||
|
# Report if version info is missing
|
||||||
|
if (
|
||||||
|
not release.minZoteroVersion
|
||||||
|
or release.minZoteroVersion == "*"
|
||||||
|
) and (
|
||||||
|
not release.maxZoteroVersion
|
||||||
|
or release.maxZoteroVersion == "*"
|
||||||
|
):
|
||||||
|
self.publisher.report_issue(
|
||||||
|
title=f"Parse {plugin.repo} of zotero version failed",
|
||||||
|
body=f"xpi: https://github.com/{plugin.repo} @{release.tagName} on {release.targetZoteroVersion}\n",
|
||||||
|
check_id=f"Parse min/max version failed: {plugin.repo}+{release.tagName}@{release.targetZoteroVersion}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"valid": True}
|
||||||
|
else:
|
||||||
|
self.publisher.report_issue(
|
||||||
|
title=f"Parse {plugin.repo} addon details failed",
|
||||||
|
body=f"xpi: https://github.com/{plugin.repo} @{release.tagName} on {release.targetZoteroVersion}\n",
|
||||||
|
check_id=f"Parse details failed: {plugin.repo}+{release.tagName}@{release.targetZoteroVersion}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_xpi_asset(
|
||||||
|
self, assets: list[ReleaseAsset]
|
||||||
|
) -> Optional[ReleaseAsset]:
|
||||||
|
"""Find XPI asset from release assets."""
|
||||||
|
# Sort by updated_at descending
|
||||||
|
sorted_assets = sorted(
|
||||||
|
assets, key=lambda a: a.updated_at, reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# First try XPI content type
|
||||||
|
for asset in sorted_assets:
|
||||||
|
if asset.content_type == ContentTypes.XPI:
|
||||||
|
return asset
|
||||||
|
|
||||||
|
# Fallback to ZIP content type
|
||||||
|
for asset in sorted_assets:
|
||||||
|
if asset.content_type == ContentTypes.ZIP:
|
||||||
|
return asset
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_for_updates(
|
||||||
|
self,
|
||||||
|
plugin: AddonInfo,
|
||||||
|
release: AddonRelease,
|
||||||
|
details: Any,
|
||||||
|
priority_sources: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Check for updates via update_url."""
|
||||||
|
if not details.update_url or not details.id or not details.version:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
response = requests.get(details.update_url, timeout=30)
|
||||||
|
if response.status_code != 200:
|
||||||
|
return
|
||||||
|
|
||||||
|
update_info = response.json()
|
||||||
|
updates = (
|
||||||
|
update_info.get("addons", {})
|
||||||
|
.get(details.id, {})
|
||||||
|
.get("updates", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find newer versions
|
||||||
|
newer_versions = [
|
||||||
|
u
|
||||||
|
for u in updates
|
||||||
|
if compare_versions(u.get("version", "0"), details.version) > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
for update in newer_versions:
|
||||||
|
xpi_url = update.get("update_link")
|
||||||
|
if not xpi_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
update_filename = (
|
||||||
|
f"{plugin.owner}#{plugin.repository}+update{details.version}.xpi"
|
||||||
|
)
|
||||||
|
update_path = self.downloader.download(xpi_url, update_filename)
|
||||||
|
|
||||||
|
if update_path:
|
||||||
|
update_details = self.xpi_parser.parse(
|
||||||
|
update_path, priority_sources
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
update_details
|
||||||
|
and update_details.id
|
||||||
|
and update_details.check_compatible(
|
||||||
|
release.zotero_check_version
|
||||||
|
)
|
||||||
|
):
|
||||||
|
# Update release with newer version
|
||||||
|
if update_details.id:
|
||||||
|
release.id = update_details.id
|
||||||
|
if update_details.name:
|
||||||
|
release.name = update_details.name
|
||||||
|
if update_details.version:
|
||||||
|
release.xpiVersion = update_details.version
|
||||||
|
if update_details.description:
|
||||||
|
release.description = update_details.description
|
||||||
|
release.minZoteroVersion = update_details.min_version
|
||||||
|
release.maxZoteroVersion = update_details.max_version
|
||||||
|
release.xpiDownloadUrl = XpiDownloadUrls(github=xpi_url)
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to check updates for {plugin.repo}: {e}")
|
||||||
|
|
||||||
|
def _save_output(self, data: list[dict[str, Any]]) -> None:
|
||||||
|
"""Save output to file."""
|
||||||
|
self.config.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(self.config.output_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(f"Saved {len(data)} addons to {self.config.output_file}")
|
||||||
10
src/zotero_scraper/utils/__init__.py
Normal file
10
src/zotero_scraper/utils/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""Utility functions module."""
|
||||||
|
|
||||||
|
from .logging import get_logger, setup_logging
|
||||||
|
from .version import compare_versions
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"compare_versions",
|
||||||
|
"get_logger",
|
||||||
|
"setup_logging",
|
||||||
|
]
|
||||||
60
src/zotero_scraper/utils/logging.py
Normal file
60
src/zotero_scraper/utils/logging.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""Logging configuration for the Zotero addons scraper."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
level: int = logging.INFO,
|
||||||
|
log_file: Optional[Path] = None,
|
||||||
|
format_string: Optional[str] = None,
|
||||||
|
) -> logging.Logger:
|
||||||
|
"""Configure the logging system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: Logging level (default: INFO).
|
||||||
|
log_file: Optional path to log file.
|
||||||
|
format_string: Optional custom format string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured root logger for the package.
|
||||||
|
"""
|
||||||
|
if format_string is None:
|
||||||
|
format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
|
||||||
|
# Create root logger for the package
|
||||||
|
logger = logging.getLogger("zotero_scraper")
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
# Remove existing handlers to avoid duplicates
|
||||||
|
logger.handlers.clear()
|
||||||
|
|
||||||
|
# Console handler
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(level)
|
||||||
|
console_handler.setFormatter(logging.Formatter(format_string))
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# File handler (optional)
|
||||||
|
if log_file:
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(level)
|
||||||
|
file_handler.setFormatter(logging.Formatter(format_string))
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""Get a child logger for a specific module.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Module name (will be prefixed with 'zotero_scraper.').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Logger instance for the module.
|
||||||
|
"""
|
||||||
|
return logging.getLogger(f"zotero_scraper.{name}")
|
||||||
75
src/zotero_scraper/utils/version.py
Normal file
75
src/zotero_scraper/utils/version.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Version comparison utilities."""
|
||||||
|
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
def compare_versions(version1: Union[str, int], version2: Union[str, int]) -> int:
|
||||||
|
"""Compare two version strings.
|
||||||
|
|
||||||
|
Supports version formats like:
|
||||||
|
- "1.0.0", "1.2.3"
|
||||||
|
- "1.0.0-beta", "1.0.0-alpha"
|
||||||
|
- "7.*", "6.*"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version1: First version string.
|
||||||
|
version2: Second version string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-1 if version1 < version2
|
||||||
|
0 if version1 == version2
|
||||||
|
1 if version1 > version2
|
||||||
|
"""
|
||||||
|
v1 = str(version1)
|
||||||
|
v2 = str(version2)
|
||||||
|
|
||||||
|
# Normalize: replace '-' with '.' for comparison
|
||||||
|
parts1 = v1.replace("-", ".").split(".")
|
||||||
|
parts2 = v2.replace("-", ".").split(".")
|
||||||
|
|
||||||
|
# Pad shorter list with zeros
|
||||||
|
max_len = max(len(parts1), len(parts2))
|
||||||
|
parts1.extend(["0"] * (max_len - len(parts1)))
|
||||||
|
parts2.extend(["0"] * (max_len - len(parts2)))
|
||||||
|
|
||||||
|
for p1, p2 in zip(parts1, parts2):
|
||||||
|
# Handle wildcard
|
||||||
|
if p1 == "*" or p2 == "*":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try numeric comparison
|
||||||
|
try:
|
||||||
|
n1, n2 = int(p1), int(p2)
|
||||||
|
if n1 < n2:
|
||||||
|
return -1
|
||||||
|
elif n1 > n2:
|
||||||
|
return 1
|
||||||
|
except ValueError:
|
||||||
|
# Fall back to string comparison for non-numeric parts
|
||||||
|
if p1 < p2:
|
||||||
|
return -1
|
||||||
|
elif p1 > p2:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def version_in_range(
|
||||||
|
version: str,
|
||||||
|
min_version: str,
|
||||||
|
max_version: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a version is within a given range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
version: Version to check.
|
||||||
|
min_version: Minimum version (inclusive).
|
||||||
|
max_version: Maximum version (inclusive).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if version is within [min_version, max_version].
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
compare_versions(version, min_version) >= 0
|
||||||
|
and compare_versions(version, max_version) <= 0
|
||||||
|
)
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests package."""
|
||||||
126
tests/conftest.py
Normal file
126
tests/conftest.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"""Pytest fixtures for Zotero addons scraper tests."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fixtures_dir() -> Path:
|
||||||
|
"""Return path to fixtures directory."""
|
||||||
|
return Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory for tests."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
yield Path(tmpdir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_manifest_json() -> dict[str, Any]:
|
||||||
|
"""Return sample manifest.json data."""
|
||||||
|
return {
|
||||||
|
"name": "Test Addon",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test addon for Zotero",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"zotero": {
|
||||||
|
"id": "test@addon.com",
|
||||||
|
"strict_min_version": "6.0",
|
||||||
|
"strict_max_version": "7.*",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_addon_config() -> dict[str, Any]:
|
||||||
|
"""Return sample addon configuration."""
|
||||||
|
return {
|
||||||
|
"repo": "owner/repo",
|
||||||
|
"releases": [
|
||||||
|
{"targetZoteroVersion": "7", "tagName": "latest"},
|
||||||
|
{"targetZoteroVersion": "6", "tagName": "v1.0"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_addon_info_output() -> dict[str, Any]:
|
||||||
|
"""Return sample output addon info."""
|
||||||
|
return {
|
||||||
|
"repo": "owner/repo",
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"targetZoteroVersion": "7",
|
||||||
|
"tagName": "v2.0.0",
|
||||||
|
"xpiDownloadUrl": {
|
||||||
|
"github": "https://github.com/owner/repo/releases/download/v2.0.0/addon.xpi",
|
||||||
|
"ghProxy": "https://gh-proxy.com/?q=https%3A//github.com/owner/repo/releases/download/v2.0.0/addon.xpi",
|
||||||
|
"kgithub": "https://kkgithub.com/owner/repo/releases/download/v2.0.0/addon.xpi",
|
||||||
|
},
|
||||||
|
"releaseDate": "2024-01-01T00:00:00Z",
|
||||||
|
"id": "test@addon.com",
|
||||||
|
"xpiVersion": "2.0.0",
|
||||||
|
"name": "Test Addon",
|
||||||
|
"description": "A test addon",
|
||||||
|
"minZoteroVersion": "7.0",
|
||||||
|
"maxZoteroVersion": "7.*",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "repo",
|
||||||
|
"description": "Test repository",
|
||||||
|
"stars": 100,
|
||||||
|
"star": 100,
|
||||||
|
"author": {
|
||||||
|
"name": "Test Author",
|
||||||
|
"url": "https://github.com/owner",
|
||||||
|
"avatar": "https://avatars.githubusercontent.com/owner",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def github_user_response() -> dict[str, Any]:
|
||||||
|
"""Return sample GitHub user API response."""
|
||||||
|
return {
|
||||||
|
"login": "owner",
|
||||||
|
"name": "Test Owner",
|
||||||
|
"html_url": "https://github.com/owner",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/owner",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def github_repo_response() -> dict[str, Any]:
|
||||||
|
"""Return sample GitHub repo API response."""
|
||||||
|
return {
|
||||||
|
"name": "repo",
|
||||||
|
"full_name": "owner/repo",
|
||||||
|
"description": "Test repository description",
|
||||||
|
"stargazers_count": 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def github_release_response() -> dict[str, Any]:
|
||||||
|
"""Return sample GitHub release API response."""
|
||||||
|
return {
|
||||||
|
"tag_name": "v2.0.0",
|
||||||
|
"prerelease": False,
|
||||||
|
"published_at": "2024-01-01T00:00:00Z",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"name": "addon.xpi",
|
||||||
|
"browser_download_url": "https://github.com/owner/repo/releases/download/v2.0.0/addon.xpi",
|
||||||
|
"content_type": "application/x-xpinstall",
|
||||||
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Integration tests package."""
|
||||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Unit tests package."""
|
||||||
189
tests/unit/test_models.py
Normal file
189
tests/unit/test_models.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"""Unit tests for data models."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from zotero_scraper.models.addon import (
|
||||||
|
AddonInfo,
|
||||||
|
AddonRelease,
|
||||||
|
Author,
|
||||||
|
XpiDownloadUrls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthor:
|
||||||
|
"""Tests for Author model."""
|
||||||
|
|
||||||
|
def test_to_dict_full(self):
|
||||||
|
"""Test to_dict with all fields."""
|
||||||
|
author = Author(
|
||||||
|
name="Test Author",
|
||||||
|
url="https://github.com/test",
|
||||||
|
avatar="https://avatars.githubusercontent.com/test",
|
||||||
|
)
|
||||||
|
result = author.to_dict()
|
||||||
|
assert result == {
|
||||||
|
"name": "Test Author",
|
||||||
|
"url": "https://github.com/test",
|
||||||
|
"avatar": "https://avatars.githubusercontent.com/test",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_to_dict_excludes_none(self):
|
||||||
|
"""Test to_dict excludes None values."""
|
||||||
|
author = Author(name="Test", url=None, avatar=None)
|
||||||
|
result = author.to_dict()
|
||||||
|
assert result == {"name": "Test"}
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
"""Test from_dict factory method."""
|
||||||
|
data = {"name": "Test", "url": "https://example.com"}
|
||||||
|
author = Author.from_dict(data)
|
||||||
|
assert author.name == "Test"
|
||||||
|
assert author.url == "https://example.com"
|
||||||
|
assert author.avatar is None
|
||||||
|
|
||||||
|
def test_from_dict_none(self):
|
||||||
|
"""Test from_dict with None input."""
|
||||||
|
author = Author.from_dict(None)
|
||||||
|
assert author.name is None
|
||||||
|
assert author.url is None
|
||||||
|
assert author.avatar is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestXpiDownloadUrls:
|
||||||
|
"""Tests for XpiDownloadUrls model."""
|
||||||
|
|
||||||
|
def test_to_dict_full(self):
|
||||||
|
"""Test to_dict with all fields."""
|
||||||
|
urls = XpiDownloadUrls(
|
||||||
|
github="https://github.com/example.xpi",
|
||||||
|
ghProxy="https://gh-proxy.com/example.xpi",
|
||||||
|
kgithub="https://kkgithub.com/example.xpi",
|
||||||
|
)
|
||||||
|
result = urls.to_dict()
|
||||||
|
assert result == {
|
||||||
|
"github": "https://github.com/example.xpi",
|
||||||
|
"ghProxy": "https://gh-proxy.com/example.xpi",
|
||||||
|
"kgithub": "https://kkgithub.com/example.xpi",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_to_dict_minimal(self):
|
||||||
|
"""Test to_dict with only required fields."""
|
||||||
|
urls = XpiDownloadUrls(github="https://github.com/example.xpi")
|
||||||
|
result = urls.to_dict()
|
||||||
|
assert result == {"github": "https://github.com/example.xpi"}
|
||||||
|
|
||||||
|
def test_from_dict_valid(self):
|
||||||
|
"""Test from_dict with valid data."""
|
||||||
|
data = {"github": "https://github.com/example.xpi"}
|
||||||
|
urls = XpiDownloadUrls.from_dict(data)
|
||||||
|
assert urls is not None
|
||||||
|
assert urls.github == "https://github.com/example.xpi"
|
||||||
|
|
||||||
|
def test_from_dict_missing_github(self):
|
||||||
|
"""Test from_dict returns None if github is missing."""
|
||||||
|
data = {"ghProxy": "https://gh-proxy.com/example.xpi"}
|
||||||
|
urls = XpiDownloadUrls.from_dict(data)
|
||||||
|
assert urls is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddonRelease:
|
||||||
|
"""Tests for AddonRelease model."""
|
||||||
|
|
||||||
|
def test_zotero_check_version_7(self):
|
||||||
|
"""Test zotero_check_version for Zotero 7."""
|
||||||
|
release = AddonRelease(targetZoteroVersion="7", tagName="latest")
|
||||||
|
assert release.zotero_check_version == "7.*"
|
||||||
|
|
||||||
|
def test_zotero_check_version_6(self):
|
||||||
|
"""Test zotero_check_version for Zotero 6."""
|
||||||
|
release = AddonRelease(targetZoteroVersion="6", tagName="v1.0")
|
||||||
|
assert release.zotero_check_version == "6.*"
|
||||||
|
|
||||||
|
def test_zotero_check_version_invalid(self):
|
||||||
|
"""Test zotero_check_version raises for invalid version."""
|
||||||
|
release = AddonRelease(targetZoteroVersion="5", tagName="v1.0")
|
||||||
|
with pytest.raises(ValueError, match="Invalid targetZoteroVersion"):
|
||||||
|
_ = release.zotero_check_version
|
||||||
|
|
||||||
|
def test_to_dict_minimal(self):
|
||||||
|
"""Test to_dict with minimal fields."""
|
||||||
|
release = AddonRelease(targetZoteroVersion="7", tagName="latest")
|
||||||
|
result = release.to_dict()
|
||||||
|
assert result == {"targetZoteroVersion": "7", "tagName": "latest"}
|
||||||
|
|
||||||
|
def test_to_dict_full(self):
|
||||||
|
"""Test to_dict with all fields."""
|
||||||
|
release = AddonRelease(
|
||||||
|
targetZoteroVersion="7",
|
||||||
|
tagName="v1.0.0",
|
||||||
|
xpiDownloadUrl=XpiDownloadUrls(github="https://example.com"),
|
||||||
|
releaseDate="2024-01-01T00:00:00Z",
|
||||||
|
id="test@addon.com",
|
||||||
|
xpiVersion="1.0.0",
|
||||||
|
name="Test Addon",
|
||||||
|
description="Test description",
|
||||||
|
minZoteroVersion="7.0",
|
||||||
|
maxZoteroVersion="7.*",
|
||||||
|
)
|
||||||
|
result = release.to_dict()
|
||||||
|
assert result["targetZoteroVersion"] == "7"
|
||||||
|
assert result["tagName"] == "v1.0.0"
|
||||||
|
assert result["xpiDownloadUrl"] == {"github": "https://example.com"}
|
||||||
|
assert result["id"] == "test@addon.com"
|
||||||
|
|
||||||
|
def test_from_dict(self, sample_addon_config):
|
||||||
|
"""Test from_dict factory method."""
|
||||||
|
release_data = sample_addon_config["releases"][0]
|
||||||
|
release = AddonRelease.from_dict(release_data)
|
||||||
|
assert release.targetZoteroVersion == "7"
|
||||||
|
assert release.tagName == "latest"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddonInfo:
|
||||||
|
"""Tests for AddonInfo model."""
|
||||||
|
|
||||||
|
def test_owner_valid(self):
|
||||||
|
"""Test owner property with valid repo."""
|
||||||
|
addon = AddonInfo(repo="owner/repo", releases=[])
|
||||||
|
assert addon.owner == "owner"
|
||||||
|
|
||||||
|
def test_owner_invalid(self):
|
||||||
|
"""Test owner property with invalid repo."""
|
||||||
|
addon = AddonInfo(repo="invalid", releases=[])
|
||||||
|
assert addon.owner is None
|
||||||
|
|
||||||
|
def test_repository_valid(self):
|
||||||
|
"""Test repository property with valid repo."""
|
||||||
|
addon = AddonInfo(repo="owner/repo", releases=[])
|
||||||
|
assert addon.repository == "repo"
|
||||||
|
|
||||||
|
def test_repository_invalid(self):
|
||||||
|
"""Test repository property with invalid repo."""
|
||||||
|
addon = AddonInfo(repo="invalid", releases=[])
|
||||||
|
assert addon.repository is None
|
||||||
|
|
||||||
|
def test_to_dict_backward_compatible(self):
|
||||||
|
"""Test to_dict maintains backward compatibility."""
|
||||||
|
addon = AddonInfo(
|
||||||
|
repo="owner/repo",
|
||||||
|
releases=[AddonRelease(targetZoteroVersion="7", tagName="v1.0")],
|
||||||
|
name="Test",
|
||||||
|
stars=100,
|
||||||
|
author=Author(name="Author"),
|
||||||
|
)
|
||||||
|
result = addon.to_dict()
|
||||||
|
|
||||||
|
# Check backward compatibility: both "stars" and "star" should exist
|
||||||
|
assert result["stars"] == 100
|
||||||
|
assert result["star"] == 100
|
||||||
|
assert result["repo"] == "owner/repo"
|
||||||
|
assert result["name"] == "Test"
|
||||||
|
assert result["author"] == {"name": "Author"}
|
||||||
|
|
||||||
|
def test_from_dict(self, sample_addon_config):
|
||||||
|
"""Test from_dict factory method."""
|
||||||
|
addon = AddonInfo.from_dict(sample_addon_config)
|
||||||
|
assert addon.repo == "owner/repo"
|
||||||
|
assert len(addon.releases) == 2
|
||||||
|
assert addon.releases[0].targetZoteroVersion == "7"
|
||||||
|
assert addon.releases[1].tagName == "v1.0"
|
||||||
149
tests/unit/test_parsers.py
Normal file
149
tests/unit/test_parsers.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""Unit tests for parsers."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from zotero_scraper.models.xpi import XpiDetail
|
||||||
|
from zotero_scraper.parsers.manifest import JsonManifestParser, RdfManifestParser
|
||||||
|
from zotero_scraper.parsers.xpi_parser import XPIParser
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonManifestParser:
|
||||||
|
"""Tests for JsonManifestParser."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parser(self):
|
||||||
|
return JsonManifestParser()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_xpi(self, sample_manifest_json, temp_dir):
|
||||||
|
"""Create a sample XPI file with manifest.json."""
|
||||||
|
xpi_path = temp_dir / "test.xpi"
|
||||||
|
with zipfile.ZipFile(xpi_path, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", json.dumps(sample_manifest_json))
|
||||||
|
return xpi_path
|
||||||
|
|
||||||
|
def test_parse_from_xpi(self, parser, sample_xpi, sample_manifest_json):
|
||||||
|
"""Test parsing manifest.json from XPI file."""
|
||||||
|
manifest = parser.parse(sample_xpi)
|
||||||
|
assert manifest is not None
|
||||||
|
assert manifest["name"] == sample_manifest_json["name"]
|
||||||
|
assert manifest["version"] == sample_manifest_json["version"]
|
||||||
|
|
||||||
|
def test_parse_from_directory(self, parser, sample_manifest_json, temp_dir):
|
||||||
|
"""Test parsing manifest.json from directory."""
|
||||||
|
manifest_path = temp_dir / "manifest.json"
|
||||||
|
with open(manifest_path, "w") as f:
|
||||||
|
json.dump(sample_manifest_json, f)
|
||||||
|
|
||||||
|
manifest = parser.parse(temp_dir)
|
||||||
|
assert manifest is not None
|
||||||
|
assert manifest["name"] == sample_manifest_json["name"]
|
||||||
|
|
||||||
|
def test_parse_nonexistent(self, parser, temp_dir):
|
||||||
|
"""Test parsing from nonexistent path."""
|
||||||
|
manifest = parser.parse(temp_dir / "nonexistent.xpi")
|
||||||
|
assert manifest is None
|
||||||
|
|
||||||
|
def test_extract_details(self, parser, sample_xpi, sample_manifest_json):
|
||||||
|
"""Test extracting details from manifest."""
|
||||||
|
manifest = parser.parse(sample_xpi)
|
||||||
|
details = parser.extract_details(sample_xpi, manifest)
|
||||||
|
|
||||||
|
assert details["name"] == "Test Addon"
|
||||||
|
assert details["version"] == "1.0.0"
|
||||||
|
assert details["id"] == "test@addon.com"
|
||||||
|
assert details["min_version"] == "6.0"
|
||||||
|
assert details["max_version"] == "7.*"
|
||||||
|
|
||||||
|
|
||||||
|
class TestXpiDetail:
|
||||||
|
"""Tests for XpiDetail model."""
|
||||||
|
|
||||||
|
def test_append_info(self):
|
||||||
|
"""Test appending info to XpiDetail."""
|
||||||
|
detail = XpiDetail()
|
||||||
|
detail.append_info({
|
||||||
|
"id": "test@addon.com",
|
||||||
|
"name": "Test Addon",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"min_version": "6.0",
|
||||||
|
"max_version": "7.*",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert detail.id == "test@addon.com"
|
||||||
|
assert detail.name == "Test Addon"
|
||||||
|
assert detail.version == "1.0.0"
|
||||||
|
assert detail.min_version == "6.0"
|
||||||
|
assert detail.max_version == "7.*"
|
||||||
|
|
||||||
|
def test_check_compatible_zotero7(self):
|
||||||
|
"""Test compatibility check for Zotero 7."""
|
||||||
|
detail = XpiDetail()
|
||||||
|
detail.min_version = "7.0"
|
||||||
|
detail.max_version = "7.*"
|
||||||
|
|
||||||
|
assert detail.check_compatible("7.*") is True
|
||||||
|
assert detail.check_compatible("6.*") is False
|
||||||
|
|
||||||
|
def test_check_compatible_both_versions(self):
|
||||||
|
"""Test compatibility check for both Zotero versions."""
|
||||||
|
detail = XpiDetail()
|
||||||
|
detail.min_version = "6.0"
|
||||||
|
detail.max_version = "7.*"
|
||||||
|
|
||||||
|
assert detail.check_compatible("7.*") is True
|
||||||
|
assert detail.check_compatible("6.*") is True
|
||||||
|
|
||||||
|
def test_is_valid(self):
|
||||||
|
"""Test validity check."""
|
||||||
|
detail = XpiDetail()
|
||||||
|
assert detail.is_valid() is False
|
||||||
|
|
||||||
|
detail.id = "test@addon.com"
|
||||||
|
assert detail.is_valid() is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestXPIParser:
|
||||||
|
"""Tests for XPIParser."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parser(self):
|
||||||
|
return XPIParser()
|
||||||
|
|
||||||
|
def test_parse_json_manifest(self, parser, sample_manifest_json, temp_dir):
|
||||||
|
"""Test parsing XPI with JSON manifest."""
|
||||||
|
xpi_path = temp_dir / "test.xpi"
|
||||||
|
with zipfile.ZipFile(xpi_path, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", json.dumps(sample_manifest_json))
|
||||||
|
|
||||||
|
details = parser.parse(xpi_path)
|
||||||
|
assert details is not None
|
||||||
|
assert details.id == "test@addon.com"
|
||||||
|
assert details.name == "Test Addon"
|
||||||
|
|
||||||
|
def test_parse_nonexistent_raises(self, parser, temp_dir):
|
||||||
|
"""Test parsing nonexistent file raises error."""
|
||||||
|
from zotero_scraper.exceptions import XPIParseError
|
||||||
|
|
||||||
|
with pytest.raises(XPIParseError):
|
||||||
|
parser.parse(temp_dir / "nonexistent.xpi")
|
||||||
|
|
||||||
|
def test_parse_priority_sources(self, parser, sample_manifest_json, temp_dir):
|
||||||
|
"""Test priority sources parameter."""
|
||||||
|
xpi_path = temp_dir / "test.xpi"
|
||||||
|
with zipfile.ZipFile(xpi_path, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", json.dumps(sample_manifest_json))
|
||||||
|
|
||||||
|
# Should work with json priority
|
||||||
|
details = parser.parse(xpi_path, priority_sources=["json"])
|
||||||
|
assert details is not None
|
||||||
|
assert details.id == "test@addon.com"
|
||||||
|
|
||||||
|
# Should return None with only rdf priority (no install.rdf)
|
||||||
|
details = parser.parse(xpi_path, priority_sources=["rdf"])
|
||||||
|
assert details is None
|
||||||
71
tests/unit/test_version.py
Normal file
71
tests/unit/test_version.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""Unit tests for version utilities."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from zotero_scraper.utils.version import compare_versions, version_in_range
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompareVersions:
|
||||||
|
"""Tests for compare_versions function."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"v1,v2,expected",
|
||||||
|
[
|
||||||
|
# Equal versions
|
||||||
|
("1.0.0", "1.0.0", 0),
|
||||||
|
("2.0", "2.0", 0),
|
||||||
|
("1", "1", 0),
|
||||||
|
# v1 > v2
|
||||||
|
("2.0.0", "1.0.0", 1),
|
||||||
|
("1.1.0", "1.0.0", 1),
|
||||||
|
("1.0.1", "1.0.0", 1),
|
||||||
|
("10.0.0", "9.0.0", 1),
|
||||||
|
("1.0.0", "0.9.9", 1),
|
||||||
|
# v1 < v2
|
||||||
|
("1.0.0", "2.0.0", -1),
|
||||||
|
("1.0.0", "1.1.0", -1),
|
||||||
|
("1.0.0", "1.0.1", -1),
|
||||||
|
("9.0.0", "10.0.0", -1),
|
||||||
|
# Different length versions
|
||||||
|
("1.0", "1.0.0", 0),
|
||||||
|
("1.0.0", "1.0", 0),
|
||||||
|
("1.0.1", "1.0", 1),
|
||||||
|
# Pre-release versions (using - separator)
|
||||||
|
("1.0.0-beta", "1.0.0-alpha", 1),
|
||||||
|
("1.0.0-alpha", "1.0.0-beta", -1),
|
||||||
|
# Wildcard versions
|
||||||
|
("7.*", "6.*", 1),
|
||||||
|
("6.*", "7.*", -1),
|
||||||
|
("7.*", "7.*", 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_compare_versions(self, v1: str, v2: str, expected: int):
|
||||||
|
"""Test version comparison."""
|
||||||
|
assert compare_versions(v1, v2) == expected
|
||||||
|
|
||||||
|
def test_compare_versions_with_int(self):
|
||||||
|
"""Test version comparison with integer input."""
|
||||||
|
assert compare_versions(1, "1.0.0") == 0
|
||||||
|
assert compare_versions("1.0.0", 1) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionInRange:
|
||||||
|
"""Tests for version_in_range function."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"version,min_ver,max_ver,expected",
|
||||||
|
[
|
||||||
|
("7.0", "6.0", "8.0", True),
|
||||||
|
("6.0", "6.0", "7.0", True),
|
||||||
|
("7.0", "6.0", "7.0", True),
|
||||||
|
("5.0", "6.0", "7.0", False),
|
||||||
|
("8.0", "6.0", "7.0", False),
|
||||||
|
("7.*", "6.*", "7.*", True),
|
||||||
|
("6.5", "6.*", "7.*", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_version_in_range(
|
||||||
|
self, version: str, min_ver: str, max_ver: str, expected: bool
|
||||||
|
):
|
||||||
|
"""Test version range checking."""
|
||||||
|
assert version_in_range(version, min_ver, max_ver) == expected
|
||||||
Loading…
Reference in New Issue
Block a user