feat(scripts): 添加 YouTube Studio 群组名到 entity_id 批量解析脚本
支持 Playwright 自动捕获鉴权、requests 回放查询、Excel/CSV 输出,包含在线捕获和离线回放两种模式
This commit is contained in:
@@ -7,6 +7,7 @@ dependencies = [
|
||||
"pandas>=2.0",
|
||||
"openpyxl>=3.1",
|
||||
"playwright>=1.40",
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
781
scripts/lookup_groups.py
Normal file
781
scripts/lookup_groups.py
Normal file
@@ -0,0 +1,781 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本
|
||||
(Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
|
||||
|
||||
## 已实证的查询机制(与 yt-studio-group-id-lookup skill 一致)
|
||||
|
||||
1. 页面自身的内部接口:
|
||||
POST https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json
|
||||
请求体 `query` 字段为搜索词,响应形如:
|
||||
{ "groupDatas": [ { "displayName": "...", "groupId": "..." } ] }
|
||||
`groupId` 即 explore URL 里的 `entity_id`。
|
||||
|
||||
2. 手写请求必 401:鉴权依赖页面 JS 逐请求计算的
|
||||
`Authorization: SAPISIDHASH...`、锁定查询所有者的 `X-YouTube-Delegation-Context`,
|
||||
以及浏览器自动携带的 Cookie。唯一可靠路径是
|
||||
「捕获页面自己的请求 -> 原样回放,只改 query」。
|
||||
|
||||
3. 本脚本用 Playwright 打开所有者分析页并监听 `search_groups` 响应:
|
||||
先尝试自动触发一次搜索(选择器猜不中时提示在浏览器里手动搜一次,任意词即可),
|
||||
从该请求拿到完整套件(全部请求头含 Cookie + 请求体模板),随后在 Python 侧
|
||||
并发回放全量名单。多所有者时逐个页面各捕一份套件,首个精确命中即停。
|
||||
|
||||
## 使用前提
|
||||
- 依赖:playwright、requests、openpyxl(项目环境 `uv sync` 后 `uv run python ...`)。
|
||||
- 必须复用「已登录 YouTube Studio」的浏览器会话(同 youtube_export_interceptor.py):
|
||||
- `--user-data-dir` + `--channel chrome|msedge`:用已登录用户数据目录启动(需先关闭该浏览器)
|
||||
- `--connect http://localhost:9222`:附加到已在调试端口运行的浏览器
|
||||
(先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222`)
|
||||
- 待查群组名单(`--names`):json / txt / csv / xlsx 均可。
|
||||
|
||||
## 用法
|
||||
# 0) 纯逻辑自测(无需浏览器/网络/第三方库)
|
||||
python lookup_groups.py --selftest
|
||||
|
||||
# 1) 在线模式:打开所有者页面 -> 捕获套件 -> 回放全量名单 -> 输出 Excel
|
||||
python lookup_groups.py --url "<owner analytics URL>" --names 名单.json ^
|
||||
--channel chrome --user-data-dir "%LOCALAPPDATA%\Google\Chrome\User Data"
|
||||
python lookup_groups.py --url "<owner1 URL>" --url "<owner2 URL>" --names 名单.xlsx ^
|
||||
--connect http://localhost:9222 --save-bundles bundles.json
|
||||
|
||||
# 2) 离线回放:套件已存盘(--save-bundles 产物),无需浏览器
|
||||
python lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx
|
||||
|
||||
套件 bundles.json(每个所有者一个对象,`--save-bundles` 自动生成,也可手工维护):
|
||||
{ "ownerId": "...", "ownerDisplay": "...", "url": "...",
|
||||
"headers": { "Authorization": "SAPISIDHASH ...", "Cookie": "...",
|
||||
"X-YouTube-Delegation-Context": "...", "...": "..." },
|
||||
"bodyTemplate": { "...": "...", "query": "" } }
|
||||
|
||||
名单 names 支持的格式:
|
||||
json : ["名字1", "名字2"] 或 [["GROUP_NAME"], ["名字1"]] 或 {"names": [...]}
|
||||
txt : 每行一个名字
|
||||
csv/xlsx: 自动识别 群组名称 / 群组 / group_name / GROUP_NAME / 实体名称 / 名称 列
|
||||
|
||||
输出(默认 group_entity_id_result.xlsx,缺 openpyxl 自动回退 csv):
|
||||
group_name / ownerid / owner_display / groupid / 备注
|
||||
多所有者按顺序逐个尝试,首个精确命中即停;全部未命中把候选写进备注便于人工复核。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
ENDPOINT = "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json"
|
||||
SEARCH_GROUPS_PATH = "/youtubei/v1/yta_web/search_groups"
|
||||
|
||||
OWNER_PATH_RE = re.compile(r"studio\.youtube\.com/owner/([A-Za-z0-9_-]+)")
|
||||
OWNER_QUERY_RE = re.compile(r"[?&]o=([A-Za-z0-9_-]+)")
|
||||
|
||||
# 回放时剥离的请求头:长度/传输类由 requests 自行计算,accept-encoding 防止收到解不开的 br。
|
||||
STRIP_HEADERS = {
|
||||
"content-length", "host", "connection", "keep-alive",
|
||||
"transfer-encoding", "accept-encoding", "content-encoding",
|
||||
}
|
||||
|
||||
HTTP_HINTS = {
|
||||
401: "(鉴权失败:套件缺 Authorization/Cookie 或已过期,请重新捕获)",
|
||||
403: "(无权限或 delegation 语境不符)",
|
||||
429: "(限流:调低 --max-workers 或稍后重试)",
|
||||
}
|
||||
|
||||
REQUEST_TIMEOUT = 30
|
||||
MANUAL_WAIT_SECONDS = 180
|
||||
|
||||
RESULT_HEADER = ["group_name", "ownerid", "owner_display", "groupid", "备注"]
|
||||
|
||||
# 名单列名别名(规范化为 strip+lower 后比较)
|
||||
NAME_COLUMN_ALIASES = {
|
||||
"group_name", "groupname", "群组名称", "群组", "group", "name", "名称",
|
||||
"实体名称", "group name",
|
||||
}
|
||||
|
||||
USAGE_HINT = (
|
||||
"\n实际运行需先 uv sync(或 pip install playwright requests openpyxl),"
|
||||
"并复用已登录 YouTube Studio 的浏览器会话(二选一):\n"
|
||||
' 方式 A: python lookup_groups.py --url "<owner analytics URL>" --names 名单.json '
|
||||
'--channel chrome --user-data-dir "%LOCALAPPDATA%\\Google\\Chrome\\User Data"\n'
|
||||
" 方式 B: 先 chrome.exe --remote-debugging-port=9222,再\n"
|
||||
' python lookup_groups.py --url "<owner analytics URL>" --names 名单.json '
|
||||
"--connect http://localhost:9222\n"
|
||||
" 离线回放(套件已存盘): python lookup_groups.py --bundles bundles.json --names 名单.json\n"
|
||||
"多所有者: --url 可重复多次,每个所有者页面各触发一次搜索即可。"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 纯逻辑:URL / 名单解析 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def parse_owner_id(url: str) -> str:
|
||||
"""从 owner 分析页 URL 提取 ownerId(路径 /owner/<id>/ 优先,其次 ?o=<id>)。"""
|
||||
m = OWNER_PATH_RE.search(url or "")
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = OWNER_QUERY_RE.search(url or "")
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def _norm_key(s) -> str:
|
||||
return str(s).strip().lower()
|
||||
|
||||
|
||||
def parse_names_payload(data) -> list:
|
||||
"""json 结构 -> 名字列表(纯列表 / 带表头二维列表 / {"names": [...]}),保序去重。"""
|
||||
if isinstance(data, dict):
|
||||
for key in ("names", "group_names", "groups", "group_name"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
else:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
names: list = []
|
||||
seen = set()
|
||||
for item in data:
|
||||
if isinstance(item, (list, tuple)):
|
||||
if not item:
|
||||
continue
|
||||
value = str(item[0]).strip()
|
||||
if _norm_key(value) in NAME_COLUMN_ALIASES: # 表头行
|
||||
continue
|
||||
else:
|
||||
value = str(item).strip()
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
names.append(value)
|
||||
return names
|
||||
|
||||
|
||||
def _names_from_rows(rows: list) -> list:
|
||||
"""二维行(含表头行)-> 名字列表;自动找别名列,单列无表头全当名字。"""
|
||||
if not rows:
|
||||
return []
|
||||
first = rows[0]
|
||||
alias_col = None
|
||||
for idx, cell in enumerate(first):
|
||||
if _norm_key(cell) in NAME_COLUMN_ALIASES:
|
||||
alias_col = idx
|
||||
break
|
||||
if alias_col is not None:
|
||||
data_rows = rows[1:]
|
||||
|
||||
def pick(r):
|
||||
return r[alias_col] if alias_col < len(r) else ""
|
||||
elif len(first) == 1:
|
||||
data_rows = rows # 单列且首行不是表头 -> 全部当名字
|
||||
|
||||
def pick(r):
|
||||
return r[0] if r else ""
|
||||
else:
|
||||
raise ValueError(
|
||||
f"无法识别名字列,第一行: {first}(表头用 群组名称/group_name,或改为单列文件)")
|
||||
|
||||
names, seen = [], set()
|
||||
for r in data_rows:
|
||||
v = str(pick(r)).strip()
|
||||
if v and _norm_key(v) not in ("nan", "none") and v not in seen:
|
||||
seen.add(v)
|
||||
names.append(v)
|
||||
return names
|
||||
|
||||
|
||||
def load_names(path: str) -> list:
|
||||
"""按扩展名加载名单:json / txt / csv / xlsx。"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".json":
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return parse_names_payload(json.load(f))
|
||||
if ext in (".xlsx", ".xls"):
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel(path, dtype=str)
|
||||
rows = [[str(c) for c in df.columns]]
|
||||
rows += [["" if v is None else str(v) for v in rec]
|
||||
for rec in df.itertuples(index=False)]
|
||||
return _names_from_rows(rows)
|
||||
# txt / csv 统一按 csv 解析(txt 每行一个名字天然兼容)
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f) if any(c.strip() for c in r)]
|
||||
return _names_from_rows(rows)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 纯逻辑:请求构造 / 匹配 / 回放 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_body(body_template: dict, query: str) -> dict:
|
||||
"""深拷贝模板并只改 query(其余字段一律不动,否则查错所有者或 401)。"""
|
||||
body = json.loads(json.dumps(body_template or {}))
|
||||
body["query"] = query
|
||||
return body
|
||||
|
||||
|
||||
def pick_match(group_datas: list, name: str):
|
||||
"""精确 displayName 命中 -> (groupId, []);否则 (None, 前 5 个候选 '名=ID')。"""
|
||||
for g in group_datas:
|
||||
if g.get("displayName") == name:
|
||||
return g.get("groupId"), []
|
||||
cands = [f"{g.get('displayName')}={g.get('groupId')}" for g in group_datas[:5]]
|
||||
return None, cands
|
||||
|
||||
|
||||
def _external_owner_id(body: dict) -> str:
|
||||
"""取请求体 context.user.delegationContext.externalOwnerId(锁定查询所有者)。"""
|
||||
try:
|
||||
return body["context"]["user"]["delegationContext"]["externalOwnerId"]
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
_TLS = threading.local()
|
||||
|
||||
|
||||
def _thread_session():
|
||||
"""线程本地 requests.Session(延迟导入:selftest/单测无需安装 requests)。"""
|
||||
import requests
|
||||
|
||||
s = getattr(_TLS, "session", None)
|
||||
if s is None:
|
||||
s = _TLS.session = requests.Session()
|
||||
return s
|
||||
|
||||
|
||||
def search(bundle: dict, name: str, session=None) -> dict:
|
||||
"""对单个所有者套件回放一次 search_groups,只改 query。
|
||||
|
||||
session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。
|
||||
"""
|
||||
headers = {k: v for k, v in (bundle.get("headers") or {}).items()
|
||||
if str(k).lower() not in STRIP_HEADERS}
|
||||
body = build_body(bundle.get("bodyTemplate") or {}, name)
|
||||
url = bundle.get("url") or ENDPOINT
|
||||
try:
|
||||
if session is None:
|
||||
session = _thread_session()
|
||||
r = session.post(url, json=body, headers=headers, timeout=REQUEST_TIMEOUT)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "ERR", "detail": str(e)}
|
||||
|
||||
code = getattr(r, "status_code", 0)
|
||||
if code != 200:
|
||||
return {"status": "HTTP", "detail": code, "hint": HTTP_HINTS.get(code, "")}
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "ERR", "detail": f"响应非 JSON: {e}"}
|
||||
|
||||
group_id, cands = pick_match(data.get("groupDatas") or [], name)
|
||||
if group_id:
|
||||
return {"status": "OK", "groupId": group_id}
|
||||
return {"status": "NF", "cands": cands}
|
||||
|
||||
|
||||
def resolve(name: str, bundles: list, session=None) -> list:
|
||||
"""多所有者逐个尝试,首个精确命中即停并记录 ownerid,未命中收集各所有者线索。"""
|
||||
notes = []
|
||||
for b in bundles:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
owner_id = b.get("ownerId", "")
|
||||
owner = b.get("ownerDisplay", "")
|
||||
r = search(b, name, session)
|
||||
if r["status"] == "OK":
|
||||
note = "精确命中" if not notes else "精确命中;此前 " + "; ".join(notes)
|
||||
return [name, owner_id, owner, r["groupId"], note]
|
||||
if r["status"] == "HTTP":
|
||||
msg = f"{owner}({owner_id}) 返回 HTTP{r['detail']}{r.get('hint', '')}"
|
||||
notes.append(msg)
|
||||
elif r["status"] == "ERR":
|
||||
notes.append(f"{owner}({owner_id}) 网络错: {r['detail'][:60]}")
|
||||
elif r["status"] == "NF":
|
||||
cands = r.get("cands") or []
|
||||
notes.append(
|
||||
f"{owner}({owner_id}) 候选: {', '.join(cands)}" if cands
|
||||
else f"{owner}({owner_id}) 无结果")
|
||||
return [name, "", "", "", "未匹配任何 owner;" + " | ".join(notes)]
|
||||
|
||||
|
||||
def validate_bundle(bundle: dict) -> list:
|
||||
"""校验套件完整性,返回问题列表(鉴权头缺失是 401 的头号根因,提前点破)。"""
|
||||
problems = []
|
||||
headers = {_norm_key(k): v for k, v in (bundle.get("headers") or {}).items()}
|
||||
if not headers:
|
||||
problems.append("headers 为空,回放必 401")
|
||||
else:
|
||||
for key, desc in (
|
||||
("authorization", "Authorization(SAPISIDHASH)"),
|
||||
("cookie", "Cookie"),
|
||||
("x-youtube-delegation-context", "X-YouTube-Delegation-Context"),
|
||||
):
|
||||
if not headers.get(key):
|
||||
problems.append(f"缺少 {desc} 头,回放大概率 401 或查错所有者")
|
||||
body = bundle.get("bodyTemplate") or {}
|
||||
if not body:
|
||||
problems.append("bodyTemplate 为空")
|
||||
elif not _external_owner_id(body):
|
||||
problems.append("bodyTemplate 缺 context.user.delegationContext,可能查错所有者")
|
||||
return problems
|
||||
|
||||
|
||||
def merge_bundles(existing: list, captured: list) -> list:
|
||||
"""合并套件:captured 按 ownerId 覆盖同名项,其余按原序保留。"""
|
||||
by_owner: dict = {}
|
||||
for b in existing:
|
||||
if isinstance(b, dict) and b.get("ownerId"):
|
||||
by_owner[b["ownerId"]] = b
|
||||
for b in captured:
|
||||
if isinstance(b, dict) and b.get("ownerId"):
|
||||
by_owner[b["ownerId"]] = b
|
||||
return list(by_owner.values())
|
||||
|
||||
|
||||
def write_out(path: str, header: list, rows: list) -> str:
|
||||
"""写结果文件:.xlsx 优先 openpyxl,缺库自动回退 .csv。返回实际写入路径。"""
|
||||
if path.lower().endswith(".xlsx"):
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
print("[i] 缺少 openpyxl,回退为 csv 输出")
|
||||
path = path[:-5] + ".csv"
|
||||
else:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "result"
|
||||
ws.append(header)
|
||||
for r in rows:
|
||||
ws.append(r)
|
||||
for col, w in zip("ABCDE", (28, 22, 24, 22, 60)):
|
||||
ws.column_dimensions[col].width = w
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(header)
|
||||
w.writerows(rows)
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 浏览器侧:自动捕获套件 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
AUTO_SEARCH_SELECTORS = (
|
||||
"input[placeholder*='搜索']",
|
||||
"input[aria-label*='搜索']",
|
||||
"input[placeholder*='Search']",
|
||||
"input[aria-label*='Search']",
|
||||
"ytcp-analytics-filter-bar input",
|
||||
"ytcp-text-input input",
|
||||
)
|
||||
|
||||
OWNER_DISPLAY_SELECTORS = (
|
||||
"ytcp-account-item button",
|
||||
".account-switcher button",
|
||||
"#owner-name",
|
||||
)
|
||||
|
||||
|
||||
def _launch_page(p, user_data_dir, channel, cdp_url):
|
||||
"""按 interceptor 同款三种方式拿到 (browser, context, page)。"""
|
||||
if cdp_url:
|
||||
browser = p.chromium.connect_over_cdp(cdp_url)
|
||||
context = browser.contexts[0] if browser.contexts else browser.new_context()
|
||||
return browser, context, context.new_page()
|
||||
if user_data_dir:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=user_data_dir,
|
||||
channel=channel,
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
return None, context, context.new_page()
|
||||
browser = p.chromium.launch(headless=False, channel=channel)
|
||||
context = browser.new_context()
|
||||
return browser, context, context.new_page()
|
||||
|
||||
|
||||
def _try_auto_search(page, probe="a"):
|
||||
"""尽力自动触发一次搜索(选择器猜不中就交回手动流程,结果无关紧要)。"""
|
||||
for sel in AUTO_SEARCH_SELECTORS:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
loc.wait_for(state="visible", timeout=1500)
|
||||
loc.fill(probe)
|
||||
try:
|
||||
loc.press("Enter")
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(1500)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _read_owner_display(page):
|
||||
"""尽力读所有者/账号显示名(仅用于结果标注,读不到不影响功能)。"""
|
||||
for sel in OWNER_DISPLAY_SELECTORS:
|
||||
try:
|
||||
text = page.locator(sel).first.inner_text(timeout=2000).strip()
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def _capture_bundle(page, url, wait_seconds, owner_display=""):
|
||||
"""打开一个所有者分析页,捕获一次 search_groups 请求 -> 完整套件。"""
|
||||
owner_id = parse_owner_id(url)
|
||||
print(f"[捕获] 打开所有者页面: {owner_display or owner_id or url}")
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[捕获] 页面加载较慢({type(e).__name__}),继续等待搜索……", file=sys.stderr)
|
||||
|
||||
if "accounts.google" in page.url:
|
||||
raise SystemExit(
|
||||
"[!] 当前会话未登录(跳转到 Google 登录页)。请用 --user-data-dir 或 "
|
||||
"--connect 复用已登录浏览器后重试。")
|
||||
|
||||
captured = []
|
||||
|
||||
def on_response(resp):
|
||||
try:
|
||||
if SEARCH_GROUPS_PATH in resp.url:
|
||||
captured.append(resp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
try:
|
||||
if _try_auto_search(page):
|
||||
print("[捕获] 已尝试自动触发一次群组搜索……")
|
||||
# 自动触发后先安静等 5 秒;仍无请求再提示手动搜索
|
||||
quiet = time.time() + 5
|
||||
while not captured and time.time() < quiet:
|
||||
page.wait_for_timeout(500)
|
||||
deadline = time.time() + wait_seconds
|
||||
notified = False
|
||||
last_report = time.time()
|
||||
while not captured and time.time() < deadline:
|
||||
if not notified:
|
||||
print(f"[捕获] 请在打开的浏览器窗口中,于该所有者分析页顶部的搜索/筛选框"
|
||||
f"输入任意词并回车(只需触发一次搜索,结果无所谓)。"
|
||||
f"最长等待 {wait_seconds} 秒……")
|
||||
notified = True
|
||||
page.wait_for_timeout(1000)
|
||||
if time.time() - last_report >= 30:
|
||||
print(f"[捕获] 仍在等待手动搜索……剩余 {int(deadline - time.time())} 秒")
|
||||
last_report = time.time()
|
||||
if not captured:
|
||||
raise TimeoutError(
|
||||
f"{wait_seconds} 秒内未捕获到 search_groups 请求。"
|
||||
"请确认页面是高级模式分析页且搜索框可用;若页面被重定向到登录页,"
|
||||
"请改用 --user-data-dir / --connect 复用已登录会话后重试。")
|
||||
finally:
|
||||
try:
|
||||
page.remove_listener("response", on_response)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resp = captured[-1] # 取最近一次(SAPISIDHASH 时间戳最新)
|
||||
req = resp.request
|
||||
try:
|
||||
headers = dict(req.all_headers())
|
||||
except Exception: # noqa: BLE001
|
||||
headers = dict(req.headers)
|
||||
try:
|
||||
body = json.loads(req.post_data or "{}")
|
||||
except Exception: # noqa: BLE001
|
||||
body = {}
|
||||
|
||||
ext_owner = _external_owner_id(body)
|
||||
if ext_owner and owner_id and ext_owner != owner_id:
|
||||
print(f"[捕获] 警告:请求体 externalOwnerId={ext_owner} 与 URL ownerId={owner_id} "
|
||||
"不一致,以请求体为准。", file=sys.stderr)
|
||||
owner_id = ext_owner
|
||||
owner_id = owner_id or ext_owner
|
||||
if not owner_id:
|
||||
raise ValueError("无法确定 ownerId(URL 非 /owner/<id>/ 且请求体无 delegationContext)")
|
||||
|
||||
bundle = {
|
||||
"ownerId": owner_id,
|
||||
"ownerDisplay": owner_display or _read_owner_display(page),
|
||||
"url": req.url,
|
||||
"headers": headers,
|
||||
"bodyTemplate": body,
|
||||
"capturedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
for pb in validate_bundle(bundle):
|
||||
print(f"[捕获] 警告:{pb}", file=sys.stderr)
|
||||
print(f"[捕获] 套件就绪:owner={bundle['ownerDisplay'] or owner_id} ({owner_id}),"
|
||||
f"headers={len(headers)} 项,请求体模板已取得")
|
||||
return bundle
|
||||
|
||||
|
||||
def capture_bundles(urls, user_data_dir=None, channel=None, cdp_url=None,
|
||||
owner_display="", wait_seconds=MANUAL_WAIT_SECONDS):
|
||||
"""打开浏览器,逐个所有者捕获套件(同一个浏览器会话依次 goto)。"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
raise SystemExit("[!] 缺少 playwright:uv sync 或 pip install playwright 后重试。")
|
||||
|
||||
mode = "CDP 附加" if cdp_url else ("用户数据目录" if user_data_dir else "全新会话(大概率未登录)")
|
||||
print(f"[捕获] 浏览器会话:{mode}")
|
||||
|
||||
bundles = []
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser, context, page = _launch_page(p, user_data_dir, channel, cdp_url)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SystemExit(
|
||||
f"[!] 启动/连接浏览器失败: {e}\n"
|
||||
" 方式 A 需先关闭对应浏览器;方式 B 先以调试端口启动:\n"
|
||||
" chrome.exe --remote-debugging-port=9222")
|
||||
try:
|
||||
for i, url in enumerate(urls, 1):
|
||||
print(f"[捕获] 所有者 {i}/{len(urls)}")
|
||||
display = owner_display if len(urls) == 1 else ""
|
||||
bundles.append(_capture_bundle(page, url, wait_seconds, display))
|
||||
finally:
|
||||
if browser is not None:
|
||||
browser.close()
|
||||
elif context is not None:
|
||||
context.close()
|
||||
return bundles
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 自测(无需浏览器/网络/第三方库,仅标准库) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _FakeResp:
|
||||
def __init__(self, code, payload=None):
|
||||
self.status_code = code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""按 `模板marker::query` 路由响应的测试替身,可区分不同所有者的套件。"""
|
||||
|
||||
def __init__(self, routing):
|
||||
self.routing = routing
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
self.calls.append((url, json, headers))
|
||||
body = json or {}
|
||||
key = f"{body.get('marker', '')}::{body.get('query', '')}"
|
||||
item = self.routing.get(key)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
if item is not None:
|
||||
return item
|
||||
return _FakeResp(200, {"groupDatas": []})
|
||||
|
||||
|
||||
def selftest():
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 1) ownerId 解析(路径优先,其次 ?o=)
|
||||
url = "https://studio.youtube.com/owner/bqSUnNpU67xJ51TxH4PKpQ/analytics?o=bqSUnNpU67xJ51TxH4PKpQ"
|
||||
assert parse_owner_id(url) == "bqSUnNpU67xJ51TxH4PKpQ"
|
||||
assert parse_owner_id("https://studio.youtube.com/analytics?o=AbCdEf123") == "AbCdEf123"
|
||||
assert parse_owner_id("https://studio.youtube.com/") == ""
|
||||
|
||||
# 2) 名单解析:json 两种格式 + dict + txt + csv(表头/单列/多列别名)
|
||||
assert parse_names_payload(["A", "B", "A "]) == ["A", "B"]
|
||||
assert parse_names_payload([["GROUP_NAME"], ["A"], ["B"]]) == ["A", "B"]
|
||||
assert parse_names_payload([["群组名称", "备注"], ["A", "x"], ["B", "y"]]) == ["A", "B"]
|
||||
assert parse_names_payload({"names": ["A", "B"]}) == ["A", "B"]
|
||||
p_txt = Path(td) / "names.txt"
|
||||
p_txt.write_text("A\n B \n\nA\n", encoding="utf-8")
|
||||
assert load_names(str(p_txt)) == ["A", "B"]
|
||||
p_csv = Path(td) / "names.csv"
|
||||
p_csv.write_text("群组名称,备注\nA,x\nB,\n", encoding="utf-8-sig")
|
||||
assert load_names(str(p_csv)) == ["A", "B"]
|
||||
p_single = Path(td) / "single.csv"
|
||||
p_single.write_text("A\nB\n", encoding="utf-8")
|
||||
assert load_names(str(p_single)) == ["A", "B"]
|
||||
|
||||
# 3) 请求体构造:深拷贝不污染模板,只改 query
|
||||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||||
"query": ""}
|
||||
body = build_body(tpl, "靓舟桃")
|
||||
assert body["query"] == "靓舟桃" and tpl["query"] == ""
|
||||
|
||||
# 4) 匹配逻辑:精确命中 / 候选
|
||||
gd = [{"displayName": "X 漫剧-1", "groupId": "G1"},
|
||||
{"displayName": "X 漫剧-2", "groupId": "G2"}]
|
||||
assert pick_match(gd, "X 漫剧-2") == ("G2", [])
|
||||
gid, cands = pick_match(gd, "X 漫剧")
|
||||
assert gid is None and cands == ["X 漫剧-1=G1", "X 漫剧-2=G2"]
|
||||
|
||||
# 5) 回放:命中 / 未命中候选 / HTTP 提示 / 剥离长度类头
|
||||
# (模板里放一个 marker 字段,让替身能区分不同所有者的套件)
|
||||
bundle = {
|
||||
"ownerId": "O1", "ownerDisplay": "Owner One", "url": ENDPOINT,
|
||||
"headers": {"Authorization": "SAPISIDHASH x", "Cookie": "SID=1",
|
||||
"X-YouTube-Delegation-Context": "ctx",
|
||||
"Content-Length": "3", "Host": "studio.youtube.com"},
|
||||
"bodyTemplate": {**tpl, "marker": "O1"},
|
||||
}
|
||||
sess = _FakeSession({
|
||||
"O1::命中": _FakeResp(200, {"groupDatas": [{"displayName": "命中", "groupId": "G9"}]}),
|
||||
"O1::疑似": _FakeResp(200, {"groupDatas": gd}),
|
||||
"O1::限流": _FakeResp(429),
|
||||
})
|
||||
assert search(bundle, "命中", sess) == {"status": "OK", "groupId": "G9"}
|
||||
r = search(bundle, "疑似", sess)
|
||||
assert r["status"] == "NF" and "X 漫剧-1=G1" in r["cands"]
|
||||
r = search(bundle, "限流", sess)
|
||||
assert r["status"] == "HTTP" and "限流" in r["hint"]
|
||||
sent_headers = sess.calls[-1][2]
|
||||
assert "Content-Length" not in sent_headers and "Host" not in sent_headers
|
||||
assert sent_headers["Authorization"] == "SAPISIDHASH x"
|
||||
assert search(bundle, "断网", _FakeSession({"O1::断网": OSError("refused")}))["status"] == "ERR"
|
||||
|
||||
# 6) 多所有者归并:先 NF 后命中 / 全未命中备注
|
||||
b1 = dict(bundle, ownerId="O1", ownerDisplay="一号")
|
||||
b2 = dict(bundle, ownerId="O2", ownerDisplay="二号",
|
||||
bodyTemplate={**tpl, "marker": "O2"})
|
||||
sess2 = _FakeSession({
|
||||
"O2::跨主": _FakeResp(200, {"groupDatas": [{"displayName": "跨主", "groupId": "G2"}]}),
|
||||
})
|
||||
row = resolve("跨主", [b1, b2], sess2)
|
||||
assert row == ["跨主", "O2", "二号", "G2",
|
||||
"精确命中;此前 一号(O1) 无结果"]
|
||||
row = resolve("查无", [b1], sess2)
|
||||
assert row[3] == "" and "未匹配任何 owner" in row[4]
|
||||
|
||||
# 7) 套件校验与合并
|
||||
problems = validate_bundle({"headers": {"Authorization": "x"},
|
||||
"bodyTemplate": tpl})
|
||||
assert any("Cookie" in p for p in problems)
|
||||
merged = merge_bundles([{"ownerId": "O1", "v": 1}],
|
||||
[{"ownerId": "O1", "v": 2}, {"ownerId": "O2", "v": 3}])
|
||||
assert [b["ownerId"] for b in merged] == ["O1", "O2"] and merged[0]["v"] == 2
|
||||
|
||||
# 8) 输出:csv 路径直写(xlsx 分支见单元测试)
|
||||
out = Path(td) / "result.csv"
|
||||
path = write_out(str(out), RESULT_HEADER, [row])
|
||||
text = out.read_text(encoding="utf-8-sig")
|
||||
assert "group_name,ownerid,owner_display,groupid,备注" in text and "查无" in text
|
||||
assert path == str(out)
|
||||
|
||||
print("selftest OK:URL/名单解析、请求体、匹配、回放、多所有者、校验、输出 全部通过")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="YouTube Studio 群组名 -> entity_id 批量解析(捕获套件 + 回放)")
|
||||
ap.add_argument("--selftest", action="store_true", help="仅跑纯逻辑自测(无需浏览器/网络)")
|
||||
ap.add_argument("--url", action="append", default=[], metavar="OWNER_URL",
|
||||
help="所有者分析页 URL,可重复多次(多所有者)")
|
||||
ap.add_argument("--names", help="群组名单:json / txt / csv / xlsx")
|
||||
ap.add_argument("--out", default="group_entity_id_result.xlsx",
|
||||
help="输出文件(.xlsx 或 .csv),默认 %(default)s")
|
||||
ap.add_argument("--bundles", help="已有套件 bundles.json(离线回放,或与 --url 捕获结果合并)")
|
||||
ap.add_argument("--save-bundles", metavar="PATH",
|
||||
help="把套件存到该 json(含 --bundles 读入的),供下次离线回放")
|
||||
ap.add_argument("--max-workers", type=int, default=8, help="并发线程数,默认 %(default)s")
|
||||
ap.add_argument("--connect", help="通过 CDP 附加到已打开浏览器,如 http://localhost:9222")
|
||||
ap.add_argument("--user-data-dir",
|
||||
help="浏览器用户数据目录(复用登录态,需先关闭该浏览器)")
|
||||
ap.add_argument("--channel", choices=["chrome", "msedge"],
|
||||
help="浏览器品牌(--user-data-dir 方式必填其一)")
|
||||
ap.add_argument("--owner-display", help="所有者显示名(可选,仅单个 --url 时用于结果标注)")
|
||||
ap.add_argument("--wait-seconds", type=int, default=MANUAL_WAIT_SECONDS,
|
||||
help="等待手动触发搜索的最长秒数,默认 %(default)s")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return
|
||||
if not args.url and not args.bundles:
|
||||
selftest()
|
||||
print(USAGE_HINT)
|
||||
return
|
||||
|
||||
bundles = []
|
||||
if args.bundles:
|
||||
try:
|
||||
with open(args.bundles, encoding="utf-8") as f:
|
||||
file_bundles = json.load(f)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SystemExit(f"[!] 读取 bundles 失败: {e}")
|
||||
bundles = [b for b in file_bundles if isinstance(b, dict)]
|
||||
for b in bundles:
|
||||
for pb in validate_bundle(b):
|
||||
print(f"[套件] 警告 {b.get('ownerId', '?')}: {pb}", file=sys.stderr)
|
||||
|
||||
if args.url:
|
||||
captured = capture_bundles(
|
||||
args.url, user_data_dir=args.user_data_dir, channel=args.channel,
|
||||
cdp_url=args.connect, owner_display=args.owner_display or "",
|
||||
wait_seconds=args.wait_seconds)
|
||||
bundles = merge_bundles(bundles, captured)
|
||||
if args.save_bundles:
|
||||
with open(args.save_bundles, "w", encoding="utf-8") as f:
|
||||
json.dump(bundles, f, ensure_ascii=False, indent=2)
|
||||
print(f"[套件] 已保存 {len(bundles)} 个所有者套件 -> {args.save_bundles}")
|
||||
|
||||
if not bundles:
|
||||
raise SystemExit("[!] 没有可用套件:请提供 --url 在线捕获,或 --bundles 离线回放。")
|
||||
|
||||
if not args.names:
|
||||
print("[i] 未提供 --names:仅完成套件捕获/校验,不执行查询。")
|
||||
return
|
||||
|
||||
try:
|
||||
names = load_names(args.names)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SystemExit(f"[!] 名单读取失败: {e}")
|
||||
if not names:
|
||||
raise SystemExit(f"[!] 名单为空或无法解析:{args.names}"
|
||||
"(支持 json/txt/csv/xlsx,列名用 群组名称/group_name)")
|
||||
|
||||
print(f"[回放] {len(names)} 个名字 × {len(bundles)} 个所有者,{args.max_workers} 线程……")
|
||||
rows = []
|
||||
with ThreadPoolExecutor(max_workers=args.max_workers) as ex:
|
||||
futs = [ex.submit(resolve, n, bundles) for n in names]
|
||||
for done, f in enumerate(as_completed(futs), 1):
|
||||
rows.append(f.result())
|
||||
if done % 50 == 0 or done == len(names):
|
||||
print(f"[回放] {done}/{len(names)}")
|
||||
|
||||
order = {n: i for i, n in enumerate(names)}
|
||||
rows.sort(key=lambda r: order.get(r[0], 10 ** 9))
|
||||
out_path = write_out(args.out, RESULT_HEADER, rows)
|
||||
|
||||
hit = sum(1 for r in rows if r[3])
|
||||
print(f"[输出] 精确命中 {hit}/{len(rows)} -> {out_path}")
|
||||
misses = [r[0] for r in rows if not r[3]]
|
||||
if misses:
|
||||
preview = "、".join(misses[:10]) + ("……" if len(misses) > 10 else "")
|
||||
print(f"[复核] 未命中 {len(misses)} 个(候选见备注列): {preview}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
781
skills/yt-studio-groupid-lookup/scripts/lookup_groups.py
Normal file
781
skills/yt-studio-groupid-lookup/scripts/lookup_groups.py
Normal file
@@ -0,0 +1,781 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
YouTube Studio 内容管理器「群组名 -> entity_id(groupId)」批量解析脚本
|
||||
(Playwright 自动捕获鉴权套件 + requests 回放查询 + Excel/CSV 输出)。
|
||||
|
||||
## 已实证的查询机制(与 yt-studio-group-id-lookup skill 一致)
|
||||
|
||||
1. 页面自身的内部接口:
|
||||
POST https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json
|
||||
请求体 `query` 字段为搜索词,响应形如:
|
||||
{ "groupDatas": [ { "displayName": "...", "groupId": "..." } ] }
|
||||
`groupId` 即 explore URL 里的 `entity_id`。
|
||||
|
||||
2. 手写请求必 401:鉴权依赖页面 JS 逐请求计算的
|
||||
`Authorization: SAPISIDHASH...`、锁定查询所有者的 `X-YouTube-Delegation-Context`,
|
||||
以及浏览器自动携带的 Cookie。唯一可靠路径是
|
||||
「捕获页面自己的请求 -> 原样回放,只改 query」。
|
||||
|
||||
3. 本脚本用 Playwright 打开所有者分析页并监听 `search_groups` 响应:
|
||||
先尝试自动触发一次搜索(选择器猜不中时提示在浏览器里手动搜一次,任意词即可),
|
||||
从该请求拿到完整套件(全部请求头含 Cookie + 请求体模板),随后在 Python 侧
|
||||
并发回放全量名单。多所有者时逐个页面各捕一份套件,首个精确命中即停。
|
||||
|
||||
## 使用前提
|
||||
- 依赖:playwright、requests、openpyxl(项目环境 `uv sync` 后 `uv run python ...`)。
|
||||
- 必须复用「已登录 YouTube Studio」的浏览器会话(同 youtube_export_interceptor.py):
|
||||
- `--user-data-dir` + `--channel chrome|msedge`:用已登录用户数据目录启动(需先关闭该浏览器)
|
||||
- `--connect http://localhost:9222`:附加到已在调试端口运行的浏览器
|
||||
(先 `chrome.exe --remote-debugging-port=9222` 或 `msedge.exe --remote-debugging-port=9222`)
|
||||
- 待查群组名单(`--names`):json / txt / csv / xlsx 均可。
|
||||
|
||||
## 用法
|
||||
# 0) 纯逻辑自测(无需浏览器/网络/第三方库)
|
||||
python lookup_groups.py --selftest
|
||||
|
||||
# 1) 在线模式:打开所有者页面 -> 捕获套件 -> 回放全量名单 -> 输出 Excel
|
||||
python lookup_groups.py --url "<owner analytics URL>" --names 名单.json ^
|
||||
--channel chrome --user-data-dir "%LOCALAPPDATA%\Google\Chrome\User Data"
|
||||
python lookup_groups.py --url "<owner1 URL>" --url "<owner2 URL>" --names 名单.xlsx ^
|
||||
--connect http://localhost:9222 --save-bundles bundles.json
|
||||
|
||||
# 2) 离线回放:套件已存盘(--save-bundles 产物),无需浏览器
|
||||
python lookup_groups.py --bundles bundles.json --names 名单.json --out result.xlsx
|
||||
|
||||
套件 bundles.json(每个所有者一个对象,`--save-bundles` 自动生成,也可手工维护):
|
||||
{ "ownerId": "...", "ownerDisplay": "...", "url": "...",
|
||||
"headers": { "Authorization": "SAPISIDHASH ...", "Cookie": "...",
|
||||
"X-YouTube-Delegation-Context": "...", "...": "..." },
|
||||
"bodyTemplate": { "...": "...", "query": "" } }
|
||||
|
||||
名单 names 支持的格式:
|
||||
json : ["名字1", "名字2"] 或 [["GROUP_NAME"], ["名字1"]] 或 {"names": [...]}
|
||||
txt : 每行一个名字
|
||||
csv/xlsx: 自动识别 群组名称 / 群组 / group_name / GROUP_NAME / 实体名称 / 名称 列
|
||||
|
||||
输出(默认 group_entity_id_result.xlsx,缺 openpyxl 自动回退 csv):
|
||||
group_name / ownerid / owner_display / groupid / 备注
|
||||
多所有者按顺序逐个尝试,首个精确命中即停;全部未命中把候选写进备注便于人工复核。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
ENDPOINT = "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json"
|
||||
SEARCH_GROUPS_PATH = "/youtubei/v1/yta_web/search_groups"
|
||||
|
||||
OWNER_PATH_RE = re.compile(r"studio\.youtube\.com/owner/([A-Za-z0-9_-]+)")
|
||||
OWNER_QUERY_RE = re.compile(r"[?&]o=([A-Za-z0-9_-]+)")
|
||||
|
||||
# 回放时剥离的请求头:长度/传输类由 requests 自行计算,accept-encoding 防止收到解不开的 br。
|
||||
STRIP_HEADERS = {
|
||||
"content-length", "host", "connection", "keep-alive",
|
||||
"transfer-encoding", "accept-encoding", "content-encoding",
|
||||
}
|
||||
|
||||
HTTP_HINTS = {
|
||||
401: "(鉴权失败:套件缺 Authorization/Cookie 或已过期,请重新捕获)",
|
||||
403: "(无权限或 delegation 语境不符)",
|
||||
429: "(限流:调低 --max-workers 或稍后重试)",
|
||||
}
|
||||
|
||||
REQUEST_TIMEOUT = 30
|
||||
MANUAL_WAIT_SECONDS = 180
|
||||
|
||||
RESULT_HEADER = ["group_name", "ownerid", "owner_display", "groupid", "备注"]
|
||||
|
||||
# 名单列名别名(规范化为 strip+lower 后比较)
|
||||
NAME_COLUMN_ALIASES = {
|
||||
"group_name", "groupname", "群组名称", "群组", "group", "name", "名称",
|
||||
"实体名称", "group name",
|
||||
}
|
||||
|
||||
USAGE_HINT = (
|
||||
"\n实际运行需先 uv sync(或 pip install playwright requests openpyxl),"
|
||||
"并复用已登录 YouTube Studio 的浏览器会话(二选一):\n"
|
||||
' 方式 A: python lookup_groups.py --url "<owner analytics URL>" --names 名单.json '
|
||||
'--channel chrome --user-data-dir "%LOCALAPPDATA%\\Google\\Chrome\\User Data"\n'
|
||||
" 方式 B: 先 chrome.exe --remote-debugging-port=9222,再\n"
|
||||
' python lookup_groups.py --url "<owner analytics URL>" --names 名单.json '
|
||||
"--connect http://localhost:9222\n"
|
||||
" 离线回放(套件已存盘): python lookup_groups.py --bundles bundles.json --names 名单.json\n"
|
||||
"多所有者: --url 可重复多次,每个所有者页面各触发一次搜索即可。"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 纯逻辑:URL / 名单解析 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def parse_owner_id(url: str) -> str:
|
||||
"""从 owner 分析页 URL 提取 ownerId(路径 /owner/<id>/ 优先,其次 ?o=<id>)。"""
|
||||
m = OWNER_PATH_RE.search(url or "")
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = OWNER_QUERY_RE.search(url or "")
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def _norm_key(s) -> str:
|
||||
return str(s).strip().lower()
|
||||
|
||||
|
||||
def parse_names_payload(data) -> list:
|
||||
"""json 结构 -> 名字列表(纯列表 / 带表头二维列表 / {"names": [...]}),保序去重。"""
|
||||
if isinstance(data, dict):
|
||||
for key in ("names", "group_names", "groups", "group_name"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
else:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
names: list = []
|
||||
seen = set()
|
||||
for item in data:
|
||||
if isinstance(item, (list, tuple)):
|
||||
if not item:
|
||||
continue
|
||||
value = str(item[0]).strip()
|
||||
if _norm_key(value) in NAME_COLUMN_ALIASES: # 表头行
|
||||
continue
|
||||
else:
|
||||
value = str(item).strip()
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
names.append(value)
|
||||
return names
|
||||
|
||||
|
||||
def _names_from_rows(rows: list) -> list:
|
||||
"""二维行(含表头行)-> 名字列表;自动找别名列,单列无表头全当名字。"""
|
||||
if not rows:
|
||||
return []
|
||||
first = rows[0]
|
||||
alias_col = None
|
||||
for idx, cell in enumerate(first):
|
||||
if _norm_key(cell) in NAME_COLUMN_ALIASES:
|
||||
alias_col = idx
|
||||
break
|
||||
if alias_col is not None:
|
||||
data_rows = rows[1:]
|
||||
|
||||
def pick(r):
|
||||
return r[alias_col] if alias_col < len(r) else ""
|
||||
elif len(first) == 1:
|
||||
data_rows = rows # 单列且首行不是表头 -> 全部当名字
|
||||
|
||||
def pick(r):
|
||||
return r[0] if r else ""
|
||||
else:
|
||||
raise ValueError(
|
||||
f"无法识别名字列,第一行: {first}(表头用 群组名称/group_name,或改为单列文件)")
|
||||
|
||||
names, seen = [], set()
|
||||
for r in data_rows:
|
||||
v = str(pick(r)).strip()
|
||||
if v and _norm_key(v) not in ("nan", "none") and v not in seen:
|
||||
seen.add(v)
|
||||
names.append(v)
|
||||
return names
|
||||
|
||||
|
||||
def load_names(path: str) -> list:
|
||||
"""按扩展名加载名单:json / txt / csv / xlsx。"""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".json":
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return parse_names_payload(json.load(f))
|
||||
if ext in (".xlsx", ".xls"):
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_excel(path, dtype=str)
|
||||
rows = [[str(c) for c in df.columns]]
|
||||
rows += [["" if v is None else str(v) for v in rec]
|
||||
for rec in df.itertuples(index=False)]
|
||||
return _names_from_rows(rows)
|
||||
# txt / csv 统一按 csv 解析(txt 每行一个名字天然兼容)
|
||||
with open(path, newline="", encoding="utf-8-sig") as f:
|
||||
rows = [r for r in csv.reader(f) if any(c.strip() for c in r)]
|
||||
return _names_from_rows(rows)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 纯逻辑:请求构造 / 匹配 / 回放 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_body(body_template: dict, query: str) -> dict:
|
||||
"""深拷贝模板并只改 query(其余字段一律不动,否则查错所有者或 401)。"""
|
||||
body = json.loads(json.dumps(body_template or {}))
|
||||
body["query"] = query
|
||||
return body
|
||||
|
||||
|
||||
def pick_match(group_datas: list, name: str):
|
||||
"""精确 displayName 命中 -> (groupId, []);否则 (None, 前 5 个候选 '名=ID')。"""
|
||||
for g in group_datas:
|
||||
if g.get("displayName") == name:
|
||||
return g.get("groupId"), []
|
||||
cands = [f"{g.get('displayName')}={g.get('groupId')}" for g in group_datas[:5]]
|
||||
return None, cands
|
||||
|
||||
|
||||
def _external_owner_id(body: dict) -> str:
|
||||
"""取请求体 context.user.delegationContext.externalOwnerId(锁定查询所有者)。"""
|
||||
try:
|
||||
return body["context"]["user"]["delegationContext"]["externalOwnerId"]
|
||||
except (KeyError, TypeError):
|
||||
return ""
|
||||
|
||||
|
||||
_TLS = threading.local()
|
||||
|
||||
|
||||
def _thread_session():
|
||||
"""线程本地 requests.Session(延迟导入:selftest/单测无需安装 requests)。"""
|
||||
import requests
|
||||
|
||||
s = getattr(_TLS, "session", None)
|
||||
if s is None:
|
||||
s = _TLS.session = requests.Session()
|
||||
return s
|
||||
|
||||
|
||||
def search(bundle: dict, name: str, session=None) -> dict:
|
||||
"""对单个所有者套件回放一次 search_groups,只改 query。
|
||||
|
||||
session 可注入测试替身(.post(url, json=..., headers=..., timeout=...))。
|
||||
"""
|
||||
headers = {k: v for k, v in (bundle.get("headers") or {}).items()
|
||||
if str(k).lower() not in STRIP_HEADERS}
|
||||
body = build_body(bundle.get("bodyTemplate") or {}, name)
|
||||
url = bundle.get("url") or ENDPOINT
|
||||
try:
|
||||
if session is None:
|
||||
session = _thread_session()
|
||||
r = session.post(url, json=body, headers=headers, timeout=REQUEST_TIMEOUT)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "ERR", "detail": str(e)}
|
||||
|
||||
code = getattr(r, "status_code", 0)
|
||||
if code != 200:
|
||||
return {"status": "HTTP", "detail": code, "hint": HTTP_HINTS.get(code, "")}
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "ERR", "detail": f"响应非 JSON: {e}"}
|
||||
|
||||
group_id, cands = pick_match(data.get("groupDatas") or [], name)
|
||||
if group_id:
|
||||
return {"status": "OK", "groupId": group_id}
|
||||
return {"status": "NF", "cands": cands}
|
||||
|
||||
|
||||
def resolve(name: str, bundles: list, session=None) -> list:
|
||||
"""多所有者逐个尝试,首个精确命中即停并记录 ownerid,未命中收集各所有者线索。"""
|
||||
notes = []
|
||||
for b in bundles:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
owner_id = b.get("ownerId", "")
|
||||
owner = b.get("ownerDisplay", "")
|
||||
r = search(b, name, session)
|
||||
if r["status"] == "OK":
|
||||
note = "精确命中" if not notes else "精确命中;此前 " + "; ".join(notes)
|
||||
return [name, owner_id, owner, r["groupId"], note]
|
||||
if r["status"] == "HTTP":
|
||||
msg = f"{owner}({owner_id}) 返回 HTTP{r['detail']}{r.get('hint', '')}"
|
||||
notes.append(msg)
|
||||
elif r["status"] == "ERR":
|
||||
notes.append(f"{owner}({owner_id}) 网络错: {r['detail'][:60]}")
|
||||
elif r["status"] == "NF":
|
||||
cands = r.get("cands") or []
|
||||
notes.append(
|
||||
f"{owner}({owner_id}) 候选: {', '.join(cands)}" if cands
|
||||
else f"{owner}({owner_id}) 无结果")
|
||||
return [name, "", "", "", "未匹配任何 owner;" + " | ".join(notes)]
|
||||
|
||||
|
||||
def validate_bundle(bundle: dict) -> list:
|
||||
"""校验套件完整性,返回问题列表(鉴权头缺失是 401 的头号根因,提前点破)。"""
|
||||
problems = []
|
||||
headers = {_norm_key(k): v for k, v in (bundle.get("headers") or {}).items()}
|
||||
if not headers:
|
||||
problems.append("headers 为空,回放必 401")
|
||||
else:
|
||||
for key, desc in (
|
||||
("authorization", "Authorization(SAPISIDHASH)"),
|
||||
("cookie", "Cookie"),
|
||||
("x-youtube-delegation-context", "X-YouTube-Delegation-Context"),
|
||||
):
|
||||
if not headers.get(key):
|
||||
problems.append(f"缺少 {desc} 头,回放大概率 401 或查错所有者")
|
||||
body = bundle.get("bodyTemplate") or {}
|
||||
if not body:
|
||||
problems.append("bodyTemplate 为空")
|
||||
elif not _external_owner_id(body):
|
||||
problems.append("bodyTemplate 缺 context.user.delegationContext,可能查错所有者")
|
||||
return problems
|
||||
|
||||
|
||||
def merge_bundles(existing: list, captured: list) -> list:
|
||||
"""合并套件:captured 按 ownerId 覆盖同名项,其余按原序保留。"""
|
||||
by_owner: dict = {}
|
||||
for b in existing:
|
||||
if isinstance(b, dict) and b.get("ownerId"):
|
||||
by_owner[b["ownerId"]] = b
|
||||
for b in captured:
|
||||
if isinstance(b, dict) and b.get("ownerId"):
|
||||
by_owner[b["ownerId"]] = b
|
||||
return list(by_owner.values())
|
||||
|
||||
|
||||
def write_out(path: str, header: list, rows: list) -> str:
|
||||
"""写结果文件:.xlsx 优先 openpyxl,缺库自动回退 .csv。返回实际写入路径。"""
|
||||
if path.lower().endswith(".xlsx"):
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
print("[i] 缺少 openpyxl,回退为 csv 输出")
|
||||
path = path[:-5] + ".csv"
|
||||
else:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "result"
|
||||
ws.append(header)
|
||||
for r in rows:
|
||||
ws.append(r)
|
||||
for col, w in zip("ABCDE", (28, 22, 24, 22, 60)):
|
||||
ws.column_dimensions[col].width = w
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(header)
|
||||
w.writerows(rows)
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 浏览器侧:自动捕获套件 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
AUTO_SEARCH_SELECTORS = (
|
||||
"input[placeholder*='搜索']",
|
||||
"input[aria-label*='搜索']",
|
||||
"input[placeholder*='Search']",
|
||||
"input[aria-label*='Search']",
|
||||
"ytcp-analytics-filter-bar input",
|
||||
"ytcp-text-input input",
|
||||
)
|
||||
|
||||
OWNER_DISPLAY_SELECTORS = (
|
||||
"ytcp-account-item button",
|
||||
".account-switcher button",
|
||||
"#owner-name",
|
||||
)
|
||||
|
||||
|
||||
def _launch_page(p, user_data_dir, channel, cdp_url):
|
||||
"""按 interceptor 同款三种方式拿到 (browser, context, page)。"""
|
||||
if cdp_url:
|
||||
browser = p.chromium.connect_over_cdp(cdp_url)
|
||||
context = browser.contexts[0] if browser.contexts else browser.new_context()
|
||||
return browser, context, context.new_page()
|
||||
if user_data_dir:
|
||||
context = p.chromium.launch_persistent_context(
|
||||
user_data_dir=user_data_dir,
|
||||
channel=channel,
|
||||
headless=False,
|
||||
args=["--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
return None, context, context.new_page()
|
||||
browser = p.chromium.launch(headless=False, channel=channel)
|
||||
context = browser.new_context()
|
||||
return browser, context, context.new_page()
|
||||
|
||||
|
||||
def _try_auto_search(page, probe="a"):
|
||||
"""尽力自动触发一次搜索(选择器猜不中就交回手动流程,结果无关紧要)。"""
|
||||
for sel in AUTO_SEARCH_SELECTORS:
|
||||
try:
|
||||
loc = page.locator(sel).first
|
||||
loc.wait_for(state="visible", timeout=1500)
|
||||
loc.fill(probe)
|
||||
try:
|
||||
loc.press("Enter")
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(1500)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _read_owner_display(page):
|
||||
"""尽力读所有者/账号显示名(仅用于结果标注,读不到不影响功能)。"""
|
||||
for sel in OWNER_DISPLAY_SELECTORS:
|
||||
try:
|
||||
text = page.locator(sel).first.inner_text(timeout=2000).strip()
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def _capture_bundle(page, url, wait_seconds, owner_display=""):
|
||||
"""打开一个所有者分析页,捕获一次 search_groups 请求 -> 完整套件。"""
|
||||
owner_id = parse_owner_id(url)
|
||||
print(f"[捕获] 打开所有者页面: {owner_display or owner_id or url}")
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[捕获] 页面加载较慢({type(e).__name__}),继续等待搜索……", file=sys.stderr)
|
||||
|
||||
if "accounts.google" in page.url:
|
||||
raise SystemExit(
|
||||
"[!] 当前会话未登录(跳转到 Google 登录页)。请用 --user-data-dir 或 "
|
||||
"--connect 复用已登录浏览器后重试。")
|
||||
|
||||
captured = []
|
||||
|
||||
def on_response(resp):
|
||||
try:
|
||||
if SEARCH_GROUPS_PATH in resp.url:
|
||||
captured.append(resp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
try:
|
||||
if _try_auto_search(page):
|
||||
print("[捕获] 已尝试自动触发一次群组搜索……")
|
||||
# 自动触发后先安静等 5 秒;仍无请求再提示手动搜索
|
||||
quiet = time.time() + 5
|
||||
while not captured and time.time() < quiet:
|
||||
page.wait_for_timeout(500)
|
||||
deadline = time.time() + wait_seconds
|
||||
notified = False
|
||||
last_report = time.time()
|
||||
while not captured and time.time() < deadline:
|
||||
if not notified:
|
||||
print(f"[捕获] 请在打开的浏览器窗口中,于该所有者分析页顶部的搜索/筛选框"
|
||||
f"输入任意词并回车(只需触发一次搜索,结果无所谓)。"
|
||||
f"最长等待 {wait_seconds} 秒……")
|
||||
notified = True
|
||||
page.wait_for_timeout(1000)
|
||||
if time.time() - last_report >= 30:
|
||||
print(f"[捕获] 仍在等待手动搜索……剩余 {int(deadline - time.time())} 秒")
|
||||
last_report = time.time()
|
||||
if not captured:
|
||||
raise TimeoutError(
|
||||
f"{wait_seconds} 秒内未捕获到 search_groups 请求。"
|
||||
"请确认页面是高级模式分析页且搜索框可用;若页面被重定向到登录页,"
|
||||
"请改用 --user-data-dir / --connect 复用已登录会话后重试。")
|
||||
finally:
|
||||
try:
|
||||
page.remove_listener("response", on_response)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resp = captured[-1] # 取最近一次(SAPISIDHASH 时间戳最新)
|
||||
req = resp.request
|
||||
try:
|
||||
headers = dict(req.all_headers())
|
||||
except Exception: # noqa: BLE001
|
||||
headers = dict(req.headers)
|
||||
try:
|
||||
body = json.loads(req.post_data or "{}")
|
||||
except Exception: # noqa: BLE001
|
||||
body = {}
|
||||
|
||||
ext_owner = _external_owner_id(body)
|
||||
if ext_owner and owner_id and ext_owner != owner_id:
|
||||
print(f"[捕获] 警告:请求体 externalOwnerId={ext_owner} 与 URL ownerId={owner_id} "
|
||||
"不一致,以请求体为准。", file=sys.stderr)
|
||||
owner_id = ext_owner
|
||||
owner_id = owner_id or ext_owner
|
||||
if not owner_id:
|
||||
raise ValueError("无法确定 ownerId(URL 非 /owner/<id>/ 且请求体无 delegationContext)")
|
||||
|
||||
bundle = {
|
||||
"ownerId": owner_id,
|
||||
"ownerDisplay": owner_display or _read_owner_display(page),
|
||||
"url": req.url,
|
||||
"headers": headers,
|
||||
"bodyTemplate": body,
|
||||
"capturedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
for pb in validate_bundle(bundle):
|
||||
print(f"[捕获] 警告:{pb}", file=sys.stderr)
|
||||
print(f"[捕获] 套件就绪:owner={bundle['ownerDisplay'] or owner_id} ({owner_id}),"
|
||||
f"headers={len(headers)} 项,请求体模板已取得")
|
||||
return bundle
|
||||
|
||||
|
||||
def capture_bundles(urls, user_data_dir=None, channel=None, cdp_url=None,
|
||||
owner_display="", wait_seconds=MANUAL_WAIT_SECONDS):
|
||||
"""打开浏览器,逐个所有者捕获套件(同一个浏览器会话依次 goto)。"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
raise SystemExit("[!] 缺少 playwright:uv sync 或 pip install playwright 后重试。")
|
||||
|
||||
mode = "CDP 附加" if cdp_url else ("用户数据目录" if user_data_dir else "全新会话(大概率未登录)")
|
||||
print(f"[捕获] 浏览器会话:{mode}")
|
||||
|
||||
bundles = []
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser, context, page = _launch_page(p, user_data_dir, channel, cdp_url)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SystemExit(
|
||||
f"[!] 启动/连接浏览器失败: {e}\n"
|
||||
" 方式 A 需先关闭对应浏览器;方式 B 先以调试端口启动:\n"
|
||||
" chrome.exe --remote-debugging-port=9222")
|
||||
try:
|
||||
for i, url in enumerate(urls, 1):
|
||||
print(f"[捕获] 所有者 {i}/{len(urls)}")
|
||||
display = owner_display if len(urls) == 1 else ""
|
||||
bundles.append(_capture_bundle(page, url, wait_seconds, display))
|
||||
finally:
|
||||
if browser is not None:
|
||||
browser.close()
|
||||
elif context is not None:
|
||||
context.close()
|
||||
return bundles
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 自测(无需浏览器/网络/第三方库,仅标准库) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _FakeResp:
|
||||
def __init__(self, code, payload=None):
|
||||
self.status_code = code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""按 `模板marker::query` 路由响应的测试替身,可区分不同所有者的套件。"""
|
||||
|
||||
def __init__(self, routing):
|
||||
self.routing = routing
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
self.calls.append((url, json, headers))
|
||||
body = json or {}
|
||||
key = f"{body.get('marker', '')}::{body.get('query', '')}"
|
||||
item = self.routing.get(key)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
if item is not None:
|
||||
return item
|
||||
return _FakeResp(200, {"groupDatas": []})
|
||||
|
||||
|
||||
def selftest():
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
# 1) ownerId 解析(路径优先,其次 ?o=)
|
||||
url = "https://studio.youtube.com/owner/bqSUnNpU67xJ51TxH4PKpQ/analytics?o=bqSUnNpU67xJ51TxH4PKpQ"
|
||||
assert parse_owner_id(url) == "bqSUnNpU67xJ51TxH4PKpQ"
|
||||
assert parse_owner_id("https://studio.youtube.com/analytics?o=AbCdEf123") == "AbCdEf123"
|
||||
assert parse_owner_id("https://studio.youtube.com/") == ""
|
||||
|
||||
# 2) 名单解析:json 两种格式 + dict + txt + csv(表头/单列/多列别名)
|
||||
assert parse_names_payload(["A", "B", "A "]) == ["A", "B"]
|
||||
assert parse_names_payload([["GROUP_NAME"], ["A"], ["B"]]) == ["A", "B"]
|
||||
assert parse_names_payload([["群组名称", "备注"], ["A", "x"], ["B", "y"]]) == ["A", "B"]
|
||||
assert parse_names_payload({"names": ["A", "B"]}) == ["A", "B"]
|
||||
p_txt = Path(td) / "names.txt"
|
||||
p_txt.write_text("A\n B \n\nA\n", encoding="utf-8")
|
||||
assert load_names(str(p_txt)) == ["A", "B"]
|
||||
p_csv = Path(td) / "names.csv"
|
||||
p_csv.write_text("群组名称,备注\nA,x\nB,\n", encoding="utf-8-sig")
|
||||
assert load_names(str(p_csv)) == ["A", "B"]
|
||||
p_single = Path(td) / "single.csv"
|
||||
p_single.write_text("A\nB\n", encoding="utf-8")
|
||||
assert load_names(str(p_single)) == ["A", "B"]
|
||||
|
||||
# 3) 请求体构造:深拷贝不污染模板,只改 query
|
||||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||||
"query": ""}
|
||||
body = build_body(tpl, "靓舟桃")
|
||||
assert body["query"] == "靓舟桃" and tpl["query"] == ""
|
||||
|
||||
# 4) 匹配逻辑:精确命中 / 候选
|
||||
gd = [{"displayName": "X 漫剧-1", "groupId": "G1"},
|
||||
{"displayName": "X 漫剧-2", "groupId": "G2"}]
|
||||
assert pick_match(gd, "X 漫剧-2") == ("G2", [])
|
||||
gid, cands = pick_match(gd, "X 漫剧")
|
||||
assert gid is None and cands == ["X 漫剧-1=G1", "X 漫剧-2=G2"]
|
||||
|
||||
# 5) 回放:命中 / 未命中候选 / HTTP 提示 / 剥离长度类头
|
||||
# (模板里放一个 marker 字段,让替身能区分不同所有者的套件)
|
||||
bundle = {
|
||||
"ownerId": "O1", "ownerDisplay": "Owner One", "url": ENDPOINT,
|
||||
"headers": {"Authorization": "SAPISIDHASH x", "Cookie": "SID=1",
|
||||
"X-YouTube-Delegation-Context": "ctx",
|
||||
"Content-Length": "3", "Host": "studio.youtube.com"},
|
||||
"bodyTemplate": {**tpl, "marker": "O1"},
|
||||
}
|
||||
sess = _FakeSession({
|
||||
"O1::命中": _FakeResp(200, {"groupDatas": [{"displayName": "命中", "groupId": "G9"}]}),
|
||||
"O1::疑似": _FakeResp(200, {"groupDatas": gd}),
|
||||
"O1::限流": _FakeResp(429),
|
||||
})
|
||||
assert search(bundle, "命中", sess) == {"status": "OK", "groupId": "G9"}
|
||||
r = search(bundle, "疑似", sess)
|
||||
assert r["status"] == "NF" and "X 漫剧-1=G1" in r["cands"]
|
||||
r = search(bundle, "限流", sess)
|
||||
assert r["status"] == "HTTP" and "限流" in r["hint"]
|
||||
sent_headers = sess.calls[-1][2]
|
||||
assert "Content-Length" not in sent_headers and "Host" not in sent_headers
|
||||
assert sent_headers["Authorization"] == "SAPISIDHASH x"
|
||||
assert search(bundle, "断网", _FakeSession({"O1::断网": OSError("refused")}))["status"] == "ERR"
|
||||
|
||||
# 6) 多所有者归并:先 NF 后命中 / 全未命中备注
|
||||
b1 = dict(bundle, ownerId="O1", ownerDisplay="一号")
|
||||
b2 = dict(bundle, ownerId="O2", ownerDisplay="二号",
|
||||
bodyTemplate={**tpl, "marker": "O2"})
|
||||
sess2 = _FakeSession({
|
||||
"O2::跨主": _FakeResp(200, {"groupDatas": [{"displayName": "跨主", "groupId": "G2"}]}),
|
||||
})
|
||||
row = resolve("跨主", [b1, b2], sess2)
|
||||
assert row == ["跨主", "O2", "二号", "G2",
|
||||
"精确命中;此前 一号(O1) 无结果"]
|
||||
row = resolve("查无", [b1], sess2)
|
||||
assert row[3] == "" and "未匹配任何 owner" in row[4]
|
||||
|
||||
# 7) 套件校验与合并
|
||||
problems = validate_bundle({"headers": {"Authorization": "x"},
|
||||
"bodyTemplate": tpl})
|
||||
assert any("Cookie" in p for p in problems)
|
||||
merged = merge_bundles([{"ownerId": "O1", "v": 1}],
|
||||
[{"ownerId": "O1", "v": 2}, {"ownerId": "O2", "v": 3}])
|
||||
assert [b["ownerId"] for b in merged] == ["O1", "O2"] and merged[0]["v"] == 2
|
||||
|
||||
# 8) 输出:csv 路径直写(xlsx 分支见单元测试)
|
||||
out = Path(td) / "result.csv"
|
||||
path = write_out(str(out), RESULT_HEADER, [row])
|
||||
text = out.read_text(encoding="utf-8-sig")
|
||||
assert "group_name,ownerid,owner_display,groupid,备注" in text and "查无" in text
|
||||
assert path == str(out)
|
||||
|
||||
print("selftest OK:URL/名单解析、请求体、匹配、回放、多所有者、校验、输出 全部通过")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(
|
||||
description="YouTube Studio 群组名 -> entity_id 批量解析(捕获套件 + 回放)")
|
||||
ap.add_argument("--selftest", action="store_true", help="仅跑纯逻辑自测(无需浏览器/网络)")
|
||||
ap.add_argument("--url", action="append", default=[], metavar="OWNER_URL",
|
||||
help="所有者分析页 URL,可重复多次(多所有者)")
|
||||
ap.add_argument("--names", help="群组名单:json / txt / csv / xlsx")
|
||||
ap.add_argument("--out", default="group_entity_id_result.xlsx",
|
||||
help="输出文件(.xlsx 或 .csv),默认 %(default)s")
|
||||
ap.add_argument("--bundles", help="已有套件 bundles.json(离线回放,或与 --url 捕获结果合并)")
|
||||
ap.add_argument("--save-bundles", metavar="PATH",
|
||||
help="把套件存到该 json(含 --bundles 读入的),供下次离线回放")
|
||||
ap.add_argument("--max-workers", type=int, default=8, help="并发线程数,默认 %(default)s")
|
||||
ap.add_argument("--connect", help="通过 CDP 附加到已打开浏览器,如 http://localhost:9222")
|
||||
ap.add_argument("--user-data-dir",
|
||||
help="浏览器用户数据目录(复用登录态,需先关闭该浏览器)")
|
||||
ap.add_argument("--channel", choices=["chrome", "msedge"],
|
||||
help="浏览器品牌(--user-data-dir 方式必填其一)")
|
||||
ap.add_argument("--owner-display", help="所有者显示名(可选,仅单个 --url 时用于结果标注)")
|
||||
ap.add_argument("--wait-seconds", type=int, default=MANUAL_WAIT_SECONDS,
|
||||
help="等待手动触发搜索的最长秒数,默认 %(default)s")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return
|
||||
if not args.url and not args.bundles:
|
||||
selftest()
|
||||
print(USAGE_HINT)
|
||||
return
|
||||
|
||||
bundles = []
|
||||
if args.bundles:
|
||||
try:
|
||||
with open(args.bundles, encoding="utf-8") as f:
|
||||
file_bundles = json.load(f)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SystemExit(f"[!] 读取 bundles 失败: {e}")
|
||||
bundles = [b for b in file_bundles if isinstance(b, dict)]
|
||||
for b in bundles:
|
||||
for pb in validate_bundle(b):
|
||||
print(f"[套件] 警告 {b.get('ownerId', '?')}: {pb}", file=sys.stderr)
|
||||
|
||||
if args.url:
|
||||
captured = capture_bundles(
|
||||
args.url, user_data_dir=args.user_data_dir, channel=args.channel,
|
||||
cdp_url=args.connect, owner_display=args.owner_display or "",
|
||||
wait_seconds=args.wait_seconds)
|
||||
bundles = merge_bundles(bundles, captured)
|
||||
if args.save_bundles:
|
||||
with open(args.save_bundles, "w", encoding="utf-8") as f:
|
||||
json.dump(bundles, f, ensure_ascii=False, indent=2)
|
||||
print(f"[套件] 已保存 {len(bundles)} 个所有者套件 -> {args.save_bundles}")
|
||||
|
||||
if not bundles:
|
||||
raise SystemExit("[!] 没有可用套件:请提供 --url 在线捕获,或 --bundles 离线回放。")
|
||||
|
||||
if not args.names:
|
||||
print("[i] 未提供 --names:仅完成套件捕获/校验,不执行查询。")
|
||||
return
|
||||
|
||||
try:
|
||||
names = load_names(args.names)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SystemExit(f"[!] 名单读取失败: {e}")
|
||||
if not names:
|
||||
raise SystemExit(f"[!] 名单为空或无法解析:{args.names}"
|
||||
"(支持 json/txt/csv/xlsx,列名用 群组名称/group_name)")
|
||||
|
||||
print(f"[回放] {len(names)} 个名字 × {len(bundles)} 个所有者,{args.max_workers} 线程……")
|
||||
rows = []
|
||||
with ThreadPoolExecutor(max_workers=args.max_workers) as ex:
|
||||
futs = [ex.submit(resolve, n, bundles) for n in names]
|
||||
for done, f in enumerate(as_completed(futs), 1):
|
||||
rows.append(f.result())
|
||||
if done % 50 == 0 or done == len(names):
|
||||
print(f"[回放] {done}/{len(names)}")
|
||||
|
||||
order = {n: i for i, n in enumerate(names)}
|
||||
rows.sort(key=lambda r: order.get(r[0], 10 ** 9))
|
||||
out_path = write_out(args.out, RESULT_HEADER, rows)
|
||||
|
||||
hit = sum(1 for r in rows if r[3])
|
||||
print(f"[输出] 精确命中 {hit}/{len(rows)} -> {out_path}")
|
||||
misses = [r[0] for r in rows if not r[3]]
|
||||
if misses:
|
||||
preview = "、".join(misses[:10]) + ("……" if len(misses) > 10 else "")
|
||||
print(f"[复核] 未命中 {len(misses)} 个(候选见备注列): {preview}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
URL_BUILDER_SCRIPT = ROOT / "skills" / "yt-studio-url-builder" / "scripts" / "build_studio_urls.py"
|
||||
COUNTRIES_JSON = URL_BUILDER_SCRIPT.parent / "countries.json"
|
||||
DOWNLOADER_SCRIPT = ROOT / "skills" / "youtube-studio-csv-download" / "scripts" / "youtube_export_download.py"
|
||||
LOOKUP_SCRIPT = ROOT / "scripts" / "lookup_groups.py"
|
||||
|
||||
|
||||
def _load_module(name, path):
|
||||
@@ -44,6 +45,12 @@ def downloader():
|
||||
return _load_module("youtube_export_download_under_test", DOWNLOADER_SCRIPT)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def lookup_groups():
|
||||
"""lookup_groups.py 模块(会话级,加载一次)。"""
|
||||
return _load_module("lookup_groups_under_test", LOOKUP_SCRIPT)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def real_countries(url_builder):
|
||||
"""脚本自带 countries.json 加载出的国家映射。"""
|
||||
|
||||
274
tests/test_lookup_groups.py
Normal file
274
tests/test_lookup_groups.py
Normal file
@@ -0,0 +1,274 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""lookup_groups.py 测试。
|
||||
|
||||
覆盖不依赖真实浏览器/登录态/网络的全部逻辑:
|
||||
parse_owner_id / parse_names_payload / load_names URL 与名单解析
|
||||
build_body / pick_match / search / resolve 请求构造、匹配、多所有者归并
|
||||
validate_bundle / merge_bundles 套件校验与合并
|
||||
write_out csv / xlsx 输出
|
||||
selftest() / --selftest / 无参 CLI 内置自测与引导输出
|
||||
|
||||
capture_bundles() 在线捕获需已登录浏览器会话,不在自动化测试范围。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import LOOKUP_SCRIPT, run_script
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试替身
|
||||
# ---------------------------------------------------------------------------
|
||||
class FakeResp:
|
||||
def __init__(self, code, payload=None):
|
||||
self.status_code = code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""按 `模板marker::query` 路由响应,可区分不同所有者的套件。
|
||||
|
||||
routing 键形如 "O1::名字";未命中键返回空结果(NF)。
|
||||
"""
|
||||
|
||||
def __init__(self, routing):
|
||||
self.routing = routing
|
||||
self.calls = []
|
||||
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
self.calls.append((url, json, headers))
|
||||
body = json or {}
|
||||
key = f"{body.get('marker', '')}::{body.get('query', '')}"
|
||||
item = self.routing.get(key)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
if item is not None:
|
||||
return item
|
||||
return FakeResp(200, {"groupDatas": []})
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bundle():
|
||||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||||
"marker": "O1", "query": ""}
|
||||
return {
|
||||
"ownerId": "O1", "ownerDisplay": "Owner One",
|
||||
"url": "https://studio.youtube.com/youtubei/v1/yta_web/search_groups?alt=json",
|
||||
"headers": {
|
||||
"Authorization": "SAPISIDHASH x", "Cookie": "SID=1",
|
||||
"X-YouTube-Delegation-Context": "ctx",
|
||||
"Content-Length": "3", "Host": "studio.youtube.com",
|
||||
},
|
||||
"bodyTemplate": tpl,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL / 名单解析
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestParse:
|
||||
def test_parse_owner_id_path_first(self, lookup_groups):
|
||||
url = ("https://studio.youtube.com/owner/bqSUnNpU67xJ51TxH4PKpQ/analytics"
|
||||
"?o=WRONG&explore=...")
|
||||
assert lookup_groups.parse_owner_id(url) == "bqSUnNpU67xJ51TxH4PKpQ"
|
||||
|
||||
def test_parse_owner_id_query_fallback(self, lookup_groups):
|
||||
assert lookup_groups.parse_owner_id(
|
||||
"https://studio.youtube.com/analytics?o=AbCdEf123") == "AbCdEf123"
|
||||
assert lookup_groups.parse_owner_id("https://studio.youtube.com/") == ""
|
||||
|
||||
def test_parse_names_payload_variants(self, lookup_groups):
|
||||
assert lookup_groups.parse_names_payload(["A", "B", "A "]) == ["A", "B"]
|
||||
assert lookup_groups.parse_names_payload(
|
||||
[["GROUP_NAME"], ["A"], ["B"]]) == ["A", "B"]
|
||||
assert lookup_groups.parse_names_payload(
|
||||
[["群组名称", "备注"], ["A", "x"], ["B", "y"]]) == ["A", "B"]
|
||||
assert lookup_groups.parse_names_payload({"names": ["A", "B"]}) == ["A", "B"]
|
||||
assert lookup_groups.parse_names_payload({"foo": 1}) == []
|
||||
assert lookup_groups.parse_names_payload("not-a-list") == []
|
||||
|
||||
def test_load_names_txt_csv(self, lookup_groups, tmp_path):
|
||||
p_txt = tmp_path / "names.txt"
|
||||
p_txt.write_text("A\n B \n\nA\n", encoding="utf-8")
|
||||
assert lookup_groups.load_names(str(p_txt)) == ["A", "B"]
|
||||
|
||||
p_csv = tmp_path / "names.csv"
|
||||
p_csv.write_text("group_name,备注\nA,x\nB,\n", encoding="utf-8-sig")
|
||||
assert lookup_groups.load_names(str(p_csv)) == ["A", "B"]
|
||||
|
||||
p_single = tmp_path / "single.csv"
|
||||
p_single.write_text("A\nB\n", encoding="utf-8")
|
||||
assert lookup_groups.load_names(str(p_single)) == ["A", "B"]
|
||||
|
||||
def test_load_names_json(self, lookup_groups, tmp_path):
|
||||
p = tmp_path / "names.json"
|
||||
p.write_text(json.dumps(["A", "B"]), encoding="utf-8")
|
||||
assert lookup_groups.load_names(str(p)) == ["A", "B"]
|
||||
|
||||
def test_load_names_xlsx(self, lookup_groups, tmp_path):
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["群组名称", "备注"])
|
||||
ws.append(["A", "x"])
|
||||
ws.append(["B", None])
|
||||
ws.append([None, "空行跳过"])
|
||||
p = tmp_path / "names.xlsx"
|
||||
wb.save(str(p))
|
||||
assert lookup_groups.load_names(str(p)) == ["A", "B"]
|
||||
|
||||
def test_load_names_unknown_column_raises(self, lookup_groups, tmp_path):
|
||||
p = tmp_path / "bad.csv"
|
||||
p.write_text("列一,列二\nA,B\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="无法识别名字列"):
|
||||
lookup_groups.load_names(str(p))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求构造 / 匹配 / 回放
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSearch:
|
||||
def test_build_body_isolation(self, lookup_groups):
|
||||
tpl = {"context": {"user": {"delegationContext": {"externalOwnerId": "O1"}}},
|
||||
"query": ""}
|
||||
body = lookup_groups.build_body(tpl, "靓舟桃")
|
||||
assert body["query"] == "靓舟桃"
|
||||
assert tpl["query"] == "" # 模板不被污染
|
||||
assert body["context"] is not tpl["context"] # 深拷贝
|
||||
body["context"]["user"]["delegationContext"]["externalOwnerId"] = "CHANGED"
|
||||
assert tpl["context"]["user"]["delegationContext"]["externalOwnerId"] == "O1"
|
||||
|
||||
def test_pick_match(self, lookup_groups):
|
||||
gd = [{"displayName": "X 漫剧-1", "groupId": "G1"},
|
||||
{"displayName": "X 漫剧-2", "groupId": "G2"}]
|
||||
assert lookup_groups.pick_match(gd, "X 漫剧-2") == ("G2", [])
|
||||
gid, cands = lookup_groups.pick_match(gd, "X 漫剧")
|
||||
assert gid is None and cands == ["X 漫剧-1=G1", "X 漫剧-2=G2"]
|
||||
|
||||
def test_search_ok_nf_http_err(self, lookup_groups, bundle):
|
||||
sess = FakeSession({
|
||||
"O1::命中": FakeResp(200, {"groupDatas": [{"displayName": "命中", "groupId": "G9"}]}),
|
||||
"O1::疑似": FakeResp(200, {"groupDatas": [
|
||||
{"displayName": "X 漫剧-1", "groupId": "G1"}]}),
|
||||
"O1::限流": FakeResp(429),
|
||||
"O1::断网": OSError("refused"),
|
||||
"O1::坏响应": FakeResp(200, None),
|
||||
})
|
||||
assert lookup_groups.search(bundle, "命中", sess) == {"status": "OK", "groupId": "G9"}
|
||||
r = lookup_groups.search(bundle, "疑似", sess)
|
||||
assert r["status"] == "NF" and "X 漫剧-1=G1" in r["cands"]
|
||||
r = lookup_groups.search(bundle, "限流", sess)
|
||||
assert r["status"] == "HTTP" and r["detail"] == 429 and "限流" in r["hint"]
|
||||
assert lookup_groups.search(bundle, "断网", sess)["status"] == "ERR"
|
||||
assert lookup_groups.search(bundle, "坏响应", sess)["status"] == "ERR"
|
||||
|
||||
def test_search_strips_hop_by_hop_headers(self, lookup_groups, bundle):
|
||||
sess = FakeSession({})
|
||||
lookup_groups.search(bundle, "任意", sess)
|
||||
url, body, headers = sess.calls[0]
|
||||
assert "Content-Length" not in headers and "Host" not in headers
|
||||
assert headers["Authorization"] == "SAPISIDHASH x"
|
||||
assert headers["X-YouTube-Delegation-Context"] == "ctx"
|
||||
assert body["query"] == "任意"
|
||||
|
||||
def test_resolve_first_hit_and_miss(self, lookup_groups, bundle):
|
||||
b1 = dict(bundle, ownerId="O1", ownerDisplay="一号")
|
||||
tpl2 = dict(bundle["bodyTemplate"], marker="O2")
|
||||
b2 = dict(bundle, ownerId="O2", ownerDisplay="二号", bodyTemplate=tpl2)
|
||||
sess = FakeSession({
|
||||
"O2::跨主": FakeResp(200, {"groupDatas": [
|
||||
{"displayName": "跨主", "groupId": "G2"}]}),
|
||||
})
|
||||
row = lookup_groups.resolve("跨主", [b1, b2], sess)
|
||||
assert row == ["跨主", "O2", "二号", "G2", "精确命中;此前 一号(O1) 无结果"]
|
||||
row = lookup_groups.resolve("查无", [b1], sess)
|
||||
assert row[:4] == ["查无", "", "", ""]
|
||||
assert "未匹配任何 owner" in row[4] and "一号(O1)" in row[4]
|
||||
|
||||
def test_resolve_http_hint_in_note(self, lookup_groups, bundle):
|
||||
sess = FakeSession({"O1::401名字": FakeResp(401)})
|
||||
row = lookup_groups.resolve("401名字", [bundle], sess)
|
||||
assert "HTTP401" in row[4] and "鉴权失败" in row[4]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 套件校验 / 合并 / 输出
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestBundleAndOutput:
|
||||
def test_validate_bundle(self, lookup_groups):
|
||||
ok = {"headers": {"Authorization": "x", "Cookie": "y",
|
||||
"X-YouTube-Delegation-Context": "z"},
|
||||
"bodyTemplate": {"context": {"user": {"delegationContext": {
|
||||
"externalOwnerId": "O1"}}}}}
|
||||
assert lookup_groups.validate_bundle(ok) == []
|
||||
|
||||
bad = {"headers": {"Authorization": "x"},
|
||||
"bodyTemplate": {"context": {}}}
|
||||
problems = lookup_groups.validate_bundle(bad)
|
||||
assert any("Cookie" in p for p in problems)
|
||||
assert any("X-YouTube-Delegation-Context" in p for p in problems)
|
||||
assert any("delegationContext" in p for p in problems)
|
||||
assert lookup_groups.validate_bundle({}) != []
|
||||
|
||||
def test_merge_bundles(self, lookup_groups):
|
||||
merged = lookup_groups.merge_bundles(
|
||||
[{"ownerId": "O1", "v": 1}, {"bad": 1}],
|
||||
[{"ownerId": "O1", "v": 2}, {"ownerId": "O2", "v": 3}])
|
||||
assert [b["ownerId"] for b in merged] == ["O1", "O2"]
|
||||
assert merged[0]["v"] == 2 # captured 覆盖同名
|
||||
|
||||
def test_write_out_csv(self, lookup_groups, tmp_path):
|
||||
out = tmp_path / "result.csv"
|
||||
path = lookup_groups.write_out(
|
||||
str(out), lookup_groups.RESULT_HEADER,
|
||||
[["A", "O1", "一号", "G1", "精确命中"]])
|
||||
assert path == str(out)
|
||||
text = out.read_text(encoding="utf-8-sig")
|
||||
assert "group_name,ownerid,owner_display,groupid,备注" in text
|
||||
assert "精确命中" in text
|
||||
|
||||
def test_write_out_xlsx(self, lookup_groups, tmp_path):
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
out = tmp_path / "result.xlsx"
|
||||
path = lookup_groups.write_out(
|
||||
str(out), lookup_groups.RESULT_HEADER,
|
||||
[["A", "O1", "一号", "G1", "精确命中"]])
|
||||
assert path == str(out)
|
||||
wb = openpyxl.load_workbook(str(out))
|
||||
ws = wb["result"]
|
||||
assert [c.value for c in ws[1]] == lookup_groups.RESULT_HEADER
|
||||
assert ws["A2"].value == "A" and ws["D2"].value == "G1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# selftest / CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCli:
|
||||
def test_selftest_function(self, lookup_groups, capsys):
|
||||
lookup_groups.selftest()
|
||||
out = capsys.readouterr().out
|
||||
assert "selftest OK" in out
|
||||
|
||||
def test_cli_selftest(self):
|
||||
r = run_script(LOOKUP_SCRIPT, ["--selftest"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "selftest OK" in r.stdout
|
||||
|
||||
def test_cli_noargs_runs_selftest_and_hint(self):
|
||||
r = run_script(LOOKUP_SCRIPT, [])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "selftest OK" in r.stdout
|
||||
assert "方式 A" in r.stdout and "离线回放" in r.stdout
|
||||
|
||||
def test_cli_bad_bundles_file_exits(self, tmp_path):
|
||||
r = run_script(LOOKUP_SCRIPT, [
|
||||
"--bundles", str(tmp_path / "nope.json"),
|
||||
"--names", str(tmp_path / "names.json")])
|
||||
assert r.returncode != 0
|
||||
assert "读取 bundles 失败" in r.stdout + r.stderr
|
||||
253
uv.lock
generated
253
uv.lock
generated
@@ -14,6 +14,178 @@ resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -130,6 +302,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -608,6 +789,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -617,6 +813,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "studiolift"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "playwright" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "openpyxl", specifier = ">=3.1" },
|
||||
{ name = "pandas", specifier = ">=2.0" },
|
||||
{ name = "playwright", specifier = ">=1.40" },
|
||||
{ name = "requests", specifier = ">=2.31" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
@@ -690,27 +914,10 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yt-studio-url-builder"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "playwright" },
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "openpyxl", specifier = ">=3.1" },
|
||||
{ name = "pandas", specifier = ">=2.0" },
|
||||
{ name = "playwright", specifier = ">=1.40" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8.0" }]
|
||||
|
||||
Reference in New Issue
Block a user