forked from all_user/stat_center
204 lines
6.4 KiB
Python
204 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Mirror jump_tab.avatar images from http://yuan.bbaazz.me to AWS S3 (bucket: meirenyu).
|
|
|
|
Behaviour:
|
|
- reads bili_app.jump_tab from the source MongoDB
|
|
- for every doc whose `avatar` is on yuan.bbaazz.me, downloads the file and
|
|
uploads it to S3 using the same URL path as the object key
|
|
- DOES NOT modify MongoDB
|
|
- skips objects that already exist in the bucket (HEAD check)
|
|
|
|
Run:
|
|
python3 script/sync_jump_tab_avatar.py # dry-run, prints actions only
|
|
python3 script/sync_jump_tab_avatar.py --apply # actually download + upload
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import mimetypes
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
import boto3
|
|
import requests
|
|
from botocore.exceptions import ClientError
|
|
from pymongo import MongoClient
|
|
|
|
# ---------- config ---------------------------------------------------------
|
|
|
|
MONGO_URI = (
|
|
"mongodb://bili_app:SbKE9KN55fptDXeNaCDX@"
|
|
"172.104.52.130:27017,172.104.52.26:27017,172.104.52.242:27017/"
|
|
"bili_app?replicaSet=fish0&maxPoolSize=200&authSource=admin"
|
|
)
|
|
DB_NAME = "bili_app"
|
|
COLL = "jump_tab"
|
|
AVATAR_FIELD = "avatar"
|
|
|
|
SOURCE_HOST = "yuan.bbaazz.me"
|
|
SOURCE_BASE = f"http://{SOURCE_HOST}"
|
|
|
|
AWS_REGION = "ap-east-1"
|
|
AWS_ACCESS_KEY = "AKIA4FIZIQT3RUPZY3AR"
|
|
AWS_SECRET_KEY = "XbLUaXkW23/AGGy03bAM9XpZNQHg+bGmS+z+A3q1"
|
|
S3_BUCKET = "meirenyu"
|
|
|
|
HTTP_TIMEOUT = 30
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def avatar_to_key(avatar: str) -> str:
|
|
"""Return the S3 key for an avatar value.
|
|
|
|
Handles both relative paths ("v3/image/foo.png") and absolute URLs
|
|
("http://yuan.bbaazz.me/v3/image/foo.png").
|
|
"""
|
|
if not isinstance(avatar, str) or not avatar.strip():
|
|
return ""
|
|
s = avatar.strip()
|
|
if s.startswith("http://") or s.startswith("https://"):
|
|
return urlparse(s).path.lstrip("/")
|
|
return s.lstrip("/")
|
|
|
|
|
|
def avatar_to_source_url(avatar: str) -> str:
|
|
"""Return the URL to download from yuan.bbaazz.me."""
|
|
s = avatar.strip()
|
|
if s.startswith("http://") or s.startswith("https://"):
|
|
return s
|
|
return f"{SOURCE_BASE}/{s.lstrip('/')}"
|
|
|
|
|
|
def guess_content_type(url: str, fallback: str | None) -> str:
|
|
ct, _ = mimetypes.guess_type(url)
|
|
return ct or fallback or "application/octet-stream"
|
|
|
|
|
|
def s3_object_exists(s3, key: str) -> bool:
|
|
try:
|
|
s3.head_object(Bucket=S3_BUCKET, Key=key)
|
|
return True
|
|
except ClientError as e:
|
|
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey", "NotFound"):
|
|
return False
|
|
raise
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--apply", action="store_true",
|
|
help="actually perform downloads and uploads")
|
|
parser.add_argument("--limit", type=int, default=0,
|
|
help="process at most N matching docs (0 = no limit)")
|
|
parser.add_argument("--inspect", action="store_true",
|
|
help="print sample docs and host distribution, then exit")
|
|
args = parser.parse_args()
|
|
|
|
mc = MongoClient(MONGO_URI)
|
|
coll = mc[DB_NAME][COLL]
|
|
|
|
if args.inspect:
|
|
total = coll.estimated_document_count()
|
|
print(f"collection={DB_NAME}.{COLL} estimated_count={total}")
|
|
print("--- 3 sample docs ---")
|
|
for d in coll.find({}, limit=3):
|
|
print(d)
|
|
print("--- avatar field stats ---")
|
|
with_avatar = coll.count_documents({AVATAR_FIELD: {"$exists": True}})
|
|
print(f"docs with `{AVATAR_FIELD}` field: {with_avatar}")
|
|
# bucket hosts
|
|
from collections import Counter
|
|
hosts = Counter()
|
|
for d in coll.find({AVATAR_FIELD: {"$exists": True}},
|
|
{AVATAR_FIELD: 1}, limit=2000):
|
|
v = d.get(AVATAR_FIELD)
|
|
if isinstance(v, str) and v:
|
|
try:
|
|
h = urlparse(v).hostname or "(no-host)"
|
|
except ValueError:
|
|
h = "(parse-error)"
|
|
hosts[h] += 1
|
|
else:
|
|
hosts[f"({type(v).__name__})"] += 1
|
|
for h, n in hosts.most_common(20):
|
|
print(f" {n:>6} {h}")
|
|
mc.close()
|
|
return 0
|
|
|
|
s3 = boto3.client(
|
|
"s3",
|
|
region_name=AWS_REGION,
|
|
aws_access_key_id=AWS_ACCESS_KEY,
|
|
aws_secret_access_key=AWS_SECRET_KEY,
|
|
)
|
|
|
|
# Every doc with a non-empty avatar is a candidate (per user: store as relative path).
|
|
cursor = coll.find(
|
|
{AVATAR_FIELD: {"$exists": True, "$ne": ""}},
|
|
{AVATAR_FIELD: 1},
|
|
no_cursor_timeout=True,
|
|
)
|
|
|
|
seen, hit, uploaded, skipped, failed = 0, 0, 0, 0, 0
|
|
sess = requests.Session()
|
|
|
|
try:
|
|
for doc in cursor:
|
|
seen += 1
|
|
avatar = doc.get(AVATAR_FIELD, "")
|
|
key = avatar_to_key(avatar)
|
|
if not key:
|
|
continue
|
|
hit += 1
|
|
src = avatar_to_source_url(avatar)
|
|
|
|
if args.apply and s3_object_exists(s3, key):
|
|
print(f"[exists] s3://{S3_BUCKET}/{key}")
|
|
skipped += 1
|
|
if args.limit and hit >= args.limit:
|
|
break
|
|
continue
|
|
|
|
if not args.apply:
|
|
print(f"[dry-run] {src} -> s3://{S3_BUCKET}/{key}")
|
|
if args.limit and hit >= args.limit:
|
|
break
|
|
continue
|
|
|
|
try:
|
|
r = sess.get(src, timeout=HTTP_TIMEOUT, stream=True)
|
|
r.raise_for_status()
|
|
ctype = guess_content_type(src, r.headers.get("Content-Type"))
|
|
body = r.content
|
|
s3.put_object(
|
|
Bucket=S3_BUCKET,
|
|
Key=key,
|
|
Body=body,
|
|
ContentType=ctype,
|
|
)
|
|
uploaded += 1
|
|
print(f"[ok] {src} -> s3://{S3_BUCKET}/{key} "
|
|
f"({len(body)} bytes, {ctype})")
|
|
except Exception as e:
|
|
failed += 1
|
|
print(f"[fail] {src}: {e}", file=sys.stderr)
|
|
|
|
if args.limit and hit >= args.limit:
|
|
break
|
|
finally:
|
|
cursor.close()
|
|
mc.close()
|
|
|
|
print()
|
|
print(f"scanned={seen} matched={hit} uploaded={uploaded} "
|
|
f"skipped={skipped} failed={failed} "
|
|
f"mode={'APPLY' if args.apply else 'DRY-RUN'}")
|
|
return 0 if failed == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|