From 384aedd38e9bc7ba252788f5327e391533076f5e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 15:59:32 +0800 Subject: [PATCH 01/15] =?UTF-8?q?chore(shared):=20=E6=9B=B4=E6=96=B0=20Twi?= =?UTF-8?q?tter=20=E5=BD=92=E6=A1=A3=E4=B8=8E=20JWT=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index 8fc1ec6..6cf77bc 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 8fc1ec69124e5b4ea86e7343a64c38a2e148e95d +Subproject commit 6cf77bc7506d1b89afc18e52645e9f010164d554 From d5eb910289e11ad88f641bace384f32d9684162d Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 16:00:17 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(twitter):=20=E6=94=AF=E6=8C=81=20x-m?= =?UTF-8?q?onkey=20=E5=8E=9F=E7=94=9F=E5=BD=92=E6=A1=A3=E4=B8=8E=20JWT=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=BB=AD=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通过会话、查询和提交协议接收 User、Post、Media 原生事实 - 复用 Bookmark 身份与 Graph 事务,媒体只保存按需读取指针 - 将 JWT 寿命改为可配置签发默认值,并追加数据库迁移 - 补齐真实 PostgreSQL 与 HTTP 回归和分发说明 --- .changes/+twitter-archive.added.md | 1 + .env.example | 3 + app/database_contract/constants.py | 3 +- app/database_contract/lifecycle.py | 2 - app/middleware.py | 22 +- app/persistence/info_base/repository.py | 35 ++ app/settings.py | 10 + app/version.py | 2 +- docker-compose.yml | 2 + docs/30-unit-tdd/README.md | 1 + docs/30-unit-tdd/twitter-extension.md | 21 + docs/40-deployment/database-contract.md | 8 +- .../first-party-extension-distribution.md | 5 +- .../twitter/.changes/+x-monkey.added.md | 1 + extensions/twitter/__init__.py | 22 +- extensions/twitter/api.py | 474 ++++++++++----- extensions/twitter/archive_http.py | 79 +++ extensions/twitter/archive_merge.py | 79 +++ extensions/twitter/archive_schema.py | 436 ++++++++++++++ extensions/twitter/archive_session.py | 358 +++++++++++ extensions/twitter/bookmark.py | 117 ++-- extensions/twitter/pdm.lock | 512 +++++++++++++++- extensions/twitter/pyproject.toml | 4 +- extensions/twitter/reconcile.py | 346 +++++++++++ extensions/twitter/resolver.py | 153 +++++ extensions/twitter/setup_flow.py | 7 + migrations/revision-integrity.json | 1 + ...a921a95314_remove_peer_jwt_lifetime_cap.py | 87 +++ scripts/dev_database.py | 1 - .../twitter/fixtures/native_archive.json | 406 +++++++++++++ .../twitter/integration/test_archive_graph.py | 568 ++++++++++++++++++ .../test_archive_http_roundtrip.py | 156 +++++ .../twitter/test_archive_contract.py | 243 ++++++++ tests/extensions/twitter/test_archive_http.py | 256 ++++++++ 34 files changed, 4165 insertions(+), 256 deletions(-) create mode 100644 .changes/+twitter-archive.added.md create mode 100644 docs/30-unit-tdd/twitter-extension.md create mode 100644 extensions/twitter/.changes/+x-monkey.added.md create mode 100644 extensions/twitter/archive_http.py create mode 100644 extensions/twitter/archive_merge.py create mode 100644 extensions/twitter/archive_schema.py create mode 100644 extensions/twitter/archive_session.py create mode 100644 extensions/twitter/reconcile.py create mode 100644 migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py create mode 100644 tests/extensions/twitter/fixtures/native_archive.json create mode 100644 tests/extensions/twitter/integration/test_archive_graph.py create mode 100644 tests/extensions/twitter/integration/test_archive_http_roundtrip.py create mode 100644 tests/extensions/twitter/test_archive_contract.py create mode 100644 tests/extensions/twitter/test_archive_http.py diff --git a/.changes/+twitter-archive.added.md b/.changes/+twitter-archive.added.md new file mode 100644 index 0000000..92c08f8 --- /dev/null +++ b/.changes/+twitter-archive.added.md @@ -0,0 +1 @@ +新增可配置的 Peer JWT 签发默认期限并移除 admission 的强制最大寿命;Host SDK 0.3.1 支持扩展在既有 Graph 事务中批量定位原生身份并串行合并。 diff --git a/.env.example b/.env.example index 3180f29..cc8c8c6 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ PEER_NAME=core-py-development # JWT Authentication (Required) JWT_SECRET=replace-with-at-least-32-random-bytes +# Signing defaults in seconds; neither value limits admission of a valid JWT. +JWT_DEFAULT_LIFETIME_SECONDS=60 +TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS=604800 # Database lifecycle authority. Compose uses the PostgreSQL owner only in its # one-shot init service and gives the application a separate runtime URL. diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py index 3514854..984ed84 100644 --- a/app/database_contract/constants.py +++ b/app/database_contract/constants.py @@ -4,7 +4,7 @@ CONTRACT_FORMAT = 1 -CONTRACT_REVISION = "extension-peer-participation-v2" +CONTRACT_REVISION = "peer-jwt-signing-defaults-v1" PROTOCOL_SCHEMA = "inkcre" INTERNAL_SCHEMA = "inkcre_internal" @@ -18,6 +18,7 @@ JWT_ROLE = AUTHENTICATED_ROLE JWT_ISSUER = "inkcre-peer" JWT_AUDIENCE = "inkcre-api" +# Frozen input for admitted historical migrations; live admission has no lifetime cap. JWT_MAX_LIFETIME_SECONDS = 24 * 60 * 60 JWT_MINIMUM_SECRET_BYTES = 32 diff --git a/app/database_contract/lifecycle.py b/app/database_contract/lifecycle.py index f4e4e88..d0fc976 100644 --- a/app/database_contract/lifecycle.py +++ b/app/database_contract/lifecycle.py @@ -14,7 +14,6 @@ JWT_ALGORITHM, JWT_AUDIENCE, JWT_ISSUER, - JWT_MAX_LIFETIME_SECONDS, JWT_ROLE, RESET_CONFIRMATION, ) @@ -79,7 +78,6 @@ def contract_document() -> dict[str, object]: "issuer": JWT_ISSUER, "audience": JWT_AUDIENCE, "required_claims": ["role", "iss", "aud", "iat", "exp"], - "maximum_lifetime_seconds": JWT_MAX_LIFETIME_SECONDS, }, "profiles": ["runtime", "development"], "protocol": protocol_document(), diff --git a/app/middleware.py b/app/middleware.py index c9405e9..7a16662 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -1,5 +1,6 @@ """Middleware for logging and request tracking.""" +import math import time from typing import Callable import uuid @@ -13,7 +14,6 @@ JWT_ALGORITHM, JWT_AUDIENCE, JWT_ISSUER, - JWT_MAX_LIFETIME_SECONDS, JWT_ROLE, ) from app.settings import settings @@ -63,15 +63,19 @@ def decode_peer_jwt( ) if not valid_numeric_dates: raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") - issued_at_number = float(issued_at) - expires_at_number = float(expires_at) + try: + issued_at_number = float(issued_at) + expires_at_number = float(expires_at) + except OverflowError as error: + raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") from error current_epoch = time.time() if now is None else now if ( claims["role"] != JWT_ROLE + or not math.isfinite(issued_at_number) + or not math.isfinite(expires_at_number) or issued_at_number > current_epoch + 60 or expires_at_number <= current_epoch or expires_at_number <= issued_at_number - or expires_at_number - issued_at_number > JWT_MAX_LIFETIME_SECONDS ): raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") return claims @@ -81,11 +85,13 @@ def create_peer_jwt( secret: str, *, now: float | None = None, - lifetime_seconds: int = 60, + lifetime_seconds: int | None = None, ) -> str: - """Create one short-lived token under the canonical Peer HTTP contract.""" - if lifetime_seconds <= 0 or lifetime_seconds > JWT_MAX_LIFETIME_SECONDS: - raise ValueError("Peer JWT lifetime is outside the contract bound") + """Sign a Peer JWT using the configured default when no lifetime is supplied.""" + if lifetime_seconds is None: + lifetime_seconds = settings.jwt_default_lifetime_seconds + if lifetime_seconds <= 0: + raise ValueError("Peer JWT lifetime must be positive") issued_at = int(time.time() if now is None else now) return jwt.encode( { diff --git a/app/persistence/info_base/repository.py b/app/persistence/info_base/repository.py index cefa236..f87b6c0 100644 --- a/app/persistence/info_base/repository.py +++ b/app/persistence/info_base/repository.py @@ -172,6 +172,41 @@ async def find_json_field( statement = statement.where(sqlmodel.col(BlockModel.id).in_(tuple(block_ids))) return tuple(await self._session.scalars(statement)) + async def find_json_field_many( + self, + resolvers: typing.Collection[str], + field: str, + values: typing.Collection[str], + ) -> tuple[BlockModel, ...]: + """Find inline JSON records in a bounded native-identity set.""" + from sqlalchemy.dialects.postgresql import JSONB + + if not resolvers or not values: + return () + return tuple( + await self._session.scalars( + sqlmodel.select(BlockModel) + .where( + sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)), + sqlalchemy.case( + ( + sqlalchemy.func.pg_input_is_valid(BlockModel.content, "jsonb"), + sqlalchemy.cast(BlockModel.content, JSONB)[field].astext, + ) + ).in_(tuple(values)), + sqlmodel.col(BlockModel.storage).is_(None), + ) + .order_by(sqlmodel.col(BlockModel.id)) + ) + ) + + async def acquire_transaction_lock(self, namespace: str) -> None: + """Serialize an owning use case until its surrounding transaction ends.""" + await self._session.execute( + sqlalchemy.text("SELECT pg_advisory_xact_lock(hashtextextended(:name, 0))"), + {"name": namespace}, + ) + async def get_related( self, block_id: BlockID, *, content: str, outgoing: bool = True ) -> BlockModel | None: diff --git a/app/settings.py b/app/settings.py index 8c392cf..88d3ff2 100644 --- a/app/settings.py +++ b/app/settings.py @@ -52,6 +52,16 @@ class Settings(BaseSettings): min_length=JWT_MINIMUM_SECRET_BYTES, description="Secret key for JWT token signing and verification", ) + jwt_default_lifetime_seconds: int = Field( + default=60, + gt=0, + description="Default Peer JWT signing lifetime; not an admission limit", + ) + twitter_x_monkey_jwt_lifetime_seconds: int = Field( + default=7 * 24 * 60 * 60, + gt=0, + description="Signing lifetime for the Twitter x-monkey session and its renewal", + ) # Observability settings obsrv: ObsrvSetting = Field(default_factory=ObsrvSetting) diff --git a/app/version.py b/app/version.py index 1ca57f6..15b5af5 100644 --- a/app/version.py +++ b/app/version.py @@ -3,7 +3,7 @@ from pathlib import Path import tomllib -CORE_VERSION = "0.3.0" +CORE_VERSION = "0.3.1" APPLICATION_VERSION = tomllib.loads( (Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8") )["project"]["version"] diff --git a/docker-compose.yml b/docker-compose.yml index cd94936..165953d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,8 @@ services: DATABASE_URL: "postgresql+psycopg://inkcre_core:${CORE_DATABASE_PASSWORD:-local-core-database-password-at-least-32-bytes}@postgres:5432/${POSTGRES_DB:-inkcre}" INKCRE_ENV_FILE: "" JWT_SECRET: ${JWT_SECRET:-local-development-jwt-secret-at-least-32-bytes} + JWT_DEFAULT_LIFETIME_SECONDS: ${JWT_DEFAULT_LIFETIME_SECONDS:-60} + TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS: ${TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS:-604800} OBSRV__LOGGING_BACKEND: ${OBSRV__LOGGING_BACKEND:-postgresql} OBSRV__TELEMETRY_ENABLED: ${OBSRV__TELEMETRY_ENABLED:-false} OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-} diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 0dcb2f4..8a5ef9c 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -13,6 +13,7 @@ This directory owns expensive internal design truth for logical units delivered | [mail-extension.md](mail-extension.md) | Mail identity, MIME materialization, collection, graph, and failure boundaries | | [memos-extension.md](memos-extension.md) | Memos adapter identity, graph grammar, persistence, and failure boundaries | | [rss-extension.md](rss-extension.md) | RSS adapter identity, collection lifecycle, reconciliation, and materialization | +| [twitter-extension.md](twitter-extension.md) | Twitter 共同身份、Bookmark 过渡、事务合并和按需媒体读取 | | [security-model.md](security-model.md) | Core-py realization of the shared security boundary model | Do not add a document for a directory inventory, a fact cheaply recovered from code, a one-off decision, or a cross-unit contract already owned by `../_shared/20-product-tdd/`. Put repeated physical-subtree hazards in the nearest `AGENTS.md`; put runtime, rollout, recovery, and environment truth in `../40-deployment/`. diff --git a/docs/30-unit-tdd/twitter-extension.md b/docs/30-unit-tdd/twitter-extension.md new file mode 100644 index 0000000..b81f4e3 --- /dev/null +++ b/docs/30-unit-tdd/twitter-extension.md @@ -0,0 +1,21 @@ +# Python Twitter 采集与归档 + +Python Twitter distribution 把 x-monkey 提交和 Bookmark 采集写入同一组普通 Blocks。原生字段与跨分发关系以 [Twitter 归档契约](../_shared/20-product-tdd/twitter-archive-contract.md) 为准;`archive_schema.py` 校验 native wire,`reconcile.py` 拥有本实现的身份选择、事务合并和关系维护。数据库没有另一套归档表。 + +一次 submit 使用一个 `graph_uow()`。Bookmark 先完成 provider 请求,再进入 `source_uow()`,锁定 Source,使用其 `graph` 合并新主帖及实际捕获的上下文,并在同一事务内推进 cursor。主帖顺序与展开上下文分开,因此引用帖不会成为下一次书签采集的 watermark;旧的数值 cursor 在比较边界转换为十进制字符串,不重置历史状态。 + +两条写入路径都先取得 `extensions.twitter.reconcile` 全局事务 advisory 锁,再批量重读原生身份。根内容和受管关系一起提交;异常让整批回滚,调用方可安全重放。该锁仅约束正常 Twitter 写入入口,不能替代所有 info-base 原始写入的约束。provider 和媒体网络访问都不能放进此事务。 + +匹配先选新版 resolver,再按 Block ID 稳定选择旧 `tweet.v1`。旧根重新采集后原地更新,保留现有 Block ID、引用和旧附件关系;未重新采集的旧根仍由 v1 decoder 读取。旧加工正文不足以重建 native RichText,不能用它伪造新记录。Bookmark provider 适配保留实际源正文;共同合并在已有 native 正文时不让较少详情的 Bookmark 正文替换它,其他可证明字段仍可补充。x-monkey 提交继续使用原生稀疏合并语义。 + +身份引用严格保存为 `{id}`,不补作者、时间或正文。query 对完整 native 记录返回 `found`,身份引用返回 `missing`,能按 ID 定位但结构损坏的记录返回 `failed`。批量身份查询使用有效 JSON 的条件表达式隔离普通文本和损坏 JSON;无法解析且无法定位 ID 的内容不会命中某个原生 ID,不会导致其他正常记录的查询失败。 + +原生观察时间保存输入字符串,不在序列化时改写精度。逐计数比较遵守上游的毫秒时间比较规则;较旧观察不覆盖新值,相同值的新时间也更新持久记录,无时间的新值删除该计数的旧时间标签。`unchanged` 表示没有持久变化,因此仅观察时间更新仍为 `accepted`。 + +新版引用边只维护约定的 reference schema,旧 `attachment:*`、`entities:url` 和用户关系保留。媒体引用顺序是 `post.mediaIds`、文章封面、`article.mediaIds` 依次合并去重,首次出现保留。Media 根通过 `content` 指向普通图片或视频内容 Block;关系改变不删除仍可能被别处引用的内容 Block。 + +媒体原生 URL、variants 和 alt 留在元数据中。只有 HTTPS 的 `pbs.twimg.com`、`video.twimg.com` 地址能够形成 HTTP Storage 指针;视频和 GIF 选择已捕获的最高 bitrate MP4,HLS 仅保留元数据。没有合适地址时仍保存原生事实,但不制造可读取的内容指针。保存、query、文本投影和索引不展开媒体;用户读取内容 Block 时才由现有 HTTP Storage 应用超时和体积限制。本版本不保存媒体本体,也不创建下载 Job,外部来源失效后仍可读取文字与元数据。 + +Python 的 v2 Post 文本投影包含原帖 ID、链接、已捕获作者、正文和长文;User/Media 投影使用自身已经捕获的文字。label 保持 Block-local,不为投影或标签下载媒体。检索沿用现有独立索引维护流程,不在提交中隐式维护索引。 + +数据回归入口为 `pdm run python -m pytest -q tests/extensions/twitter`。真实数据库场景要求显式提供 `INKCRE_TEST_DATABASE_URL`,使用本任务独占、已迁移的可丢弃 PostgreSQL runtime;测试只清理自己创建的精确 IDs,不重置开发库。固定 fixture 同时用于原生 roundtrip 和独立上游合同验证,真实 userscript、HTTP 会话及 Web 消费仍需要各自的纵向验收。 diff --git a/docs/40-deployment/database-contract.md b/docs/40-deployment/database-contract.md index b46e032..7d52dc4 100644 --- a/docs/40-deployment/database-contract.md +++ b/docs/40-deployment/database-contract.md @@ -122,13 +122,17 @@ role: authenticated issuer: inkcre-peer audience: inkcre-api required: role, iss, aud, iat, exp -maximum lifetime: 24 hours future iat tolerance: 60 seconds ``` PostgREST exposes only `inkcre`,connects as `authenticator`,uses denied `anonymous` fallback,and invokes `inkcre_internal.check_jwt` before every admitted request。FastAPI peer middleware validates the same vectors。 +JWT 没有强制最大寿命。`JWT_DEFAULT_LIFETIME_SECONDS` 是 Core 签发器的默认值(60 秒), +`TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS` 是 Twitter x-monkey 会话初次签发及续签的默认值(604800 秒,7 天); +二者必须为正整数,不参与 token admission。调整默认值影响后续签发,不会撤销已签发的有效 token。 +普通 Web Peer 仍默认签发 24 小时 token。有效期、签名、必需 claims 和未来 iat 容差仍参与验证。 + ## Canonical Production Profile [`deploy/profiles/production.json`](../../deploy/profiles/production.json) 是 checked-in non-secret discovery surface。 @@ -140,7 +144,7 @@ implementation 或 migration verification 不授权修改 production profile 或 ## Versioning -`db contract --json` reports `peer-database-runtime-v3` plus the image source revision. +`db contract --json` reports the executable contract revision plus the image source revision. Every production candidate also contains `/app/database-contract/`: - `database-roles.sql` recreates the password-free global principals required by policies; diff --git a/docs/40-deployment/first-party-extension-distribution.md b/docs/40-deployment/first-party-extension-distribution.md index fdd6992..3f70749 100644 --- a/docs/40-deployment/first-party-extension-distribution.md +++ b/docs/40-deployment/first-party-extension-distribution.md @@ -147,7 +147,10 @@ Core Host SDK 0.3 adds `app.http.get_public_http_base_url()` without changing th or persistence API. Its authority is `app/version.py`, independently of the Core service package version in `pyproject.toml`; preparing a service Release PR does not change the Host SDK version. -GitHub, Learn English, Mail, RSS, Telegram, and Twitter retain their SDK 0.2 lower bound and support +Core Host SDK 0.3.1 adds batched JSON identity lookup and transaction advisory locking to the +existing Graph repository. Twitter archive reconciliation requires `>=0.3.1 <0.4.0`. + +GitHub, Learn English, Mail, RSS, and Telegram retain their SDK 0.2 lower bound and support SDK 0.3 through `>=0.2.0 <0.4.0`. Memos uses the new HTTP address API and requires `>=0.3.0 <0.4.0`. Compatibility metadata changes require new immutable Extension releases; an old wheel's range must not be widened. Before upgrading a deployment, disable affected diff --git a/extensions/twitter/.changes/+x-monkey.added.md b/extensions/twitter/.changes/+x-monkey.added.md new file mode 100644 index 0000000..16287f1 --- /dev/null +++ b/extensions/twitter/.changes/+x-monkey.added.md @@ -0,0 +1 @@ +支持以现有 JWT 连接 x-monkey,保存与合并用户、帖子和媒体元数据,与 Bookmark 共用原生身份;媒体保持按需读取,会话默认七天并自动续签。 diff --git a/extensions/twitter/__init__.py b/extensions/twitter/__init__.py index 68fd37e..d0764ef 100644 --- a/extensions/twitter/__init__.py +++ b/extensions/twitter/__init__.py @@ -33,9 +33,17 @@ class Extension( ): @classmethod def _init_resolvers(cls): - from .resolver import TweetResolver + from .resolver import ( + ArchiveMediaResolver, + ArchivePostResolver, + ArchiveUserResolver, + TweetResolver, + ) ResolverManager.register_resolver(TweetResolver) + ResolverManager.register_resolver(ArchivePostResolver) + ResolverManager.register_resolver(ArchiveUserResolver) + ResolverManager.register_resolver(ArchiveMediaResolver) @classmethod def _init_sources(cls): @@ -71,8 +79,10 @@ async def on_start_async(cls, app): @classmethod def _register_apis(cls, router: APIRouter): from .setup_flow import register_setup_routes + from .archive_http import router as archive_http_router register_setup_routes(router) + router.include_router(archive_http_router, prefix="/x-monkey") @classmethod def api_dependencies(cls) -> list[typing.Any]: @@ -87,7 +97,15 @@ def peer_inbounds(cls) -> tuple[typing.Any, ...]: @classmethod def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: - return (PublicHTTPRoute(method="GET", path="/auth/callback"),) + return ( + PublicHTTPRoute(method="GET", path="/auth/callback"), + PublicHTTPRoute(method="POST", path="/x-monkey/session"), + PublicHTTPRoute(method="GET", path="/x-monkey/account"), + PublicHTTPRoute(method="POST", path="/x-monkey/disconnect"), + PublicHTTPRoute(method="GET", path="/x-monkey/api/v1/session"), + PublicHTTPRoute(method="POST", path="/x-monkey/api/v1/archive/query"), + PublicHTTPRoute(method="POST", path="/x-monkey/api/v1/archive/submit"), + ) @classmethod async def update_config_async( diff --git a/extensions/twitter/api.py b/extensions/twitter/api.py index 90ca397..828206c 100644 --- a/extensions/twitter/api.py +++ b/extensions/twitter/api.py @@ -3,6 +3,8 @@ import abc import asyncio import datetime +from email.utils import parsedate_to_datetime +import html from pathlib import Path import re import typing @@ -14,34 +16,274 @@ import twikit import twikit.media from typing import Optional as Opt -from dd import dd from utils.datetime_ import get_timestamp -from .schema import TweetPhoto, TweetVideo, VideoVariant +from .archive_schema import ArchiveBatch from . import Extension -class Tweet(sqlmodel.SQLModel): - id: int - user_id: str | None = None - """推文用户ID""" - conversation_id: Opt[int] = None - text: str - """推文文本内容 - - - 移除回复提及 - - 移除媒体、网页链接(用 `[photo]`、`[video]`、`[link]` 占位) - """ - quote: Opt[int] = None - """引用推文ID""" - photos: tuple[TweetPhoto, ...] = () - videos: tuple[TweetVideo, ...] = () - urls: tuple[str, ...] = () - - class TwitterAPIResult(sqlmodel.SQLModel): next_page: Opt[str] = None previous_page: Opt[str] = None - tweets: tuple[Tweet, ...] = () + batch: ArchiveBatch = pydantic.Field(default_factory=ArchiveBatch) + primary_post_ids: tuple[str, ...] = () + + +def _instant(value: str | datetime.datetime) -> str: + if isinstance(value, str): + try: + value = datetime.datetime.fromisoformat(value) + except ValueError: + value = parsedate_to_datetime(value) + if value.tzinfo is None: + raise ValueError("Twitter provider returned a timestamp without a timezone") + return ( + value.astimezone(datetime.timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + + +def _observed_at() -> str: + return ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat(timespec="milliseconds") + .replace("+00:00", "Z") + ) + + +def _media_id(media: dict) -> str | None: + if media.get("id"): + return str(media["id"]) + match = re.fullmatch(r"(?:3|7|13)_([1-9][0-9]*)", media.get("media_key", "")) + return match[1] if match else None + + +def _provider_counts(raw: dict, names: dict[str, str], observed_at: str) -> dict: + values = { + name: raw[source] for source, name in names.items() if raw.get(source) is not None + } + return ( + {"counts": values, "countsObservedAt": {name: observed_at for name in values}} + if values + else {} + ) + + +def _official_archive( + raw_tweets: list[dict], includes: dict, observed_at: str +) -> ArchiveBatch: + media = [] + media_ids = {} + for raw in includes.get("media", ()): + media_id = _media_id(raw) + if media_id is None: + continue + media_ids[raw["media_key"]] = media_id + kind = {"photo": "photo", "video": "video", "animated_gif": "gif"}.get(raw["type"]) + url = raw.get("url") if kind == "photo" else raw.get("preview_image_url") + if kind is None or not url: + continue + item = {"id": media_id, "type": kind, "url": url} + item.update( + { + target: raw[source] + for source, target in { + "width": "width", + "height": "height", + "alt_text": "alt", + }.items() + if raw.get(source) is not None + } + ) + if raw.get("variants"): + item["video"] = { + "variants": [ + { + "url": variant["url"], + "type": variant["content_type"], + **({"bitrate": variant["bit_rate"]} if "bit_rate" in variant else {}), + } + for variant in raw["variants"] + ] + } + if raw.get("duration_ms") is not None: + item["video"]["durationMs"] = raw["duration_ms"] + media.append(item) + users = [] + for raw in includes.get("users", ()): + user = {"id": raw["id"], "handle": raw["username"], "name": raw["name"]} + user.update( + { + target: raw[source] + for source, target in { + "profile_image_url": "avatarUrl", + "location": "location", + "url": "website", + "protected": "protected", + }.items() + if raw.get(source) is not None + } + ) + if "description" in raw: + user["bio"] = {"text": html.unescape(raw["description"])} + if "created_at" in raw: + user["createdAt"] = _instant(raw["created_at"]) + user.update( + _provider_counts( + raw.get("public_metrics", {}), + { + "followers_count": "followers", + "following_count": "following", + "tweet_count": "posts", + }, + observed_at, + ) + ) + users.append(user) + posts = [] + for raw in [*raw_tweets, *includes.get("tweets", ())]: + post = { + "id": raw["id"], + "authorId": raw["author_id"], + "createdAt": _instant(raw["created_at"]), + } + references = { + reference["type"]: reference["id"] for reference in raw.get("referenced_tweets", ()) + } + if "retweeted" in references: + post["repostOf"] = references["retweeted"] + else: + post["text"] = { + "text": html.unescape(raw.get("note_tweet", {}).get("text", raw["text"])) + } + if "replied_to" in references: + post["replyTo"] = {"postId": references["replied_to"]} + if "quoted" in references: + post["quote"] = {"postId": references["quoted"]} + if raw.get("conversation_id") and raw["conversation_id"] != raw["id"]: + post["conversationId"] = raw["conversation_id"] + post.update( + { + target: raw[source] + for source, target in {"lang": "lang", "possibly_sensitive": "sensitive"}.items() + if raw.get(source) is not None + } + ) + attached = [ + media_ids[key] + for key in raw.get("attachments", {}).get("media_keys", ()) + if key in media_ids + ] + if attached: + post["mediaIds"] = attached + post.update( + _provider_counts( + raw.get("public_metrics", {}), + { + "reply_count": "replies", + "retweet_count": "reposts", + "quote_count": "quotes", + "like_count": "likes", + "bookmark_count": "bookmarks", + "impression_count": "views", + }, + observed_at, + ) + ) + posts.append(post) + return ArchiveBatch.model_validate({"users": users, "posts": posts, "media": media}) + + +def _twikit_archive( + tweets: typing.Iterable[twikit.Tweet], observed_at: str +) -> ArchiveBatch: + posts: dict[str, dict] = {} + users: dict[str, dict] = {} + media: dict[str, dict] = {} + + def collect(tweet: twikit.Tweet) -> None: + if tweet.id in posts: + return + user = tweet.user + post: dict[str, typing.Any] = { + "id": tweet.id, + "authorId": user.id, + "createdAt": _instant(tweet.created_at_datetime), + } + posts[tweet.id] = post + users[user.id] = {"id": user.id, "handle": user.screen_name, "name": user.name} + if tweet.retweeted_tweet is not None: + post["repostOf"] = tweet.retweeted_tweet.id + collect(tweet.retweeted_tweet) + return + post["text"] = {"text": html.unescape(tweet.full_text)} + if tweet.in_reply_to: + post["replyTo"] = {"postId": tweet.in_reply_to} + conversation_id = tweet._legacy.get("conversation_id_str") + if conversation_id and conversation_id != tweet.id: + post["conversationId"] = conversation_id + if tweet.quote is not None: + post["quote"] = {"postId": tweet.quote.id} + collect(tweet.quote) + if tweet.lang: + post["lang"] = tweet.lang + post.update( + _provider_counts( + tweet._legacy, + { + "reply_count": "replies", + "retweet_count": "reposts", + "quote_count": "quotes", + "favorite_count": "likes", + "bookmark_count": "bookmarks", + }, + observed_at, + ) + ) + attached = [] + for item in tweet.media: + kind = {"photo": "photo", "video": "video", "animated_gif": "gif"}.get(item.type) + if kind is None or not item.id or not item.media_url: + continue + value: dict[str, typing.Any] = {"id": item.id, "type": kind, "url": item.media_url} + original = item._data.get("original_info") or {} + value.update( + { + name: original[name] + for name in ("width", "height") + if original.get(name) is not None + } + ) + if item._data.get("ext_alt_text") is not None: + value["alt"] = item._data["ext_alt_text"] + info = item._data.get("video_info") or {} + if info.get("variants"): + value["video"] = { + "variants": [ + { + "url": variant["url"], + "type": variant["content_type"], + **({"bitrate": variant["bitrate"]} if "bitrate" in variant else {}), + } + for variant in info["variants"] + ] + } + if info.get("duration_millis") is not None: + value["video"]["durationMs"] = info["duration_millis"] + media[item.id] = value + attached.append(item.id) + if attached: + post["mediaIds"] = attached + + for tweet in tweets: + collect(tweet) + return ArchiveBatch.model_validate( + { + "users": list(users.values()), + "posts": list(posts.values()), + "media": list(media.values()), + } + ) class TwitterAPI(abc.ABC): @@ -359,74 +601,8 @@ async def get_user(self) -> tuple[str, str]: def _resolve_tweets( self, raw_tweets: list[dict], includes: dict[str, list[dict]] - ) -> list[Tweet]: - include_medias = includes.get("media", ()) - - tweets: list[Tweet] = [] - for tweet in raw_tweets: - tweet = dd(tweet) - tweet_id = tweet.id() - - # resolve medias - media_keys = tweet._.attachments.media_keys() or () - photos: list[TweetPhoto] = [] - videos: list[TweetVideo] = [] - for media_key in media_keys: - for include_media in include_medias: - include_media = dd(include_media) - if include_media._.media_key() == media_key: - media_type = include_media._.type() - if media_type == "video": - videos.append( - TweetVideo( - id=media_key, - variants=tuple( - VideoVariant(**variant) - for variant in (include_media._.variants() or ()) - ), - ) - ) - elif media_type == "photo": - photos.append( - TweetPhoto( - id=media_key, - url=include_media.url(lambda x: x or ""), - ) - ) - else: - # TODO log warning for unsupported media type - pass - break - - # resolve conversation ID - conversation_id = tweet._.conversation_id() - if conversation_id == tweet_id: - conversation_id = None - - # resolve url entities - urls: list[str] = [] - for entity in tweet._.entities.urls() or []: - url = entity.get("expanded_url") - if url: - urls.append(url) - - # resolve text - tweet_text = tweet.text() - tweet_text = re.sub(r"^(?:@\w+\s*)+", "", tweet_text) - - tweets.append( - Tweet( - id=tweet_id, - user_id=tweet._.author_id(), - text=tweet_text, - conversation_id=conversation_id, - photos=tuple(photos), - videos=tuple(videos), - urls=tuple(urls), - ) - ) - - return tweets + ) -> ArchiveBatch: + return _official_archive(raw_tweets, includes, _observed_at()) async def get_bookmarks( self, max_results: int = 20, page: str | None = None @@ -440,9 +616,21 @@ async def get_bookmarks( """ bookmarks_query = { "max_results": max_results, - "tweet.fields": "attachments,entities,lang,conversation_id", - "media.fields": "alt_text,media_key,url,type", - "expansions": "attachments.media_keys,attachments.media_source_tweet", + "tweet.fields": ( + "author_id,created_at,attachments,entities,lang,conversation_id," + "referenced_tweets,public_metrics,possibly_sensitive,note_tweet" + ), + "media.fields": ( + "alt_text,media_key,url,type,preview_image_url,width,height,duration_ms,variants" + ), + "user.fields": ( + "id,name,username,description,profile_image_url,url," + "protected,location,public_metrics,created_at" + ), + "expansions": ( + "author_id,attachments.media_keys,referenced_tweets.id," + "referenced_tweets.id.author_id" + ), } if page: bookmarks_query["pagination_token"] = page @@ -452,11 +640,12 @@ async def get_bookmarks( path_params={"id": self.__user_id}, query=bookmarks_query, ) - tweets = self._resolve_tweets(res.get("data", []), res.get("includes", {})) + batch = self._resolve_tweets(res.get("data", []), res.get("includes", {})) return TwitterAPIResult( next_page=res.get("meta", {}).get("next_token"), previous_page=res.get("meta", {}).get("previous_token"), - tweets=tuple(tweets), + batch=batch, + primary_post_ids=tuple(post["id"] for post in res.get("data", ())), ) async def get_tweets( @@ -468,16 +657,29 @@ async def get_tweets( query={ "query": query, "max_results": max_results, - "tweet.fields": "attachments,entities,lang,conversation_id", - "media.fields": "alt_text,media_key,url,type", - "expansions": "attachments.media_keys,attachments.media_source_tweet", + "tweet.fields": ( + "author_id,created_at,attachments,entities,lang,conversation_id," + "referenced_tweets,public_metrics,possibly_sensitive,note_tweet" + ), + "media.fields": ( + "alt_text,media_key,url,type,preview_image_url,width,height,duration_ms,variants" + ), + "user.fields": ( + "id,name,username,description,profile_image_url,url," + "protected,location,public_metrics,created_at" + ), + "expansions": ( + "author_id,attachments.media_keys,referenced_tweets.id," + "referenced_tweets.id.author_id" + ), }, ) - tweets = self._resolve_tweets(res.get("data", []), res.get("includes", {})) + batch = self._resolve_tweets(res.get("data", []), res.get("includes", {})) return TwitterAPIResult( next_page=res.get("meta", {}).get("next_token"), previous_page=res.get("meta", {}).get("previous_token"), - tweets=tuple(tweets), + batch=batch, + primary_post_ids=tuple(post["id"] for post in res.get("data", ())), ) async def get_replies( @@ -550,59 +752,8 @@ async def _login(self): ) @staticmethod - def _resolve_tweet(tweet: twikit.Tweet) -> Tweet: - # resolve text - # remove reply mentions - tweet_text = re.sub(r"^(?:@\w+\s*)+", "", tweet.text) - - # resolve medias - photos: list[TweetPhoto] = [] - videos: list[TweetVideo] = [] - for media in tweet.media: - media_url = media.url - if isinstance(media, twikit.media.Photo): - photos.append(TweetPhoto(id=media.id, url=media.media_url)) - tweet_text = tweet_text.replace(media_url, "[photo]") - elif isinstance(media, twikit.media.Video): - videos.append( - TweetVideo( - id=media.id, - variants=tuple( - VideoVariant( - bitrate=variant.bitrate, - content_type=variant.content_type, - url=variant.url or "", - ) - for variant in media.streams - ), - ) - ) - tweet_text = tweet_text.replace(media_url, "[video]") - - # resolve urls - urls: list[str] = [] - for i in typing.cast(dict, tweet.urls): - i = dd(i) - ex_url = i._.expanded_url() - url = i._.url() - if ex_url: - urls.append(ex_url) - if url: - tweet_text = tweet_text.replace(url, "[link]") - - return Tweet( - id=int(tweet.id), - conversation_id=int(tweet.in_reply_to) if tweet.in_reply_to else None, - user_id=tweet.user.screen_name, - text=tweet_text, - photos=tuple(photos), - videos=tuple(videos), - urls=tuple(urls), - ) - - @classmethod - def _resolve_tweets(cls, result: twikit.utils.Result[twikit.Tweet]) -> tuple[Tweet, ...]: - return tuple(cls._resolve_tweet(tweet) for tweet in result) + def _resolve_tweets(result: typing.Iterable[twikit.Tweet]) -> ArchiveBatch: + return _twikit_archive(result, _observed_at()) async def get_bookmarks( self, max_results: int = 20, page: str | None = None @@ -613,7 +764,8 @@ async def get_bookmarks( return TwitterAPIResult( next_page=res.next_cursor, previous_page=res.previous_cursor, - tweets=tuple(self._resolve_tweets(res)), + batch=self._resolve_tweets(res), + primary_post_ids=tuple(tweet.id for tweet in res), ) async def get_tweets( @@ -635,17 +787,18 @@ async def get_tweets( return TwitterAPIResult( next_page=res.next_cursor, previous_page=res.previous_cursor, - tweets=tuple(self._resolve_tweets(res)), + batch=self._resolve_tweets(res), + primary_post_ids=tuple(tweet.id for tweet in res), ) async def _get_a_reply_of( self, from_: str, replies: twikit.utils.Result[twikit.Tweet] - ) -> Tweet | None: + ) -> twikit.Tweet | None: if len(replies) == 0: return None for reply in replies: if reply.user.screen_name == from_: - return self._resolve_tweet(reply) + return reply else: await asyncio.sleep(5) # avoid rate limit replies = await replies.next() @@ -654,7 +807,7 @@ async def _get_a_reply_of( async def get_replies( self, *conversation_ids: str, from_: str | None = None, max_results: int = 20 ) -> TwitterAPIResult: - res_tweets: list[Tweet] = [] + res_tweets: list[twikit.Tweet] = [] for cid in conversation_ids: await asyncio.sleep(3) # avoid rate limit try: @@ -672,6 +825,9 @@ async def get_replies( if the_reply: res_tweets.append(the_reply) else: - res_tweets.extend(self._resolve_tweets(replies)) + res_tweets.extend(replies) - return TwitterAPIResult(tweets=tuple(res_tweets)) + return TwitterAPIResult( + batch=self._resolve_tweets(res_tweets), + primary_post_ids=tuple(tweet.id for tweet in res_tweets), + ) diff --git a/extensions/twitter/archive_http.py b/extensions/twitter/archive_http.py new file mode 100644 index 0000000..331b1b2 --- /dev/null +++ b/extensions/twitter/archive_http.py @@ -0,0 +1,79 @@ +"""HTTP endpoints implementing the x-monkey remote archive contract.""" + +from __future__ import annotations + +import fastapi +from fastapi import Request, Response + +from .archive_schema import ArchiveBatch, ArchiveQuery, QueryResponse, SubmitResponse +from .archive_session import ( + SESSION_COOKIE_NAME, + SESSION_USE_CLAIM, + SESSION_USE_TWITTER_ARCHIVE, + _decode_peer_token, + renew_session_if_needed, + validate_archive_request_source, + x_monkey_base_url, +) +from .reconcile import query_archive, submit_archive + +router = fastapi.APIRouter() +CLIENT_HEADER = "x-tweet-db-client" +CLIENT_NAME = "x-monkey" + + +def _protocol_error(status_code: int, message: str) -> fastapi.responses.JSONResponse: + return fastapi.responses.JSONResponse( + {"error": message}, + status_code=status_code, + headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}, + ) + + +async def _authorize_archive_request(request: Request, response: Response) -> int | None: + if not await validate_archive_request_source(request): + return 403 + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type != "application/json": + return 415 + if request.headers.get(CLIENT_HEADER) != CLIENT_NAME: + return 400 + token = request.cookies.get(SESSION_COOKIE_NAME) + claims = _decode_peer_token(token) if token else None + if claims is None or claims.get(SESSION_USE_CLAIM) != SESSION_USE_TWITTER_ARCHIVE: + return 401 + base_url = await x_monkey_base_url() + if base_url: + renew_session_if_needed(response, claims, base_url=base_url) + return None + + +@router.post( + "/api/v1/archive/query", + response_model=QueryResponse, + response_model_exclude_unset=True, +) +async def archive_query(body: ArchiveQuery, request: Request, response: Response): + failure = await _authorize_archive_request(request, response) + if failure is not None: + return _protocol_error(failure, "archive session or request headers are invalid") + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return await query_archive(body) + + +@router.post( + "/api/v1/archive/submit", + response_model=SubmitResponse, + response_model_exclude_unset=True, +) +async def archive_submit(body: ArchiveBatch, request: Request, response: Response): + failure = await _authorize_archive_request(request, response) + if failure is not None: + return _protocol_error(failure, "archive session or request headers are invalid") + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + return await submit_archive(body) + + +__all__ = ["CLIENT_HEADER", "CLIENT_NAME", "router"] diff --git a/extensions/twitter/archive_merge.py b/extensions/twitter/archive_merge.py new file mode 100644 index 0000000..a22ec05 --- /dev/null +++ b/extensions/twitter/archive_merge.py @@ -0,0 +1,79 @@ +"""Presence-aware native archive merge, including per-counter capture times.""" + +from __future__ import annotations + +import copy +import datetime + +from pydantic import JsonValue + + +def _merge_content(target: dict[str, JsonValue], incoming: dict[str, JsonValue]) -> None: + for key, value in incoming.items(): + current = target.get(key) + if isinstance(value, dict) and isinstance(current, dict): + _merge_content(current, value) + else: + target[key] = copy.deepcopy(value) + + +def _observation_time(value: str) -> datetime.datetime: + parsed = datetime.datetime.fromisoformat(value) + # JavaScript Date.parse compares capture times at millisecond precision. + return parsed.replace(microsecond=parsed.microsecond // 1000 * 1000) + + +def merge_archive_record( + existing: dict[str, JsonValue], incoming: dict[str, JsonValue] +) -> dict[str, JsonValue]: + """Return a detached merge; delayed counter observations cannot replace newer ones.""" + result = copy.deepcopy(existing) + _merge_content( + result, + { + key: value + for key, value in incoming.items() + if key not in {"counts", "countsObservedAt"} + }, + ) + counts = incoming.get("counts") + if not isinstance(counts, dict): + return result + + current_counts = result.get("counts") + if not isinstance(current_counts, dict): + current_counts = {} + current_times = result.get("countsObservedAt") + if not isinstance(current_times, dict): + current_times = {} + incoming_times = incoming.get("countsObservedAt") + if not isinstance(incoming_times, dict): + incoming_times = {} + + for key, value in counts.items(): + observed_at = incoming_times.get(key) + current_time = current_times.get(key) + if ( + isinstance(observed_at, str) + and isinstance(current_time, str) + and _observation_time(observed_at) < _observation_time(current_time) + ): + continue + count_changed = key not in current_counts or current_counts[key] != value + if count_changed: + current_counts[key] = copy.deepcopy(value) + if isinstance(observed_at, str): + current_times[key] = observed_at + elif count_changed: + current_times.pop(key, None) + + if current_counts: + result["counts"] = current_counts + if current_times: + result["countsObservedAt"] = current_times + else: + result.pop("countsObservedAt", None) + return result + + +__all__ = ["merge_archive_record"] diff --git a/extensions/twitter/archive_schema.py b/extensions/twitter/archive_schema.py new file mode 100644 index 0000000..1053220 --- /dev/null +++ b/extensions/twitter/archive_schema.py @@ -0,0 +1,436 @@ +"""Native x-monkey archive records and remote database wire contracts.""" + +from __future__ import annotations + +import datetime +import re +from typing import Annotated, Generic, Literal, TypeVar + +import pydantic + + +ArchiveID = Annotated[str, pydantic.Field(strict=True, pattern=r"^[1-9][0-9]*$")] +Count = Annotated[int, pydantic.Field(strict=True, ge=0)] +Number = Annotated[int | float, pydantic.Field(allow_inf_nan=False)] + + +def _validate_instant(value: str) -> str: + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", value): + raise ValueError("must be a UTC timestamp with second precision") + datetime.datetime.fromisoformat(value) + return value + + +def _validate_observed_at(value: str) -> str: + if not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z", value + ): + raise ValueError("must be a UTC ISO-8601 timestamp including seconds") + datetime.datetime.fromisoformat(value) + return value + + +Instant = Annotated[str, pydantic.AfterValidator(_validate_instant)] +ObservedAt = Annotated[str, pydantic.AfterValidator(_validate_observed_at)] + + +def _validate_range(value: tuple[int, int]) -> tuple[int, int]: + if value[1] < value[0]: + raise ValueError("range end before start") + return value + + +Range = Annotated[ + tuple[Count, Count], + pydantic.Field(strict=False), + pydantic.AfterValidator(_validate_range), +] +Rect = Annotated[tuple[Count, Count, Count, Count], pydantic.Field(strict=False)] + + +class _WireModel(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) + + @pydantic.field_validator("*") + @classmethod + def reject_explicit_null(cls, value: object) -> object: + if value is None: + raise ValueError("omit unknown fields instead of sending null") + return value + + +class UrlEntity(_WireModel): + type: Literal["url"] + range: Range + expanded: str + + +class MentionEntity(_WireModel): + type: Literal["mention"] + range: Range + userId: ArchiveID + + +class HashtagEntity(_WireModel): + type: Literal["hashtag"] + range: Range + + +class CashtagEntity(_WireModel): + type: Literal["cashtag"] + range: Range + name: str | None = None + ticker: str | None = None + + +class TimestampEntity(_WireModel): + type: Literal["timestamp"] + range: Range + + +Entity = Annotated[ + UrlEntity | MentionEntity | HashtagEntity | CashtagEntity | TimestampEntity, + pydantic.Field(discriminator="type"), +] + + +class TextStyle(_WireModel): + range: Range + styles: list[str] = pydantic.Field(min_length=1) + + +class RichText(_WireModel): + text: str + visible: Range | None = None + entities: list[Entity] | None = pydantic.Field(default=None, min_length=1) + styles: list[TextStyle] | None = pydantic.Field(default=None, min_length=1) + + +class UserCounts(_WireModel): + followers: Count | None = None + following: Count | None = None + posts: Count | None = None + mediaPosts: Count | None = None + likes: Count | None = None + + +class UserCountsObservedAt(_WireModel): + followers: ObservedAt | None = None + following: ObservedAt | None = None + posts: ObservedAt | None = None + mediaPosts: ObservedAt | None = None + likes: ObservedAt | None = None + + +class Affiliation(_WireModel): + name: str + url: str | None = None + badgeUrl: str | None = None + kind: str | None = None + ownerId: ArchiveID | None = None + + +class Verification(_WireModel): + blue: bool | None = None + type: str | None = None + + +class Professional(_WireModel): + type: str | None = None + categories: list[str] | None = pydantic.Field(default=None, min_length=1) + + +class ArchiveUser(_WireModel): + id: ArchiveID + handle: str + name: str + createdAt: Instant | None = None + avatarUrl: str | None = None + avatarShape: str | None = None + bannerUrl: str | None = None + bio: RichText | None = None + location: str | None = None + website: str | None = None + pinnedPostIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=1) + protected: bool | None = None + sensitive: bool | None = None + interstitial: str | None = None + withheldIn: list[str] | None = pydantic.Field(default=None, min_length=1) + verified: Verification | None = None + affiliation: Affiliation | None = None + parody: str | None = None + professional: Professional | None = None + counts: UserCounts | None = None + countsObservedAt: UserCountsObservedAt | None = None + + +class VideoVariant(_WireModel): + url: str + type: str + bitrate: Count | None = None + + +class MediaTag(_WireModel): + userId: ArchiveID + handle: str | None = None + + +class Video(_WireModel): + durationMs: Count | None = None + variants: list[VideoVariant] = pydantic.Field(min_length=1) + + +class ArchiveMedia(_WireModel): + id: ArchiveID + type: Literal["photo", "video", "gif"] + url: str + width: Count | None = None + height: Count | None = None + alt: str | None = None + crops: list[Rect] | None = pydantic.Field(default=None, min_length=1) + faces: list[Rect] | None = pydantic.Field(default=None, min_length=1) + tags: list[MediaTag] | None = pydantic.Field(default=None, min_length=1) + video: Video | None = None + sourcePostId: ArchiveID | None = None + sourceUserId: ArchiveID | None = None + sensitive: list[str] | None = pydantic.Field(default=None, min_length=1) + unavailable: str | None = None + title: str | None = None + description: str | None = None + link: str | None = None + + +class PollChoice(_WireModel): + label: str + count: Count | None = None + + +class Poll(_WireModel): + choices: list[PollChoice] = pydantic.Field(min_length=2) + endsAt: Instant | None = None + final: bool | None = None + updatedAt: Instant | None = None + + +class CardImage(_WireModel): + url: str + width: Count | None = None + height: Count | None = None + alt: str | None = None + + +class CardPlayer(_WireModel): + url: str + width: Count | None = None + height: Count | None = None + + +class Card(_WireModel): + type: str + url: str + title: str | None = None + description: str | None = None + domain: str | None = None + image: CardImage | None = None + siteUserId: ArchiveID | None = None + creatorUserId: ArchiveID | None = None + player: CardPlayer | None = None + poll: Poll | None = None + extra: dict[str, str | bool] | None = None + + +class ArticleBlock(_WireModel): + type: str + text: str + styles: list[Annotated[tuple[Count, Count, str], pydantic.Field(strict=False)]] | None = ( + pydantic.Field(default=None, min_length=1) + ) + entities: ( + list[Annotated[tuple[Count, Count, Count], pydantic.Field(strict=False)]] | None + ) = pydantic.Field(default=None, min_length=1) + data: dict[str, pydantic.JsonValue] | None = None + + +class ArticleEntity(_WireModel): + type: str + data: dict[str, pydantic.JsonValue] + + +class Article(_WireModel): + id: ArchiveID + title: str + preview: str | None = None + coverMediaId: ArchiveID | None = None + publishedAt: Instant | None = None + modifiedAt: Instant | None = None + blocks: list[ArticleBlock] | None = pydantic.Field(default=None, min_length=1) + entities: list[ArticleEntity] | None = pydantic.Field(default=None, min_length=1) + mediaIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=1) + + +class Place(_WireModel): + id: str + name: str | None = None + fullName: str | None = None + country: str | None = None + countryCode: str | None = None + type: str | None = None + bbox: ( + Annotated[tuple[Number, Number, Number, Number], pydantic.Field(strict=False)] | None + ) = None + + +class PostCounts(_WireModel): + replies: Count | None = None + reposts: Count | None = None + quotes: Count | None = None + likes: Count | None = None + bookmarks: Count | None = None + views: Count | None = None + + +class PostCountsObservedAt(_WireModel): + replies: ObservedAt | None = None + reposts: ObservedAt | None = None + quotes: ObservedAt | None = None + likes: ObservedAt | None = None + bookmarks: ObservedAt | None = None + views: ObservedAt | None = None + + +class ReplyTo(_WireModel): + postId: ArchiveID + userId: ArchiveID | None = None + handle: str | None = None + tombstone: str | None = pydantic.Field(default=None, min_length=1) + + +class Quote(_WireModel): + postId: ArchiveID + url: str | None = None + tombstone: str | None = pydantic.Field(default=None, min_length=1) + + +class CommunityNote(_WireModel): + id: ArchiveID | None = None + title: str | None = None + text: RichText + lang: str | None = None + url: str | None = None + + +class ContentWarning(_WireModel): + title: str + text: str | None = None + + +class Community(_WireModel): + id: ArchiveID + name: str | None = None + + +class AIGenerated(_WireModel): + media: bool + source: str + + +class ArchivePost(_WireModel): + id: ArchiveID + authorId: ArchiveID + createdAt: Instant + repostOf: ArchiveID | None = None + conversationId: ArchiveID | None = None + replyTo: ReplyTo | None = None + quote: Quote | None = None + text: RichText | None = None + lang: str | None = None + source: str | None = None + mediaIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=1) + card: Card | None = None + article: Article | None = None + place: Place | None = None + counts: PostCounts | None = None + countsObservedAt: PostCountsObservedAt | None = None + editIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=2) + communityNote: CommunityNote | None = None + sensitive: bool | None = None + contentWarning: ContentWarning | None = None + community: Community | None = None + replyPolicy: str | None = None + paidPromotion: bool | None = None + aiGenerated: AIGenerated | None = None + + +class ArchiveBatch(_WireModel): + users: list[ArchiveUser] = pydantic.Field(default_factory=list, max_length=1000) + posts: list[ArchivePost] = pydantic.Field(default_factory=list, max_length=1000) + media: list[ArchiveMedia] = pydantic.Field(default_factory=list, max_length=1000) + + +class ArchiveQuery(_WireModel): + users: list[ArchiveID] = pydantic.Field(default_factory=list, max_length=1000) + posts: list[ArchiveID] = pydantic.Field(default_factory=list, max_length=1000) + media: list[ArchiveID] = pydantic.Field(default_factory=list, max_length=1000) + + +T = TypeVar("T") + + +class QueryResult(_WireModel, Generic[T]): + id: ArchiveID + status: Literal["found", "missing", "failed"] + data: T | None = None + error: str | None = None + + +class QueryResponse(_WireModel): + users: list[QueryResult[ArchiveUser]] + posts: list[QueryResult[ArchivePost]] + media: list[QueryResult[ArchiveMedia]] + + +class SubmitResult(_WireModel): + id: ArchiveID + status: Literal["accepted", "unchanged", "partial", "failed"] + reason: str | None = None + + +class SubmitSummary(_WireModel): + accepted: Count + unchanged: Count + partial: Count + failed: Count + total: Count + + +class SubmitResponse(_WireModel): + summary: SubmitSummary + users: list[SubmitResult] + posts: list[SubmitResult] + media: list[SubmitResult] + + +class SessionResponse(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", strict=True) + + authenticated: bool + registered: bool + username: str | None + expires_at: str | None = None + account_url: str | None = None + + +__all__ = [ + "ArchiveBatch", + "ArchiveID", + "ArchiveMedia", + "ArchivePost", + "ArchiveQuery", + "ArchiveUser", + "QueryResponse", + "QueryResult", + "SessionResponse", + "SubmitResponse", + "SubmitResult", + "SubmitSummary", +] diff --git a/extensions/twitter/archive_session.py b/extensions/twitter/archive_session.py new file mode 100644 index 0000000..cb72562 --- /dev/null +++ b/extensions/twitter/archive_session.py @@ -0,0 +1,358 @@ +"""Browser session boundary for the x-monkey archive protocol.""" + +from __future__ import annotations + +from datetime import UTC, datetime +import html +import time +import re +from urllib.parse import parse_qsl, urlsplit + +import fastapi +import jwt +from fastapi import Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.business.peer import PeerManager +from app.database_contract.constants import ( + JWT_ALGORITHM, + JWT_AUDIENCE, + JWT_ISSUER, + JWT_ROLE, +) +from app.middleware import decode_peer_jwt +from app.settings import settings + +SESSION_USE_CLAIM = "session_use" +SESSION_USE_TWITTER_ARCHIVE = "twitter_archive" +SESSION_COOKIE_NAME = "inkcre_twitter_archive" +SESSION_COOKIE_PATH_SUFFIX = "/twitter/x-monkey/" +SESSION_RENEWAL_REMAINING_FRACTION = 0.5 +MAX_CONNECT_FORM_BYTES = 8 * 1024 + +router = fastapi.APIRouter() + + +async def x_monkey_base_url() -> str | None: + """Return the Core's configured public protocol base, preserving its path prefix.""" + try: + base_url = (await PeerManager.get_current_config_async()).http_public_base_url + except (RuntimeError, ValueError): + return None + return f"{base_url.rstrip('/')}/twitter/x-monkey" if base_url else None + + +def _session_cookie_path(base_url: str) -> str: + return f"{urlsplit(base_url).path.rstrip('/')}/" + + +def _format_expiry(epoch_seconds: int) -> str: + return datetime.fromtimestamp(epoch_seconds, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _decode_peer_token(token: str) -> dict | None: + try: + return decode_peer_jwt(token, settings.jwt_secret) + except jwt.InvalidTokenError: + return None + + +def _decode_session_cookie(request: Request) -> dict | None: + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + return None + claims = _decode_peer_token(token) + if claims is None or claims.get(SESSION_USE_CLAIM) != SESSION_USE_TWITTER_ARCHIVE: + return None + return claims + + +def _issue_session_token() -> tuple[str, int]: + issued_at = int(time.time()) + lifetime = settings.twitter_x_monkey_jwt_lifetime_seconds + expires_at = issued_at + lifetime + token = jwt.encode( + { + "role": JWT_ROLE, + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "iat": issued_at, + "exp": expires_at, + SESSION_USE_CLAIM: SESSION_USE_TWITTER_ARCHIVE, + }, + settings.jwt_secret, + algorithm=JWT_ALGORITHM, + ) + return token, expires_at + + +def _set_session_cookie( + response: Response, + *, + token: str, + expires_at: int, + base_url: str, +) -> None: + response.set_cookie( + SESSION_COOKIE_NAME, + token, + max_age=max(0, expires_at - int(time.time())), + path=_session_cookie_path(base_url), + secure=True, + httponly=True, + samesite="none", + ) + + +def _clear_session_cookie(response: Response, base_url: str | None) -> None: + path = _session_cookie_path(base_url) if base_url else SESSION_COOKIE_PATH_SUFFIX + response.delete_cookie( + SESSION_COOKIE_NAME, + path=path, + secure=True, + httponly=True, + samesite="none", + ) + + +def renew_session_if_needed( + response: Response, + claims: dict, + *, + base_url: str, +) -> int: + """Renew an eligible session JWT at half of its configured lifetime.""" + expires_at = int(claims["exp"]) + lifetime = settings.twitter_x_monkey_jwt_lifetime_seconds + if expires_at - int(time.time()) <= lifetime * SESSION_RENEWAL_REMAINING_FRACTION: + token, expires_at = _issue_session_token() + _set_session_cookie(response, token=token, expires_at=expires_at, base_url=base_url) + return expires_at + + +def _html_page(body: str, *, status_code: int = 200) -> HTMLResponse: + return HTMLResponse( + f""" + + + +x-monkey 连接 +

InKCre x-monkey 连接

{body}
""", + status_code=status_code, + headers={ + "Cache-Control": "no-store", + "Content-Security-Policy": ( + "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" + ), + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + ) + + +def _request_origin(request: Request) -> str | None: + value = request.headers.get("origin") + if value is None: + return None + try: + parts = urlsplit(value) + # Accessing .port also rejects malformed authority syntax. + _ = parts.port + except ValueError: + return None + if ( + parts.scheme not in {"http", "https", "chrome-extension", "moz-extension"} + or not parts.netloc + or parts.username is not None + or parts.password is not None + or parts.path not in {"", "/"} + or parts.query + or parts.fragment + ): + return None + if parts.scheme in {"chrome-extension", "moz-extension"}: + if not re.fullmatch(r"[a-zA-Z0-9_-]+", parts.netloc): + return None + elif parts.scheme == "https" and parts.netloc.lower() == "x.com": + return "https://x.com" + return f"{parts.scheme}://{parts.netloc}" + + +async def _core_origin() -> str | None: + try: + base_url = (await PeerManager.get_current_config_async()).http_public_base_url + except (RuntimeError, ValueError): + return None + if not base_url: + return None + parts = urlsplit(base_url) + return f"{parts.scheme}://{parts.netloc}" + + +async def validate_archive_request_source(request: Request) -> bool: + """Apply the protocol's source policy; Origin is a filter, never a credential.""" + origin = _request_origin(request) + if origin is None: + # A missing Origin is expected for some extension-origin GM requests. + return "origin" not in request.headers + if origin in {"https://x.com"} or origin.startswith( + ("chrome-extension://", "moz-extension://") + ): + return True + return origin == await _core_origin() + + +def _is_top_level_navigation(request: Request) -> bool: + return ( + request.headers.get("sec-fetch-mode") == "navigate" + and request.headers.get("sec-fetch-dest") == "document" + ) + + +async def _connect_form_token(request: Request) -> str | None: + content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type != "application/x-www-form-urlencoded": + return None + body = await request.body() + if not body or len(body) > MAX_CONNECT_FORM_BYTES: + return None + try: + fields = parse_qsl( + body.decode("utf-8"), + keep_blank_values=True, + strict_parsing=True, + max_num_fields=2, + encoding="utf-8", + errors="strict", + ) + except (UnicodeDecodeError, ValueError): + return None + if len(fields) != 1 or fields[0][0] != "token" or not fields[0][1]: + return None + return fields[0][1] + + +@router.post("/session") +async def connect_session(request: Request): + origin = _request_origin(request) + if ( + origin is None + or not origin.startswith(("http://", "https://")) + or not _is_top_level_navigation(request) + ): + return _html_page("

请从 InKCre Web 的 Twitter 设置发起连接。

", status_code=403) + + token = await _connect_form_token(request) + claims = _decode_peer_token(token) if token else None + if claims is None or SESSION_USE_CLAIM in claims: + return _html_page( + "

连接凭据无效或已失效,请返回 InKCre Web 重新连接。

", status_code=401 + ) + + base_url = await x_monkey_base_url() + if base_url is None: + return _html_page( + "

Core 尚未配置公共 HTTPS 地址,无法建立连接。

", status_code=503 + ) + + token, expires_at = _issue_session_token() + response = RedirectResponse(f"{base_url}/account", status_code=303) + _set_session_cookie(response, token=token, expires_at=expires_at, base_url=base_url) + return response + + +@router.get("/account", response_class=HTMLResponse) +async def account_page(request: Request): + claims = _decode_session_cookie(request) + base_url = await x_monkey_base_url() + if claims is None: + return _html_page( + "

尚未连接。请打开已连接此 InKCre 部署的 Web," + "在 Twitter 设置中点击“连接 x-monkey”,然后返回脚本检测连接。

" + ) + + expires_at = int(claims["exp"]) + if base_url is None: + return _html_page("

连接有效,但 Core 公共 HTTPS 地址未配置。

", status_code=503) + action = html.escape(f"{base_url}/disconnect", quote=True) + expiry = html.escape(_format_expiry(expires_at)) + return _html_page( + f"

已连接 InKCre Peer:{html.escape(settings.peer_name)}

" + f"

会话到期时间:

" + f'
' + '
' + "

断开只清除此浏览器中的会话,不会删除已保存的信息。

" + ) + + +@router.post("/disconnect") +async def disconnect_session(request: Request): + if _request_origin(request) != await _core_origin() or not _is_top_level_navigation( + request + ): + return _html_page("

断开请求来源无效。

", status_code=403) + + base_url = await x_monkey_base_url() + if base_url is None: + return _html_page("

Core 尚未配置公共 HTTPS 地址。

", status_code=503) + response = RedirectResponse(f"{base_url}/account", status_code=303) + _clear_session_cookie(response, base_url) + return response + + +@router.get("/api/v1/session") +async def get_session(request: Request, response: Response): + from .archive_schema import SessionResponse + + base_url = await x_monkey_base_url() + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + account_url = f"{base_url}/account" if base_url else None + fetch_mode = request.headers.get("sec-fetch-mode") + if fetch_mode == "no-cors" or not await validate_archive_request_source(request): + return fastapi.responses.JSONResponse( + {"error": "request source is not allowed"}, + status_code=403, + headers={"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"}, + ) + claims = _decode_session_cookie(request) + if claims is None: + if request.cookies.get(SESSION_COOKIE_NAME): + _clear_session_cookie(response, base_url) + return SessionResponse( + authenticated=False, + registered=False, + username=None, + account_url=account_url, + ) + + if base_url is None: + return SessionResponse( + authenticated=False, + registered=False, + username=None, + account_url=None, + ) + expires_at = renew_session_if_needed(response, claims, base_url=base_url) + return SessionResponse( + authenticated=True, + registered=True, + username=settings.peer_name, + expires_at=_format_expiry(expires_at), + account_url=account_url, + ) + + +__all__ = [ + "SESSION_COOKIE_NAME", + "SESSION_RENEWAL_REMAINING_FRACTION", + "SESSION_USE_CLAIM", + "SESSION_USE_TWITTER_ARCHIVE", + "connect_session", + "disconnect_session", + "get_session", + "renew_session_if_needed", + "router", + "validate_archive_request_source", + "x_monkey_base_url", +] diff --git a/extensions/twitter/bookmark.py b/extensions/twitter/bookmark.py index eb75248..014d406 100644 --- a/extensions/twitter/bookmark.py +++ b/extensions/twitter/bookmark.py @@ -3,16 +3,12 @@ import pydantic import sqlmodel import typing -from app.business.info_base.commands import persist_stars -from app.business.info_base.resolver import ImageResolver, VideoResolver, HTMLResolver from app.business.source import SourceBase from app.persistence.source.uow import source_uow -from app.schemas.info_base.main import OutArcForm, StarsGraphForm -from app.schemas.info_base.relation import RelationForm from app.schemas.job import JobModel from .api import TwitterAPI -from .resolver import TweetResolver -from .schema import Tweet +from .archive_schema import ArchiveBatch +from .reconcile import persist_archive class SourceConfig(sqlmodel.SQLModel): @@ -28,51 +24,33 @@ class CollectConfig(pydantic.BaseModel): result_limit: int = pydantic.Field(default=40, ge=5, le=100) -def _video_url(video) -> str | None: - supported = tuple( - variant - for variant in video.variants - if variant.url and variant.content_type in {None, "video/mp4"} - ) - selected = max(supported, key=lambda variant: variant.bitrate or -1, default=None) - return selected.url if selected is not None else None - - -def tweet_to_graph(tweet) -> StarsGraphForm: - """Map one Twitter API DTO into the persisted root and relation-owned links.""" - canonical = Tweet( - id=tweet.id, - user_id=tweet.user_id, - conversation_id=tweet.conversation_id, - quote=tweet.quote, - text=tweet.text, - ) - videos = tuple( - (video, url) for video in tweet.videos if (url := _video_url(video)) is not None - ) - return StarsGraphForm( - block=TweetResolver.create_block(canonical), - out_arcs=tuple( - OutArcForm( - relation=RelationForm(content=f"attachment:photo:{photo.id}"), - to_graph=ImageResolver.create_graph(url=photo.url, alt_text=photo.alt_text), - ) - for photo in tweet.photos - ) - + tuple( - OutArcForm( - relation=RelationForm(content=f"attachment:video:{video.id}"), - to_graph=VideoResolver.create_graph(url=url), - ) - for video, url in videos - ) - + tuple( - OutArcForm( - relation=RelationForm(content="entities:url"), - to_graph=HTMLResolver.create_graph(url=url), - ) - for url in tweet.urls - ), +def _selected_batch(batch: ArchiveBatch, primary_ids: tuple[str, ...]) -> ArchiveBatch: + posts = {post.id: post for post in batch.posts} + selected = set(primary_ids) + pending = list(primary_ids) + while pending: + post = posts[pending.pop()] + references = [post.repostOf] + if post.quote is not None: + references.append(post.quote.postId) + if post.replyTo is not None: + references.append(post.replyTo.postId) + for native_id in references: + if native_id is not None and native_id in posts and native_id not in selected: + selected.add(native_id) + pending.append(native_id) + chosen = [post for post in batch.posts if post.id in selected] + authors = {post.authorId for post in chosen} + media_ids = {native_id for post in chosen for native_id in post.mediaIds or ()} + for post in chosen: + if post.article is not None: + media_ids.update(post.article.mediaIds or ()) + if post.article.coverMediaId is not None: + media_ids.add(post.article.coverMediaId) + return ArchiveBatch( + posts=chosen, + users=[user for user in batch.users if user.id in authors], + media=[media for media in batch.media if media.id in media_ids], ) @@ -107,35 +85,26 @@ async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: api_client = await TwitterAPI.new() bookmarks_res = await api_client.get_bookmarks(page=page, max_results=result_limit) - # find new tweets start point - old_start_at = len(bookmarks_res.tweets) - if not full: - state = await self.get_state() - latest_tweet_id = state.get("latest_tweet_id") - if latest_tweet_id: - old_start_at = next( - ( - i for i, tweet in enumerate(bookmarks_res.tweets) if tweet.id == latest_tweet_id - ), - len(bookmarks_res.tweets), - ) - - collected = [] - for tweet in ( - bookmarks_res.tweets if full else reversed(bookmarks_res.tweets[:old_start_at]) - ): - collected.append(tweet_to_graph(tweet)) - async with source_uow() as uow: source = await uow.sources.get(self._id, lock=True) if source is None: raise ValueError("Twitter Source no longer exists") - for graph in reversed(collected) if full else collected: - await persist_stars(graph, uow.graph) - if not full and bookmarks_res.tweets: + primary_ids = bookmarks_res.primary_post_ids + selected = primary_ids + if not full: + latest_id = str((source.state or {}).get("latest_tweet_id", "")) + if latest_id in primary_ids: + selected = primary_ids[: primary_ids.index(latest_id)] + if selected: + await persist_archive( + _selected_batch(bookmarks_res.batch, selected), + uow.graph, + preserve_existing_text=True, + ) + if not full and primary_ids: source.state = { **(source.state or {}), - "latest_tweet_id": bookmarks_res.tweets[0].id, + "latest_tweet_id": primary_ids[0], } await uow.sources.save(source) diff --git a/extensions/twitter/pdm.lock b/extensions/twitter/pdm.lock index f3c8dd6..c364f42 100644 --- a/extensions/twitter/pdm.lock +++ b/extensions/twitter/pdm.lock @@ -5,11 +5,99 @@ groups = ["default"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:b28244fe9332fd52d2f6c6a5ad59826451c72e3844da288d912be315ba38ce46" +content_hash = "sha256:f0a380abdb35763ff9b11b5222987779644b2512a442d8daf00187d3281e1ebc" [[metadata.targets]] requires_python = "==3.12.*" +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +requires_python = ">=3.10" +summary = "Happy Eyeballs for asyncio" +groups = ["default"] +files = [ + {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, + {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, +] + +[[package]] +name = "aiohttp" +version = "3.14.5" +requires_python = ">=3.10" +summary = "Async http client/server framework (asyncio)" +groups = ["default"] +dependencies = [ + "aiohappyeyeballs>=2.5.0", + "aiosignal>=1.4.0", + "async-timeout<6.0,>=4.0; python_version < \"3.11\"", + "attrs>=17.3.0", + "frozenlist>=1.1.1", + "multidict<8.0,>=4.5", + "propcache>=0.2.0", + "typing-extensions>=4.4; python_version < \"3.13\"", + "yarl<2.0,>=1.25.1", +] +files = [ + {file = "aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450"}, + {file = "aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8"}, + {file = "aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2"}, + {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764"}, + {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820"}, + {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835"}, + {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02"}, + {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584"}, + {file = "aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d"}, + {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30"}, + {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073"}, + {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794"}, + {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd"}, + {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d"}, + {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748"}, + {file = "aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1"}, + {file = "aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111"}, + {file = "aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac"}, + {file = "aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b"}, + {file = "aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178"}, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +requires_python = ">=3.9" +summary = "aiosignal: a list of registered asynchronous callbacks" +groups = ["default"] +dependencies = [ + "frozenlist>=1.1.0", + "typing-extensions>=4.2; python_version < \"3.13\"", +] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +requires_python = ">=3.9" +summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." +groups = ["default"] +files = [ + {file = "annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101"}, + {file = "annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb"}, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +requires_python = ">=3.10" +summary = "Reusable constraint types to use with typing.Annotated" +groups = ["default"] +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + [[package]] name = "anyio" version = "4.10.0" @@ -27,6 +115,32 @@ files = [ {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, ] +[[package]] +name = "attrs" +version = "26.1.0" +requires_python = ">=3.9" +summary = "Classes Without Boilerplate" +groups = ["default"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "authlib" +version = "1.9.1" +requires_python = ">=3.10" +summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +groups = ["default"] +dependencies = [ + "cryptography>=45.0.1", + "joserfc>=1.6.8", +] +files = [ + {file = "authlib-1.9.1-py2.py3-none-any.whl", hash = "sha256:8b9be8b1e5174dcbf5520e984f1a22dc2edae5b6e40a61d4cedbf86b97e9e69b"}, + {file = "authlib-1.9.1.tar.gz", hash = "sha256:5c7d9848f47cac340f060f76ae0bc09b59f5c8e89cc1bb032d4370a0c55b5974"}, +] + [[package]] name = "beautifulsoup4" version = "4.13.4" @@ -53,6 +167,101 @@ files = [ {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, ] +[[package]] +name = "cffi" +version = "2.1.1" +requires_python = ">=3.10" +summary = "Foreign Function Interface for Python calling C code." +groups = ["default"] +marker = "platform_python_implementation != \"PyPy\"" +dependencies = [ + "pycparser; implementation_name != \"PyPy\"", +] +files = [ + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +requires_python = "!=3.9.0,!=3.9.1,>=3.9" +summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +groups = ["default"] +dependencies = [ + "cffi>=2.0.0; platform_python_implementation != \"PyPy\"", + "typing-extensions>=4.13.2; python_full_version < \"3.11\"", +] +files = [ + {file = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93"}, + {file = "cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c"}, + {file = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94"}, + {file = "cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de"}, + {file = "cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5"}, +] + +[[package]] +name = "datadot" +version = "1.0.0" +requires_python = ">=3.8" +summary = "A functional data navigation tool for Python" +groups = ["default"] +files = [ + {file = "datadot-1.0.0-py3-none-any.whl", hash = "sha256:65cda58f57d2b9c4172d193f6c9e7952bfea033b0fb0237985133496a08c980f"}, + {file = "datadot-1.0.0.tar.gz", hash = "sha256:10d44a4277813d1f8ce7fd49dacf05c61bfc61ea8f50066279bfe2977ff0b208"}, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +requires_python = ">=3.10" +summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +groups = ["default"] +dependencies = [ + "annotated-doc>=0.0.2", + "pydantic>=2.9.0", + "starlette>=0.46.0", + "typing-extensions>=4.8.0", + "typing-inspection>=0.4.2", +] +files = [ + {file = "fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c"}, + {file = "fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e"}, +] + [[package]] name = "filetype" version = "1.2.0" @@ -63,6 +272,54 @@ files = [ {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +requires_python = ">=3.9" +summary = "A list-like structure which implements collections.abc.MutableSequence" +groups = ["default"] +files = [ + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +requires_python = ">=3.10" +summary = "Lightweight in-process concurrent programming" +groups = ["default"] +marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"" +files = [ + {file = "greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1"}, + {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc"}, + {file = "greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44"}, + {file = "greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7"}, + {file = "greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395"}, + {file = "greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0"}, + {file = "greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -133,6 +390,20 @@ files = [ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[[package]] +name = "joserfc" +version = "1.7.7" +requires_python = ">=3.10" +summary = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" +groups = ["default"] +dependencies = [ + "cryptography>=45.0.1", +] +files = [ + {file = "joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c"}, + {file = "joserfc-1.7.7.tar.gz", hash = "sha256:471d0fd12a5a0d1c0942de280f39efe6f597878315033270b504d4d006d25510"}, +] + [[package]] name = "js2py-3-13" version = "0.74.1" @@ -188,6 +459,125 @@ files = [ {file = "m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd"}, ] +[[package]] +name = "multidict" +version = "7.1.0" +requires_python = ">=3.10" +summary = "multidict implementation" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.1.0; python_version < \"3.11\"", +] +files = [ + {file = "multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21"}, + {file = "multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4"}, + {file = "multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01"}, + {file = "multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf"}, + {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de"}, + {file = "multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb"}, + {file = "multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085"}, + {file = "multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d"}, + {file = "multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0"}, + {file = "multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec"}, +] + +[[package]] +name = "propcache" +version = "0.5.4" +requires_python = ">=3.10" +summary = "Accelerated property cache" +groups = ["default"] +files = [ + {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8"}, + {file = "propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b"}, + {file = "propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031"}, + {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161"}, + {file = "propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8"}, + {file = "propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb"}, + {file = "propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737"}, + {file = "propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468"}, + {file = "propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558"}, +] + +[[package]] +name = "pycparser" +version = "3.11" +requires_python = ">=3.10" +summary = "C parser in Python" +groups = ["default"] +marker = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80"}, + {file = "pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc"}, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +requires_python = ">=3.10" +summary = "Data validation using Python type hints" +groups = ["default"] +dependencies = [ + "annotated-types>=0.6.0", + "pydantic-core==2.50.1", + "typing-extensions>=4.16.0", + "typing-inspection>=0.4.4", +] +files = [ + {file = "pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"}, + {file = "pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"}, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +requires_python = ">=3.10" +summary = "Core functionality for Pydantic validation and serialization" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.16.0", +] +files = [ + {file = "pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0"}, + {file = "pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a"}, + {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9"}, + {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff"}, + {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4"}, + {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2"}, + {file = "pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e"}, + {file = "pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498"}, + {file = "pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1"}, + {file = "pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09"}, +] + [[package]] name = "pyjsparser" version = "2.7.1" @@ -197,6 +587,20 @@ files = [ {file = "pyjsparser-2.7.1.tar.gz", hash = "sha256:be60da6b778cc5a5296a69d8e7d614f1f870faf94e1b1b6ac591f2ad5d729579"}, ] +[[package]] +name = "pyjwt" +version = "2.15.1" +requires_python = ">=3.9" +summary = "JSON Web Token implementation in Python" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.0; python_version < \"3.11\"", +] +files = [ + {file = "pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"}, + {file = "pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"}, +] + [[package]] name = "pyotp" version = "2.9.0" @@ -252,6 +656,59 @@ files = [ {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.55" +requires_python = ">=3.7" +summary = "Database Abstraction Library" +groups = ["default"] +dependencies = [ + "greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"", + "importlib-metadata; python_version < \"3.8\"", + "typing-extensions>=4.6.0", +] +files = [ + {file = "sqlalchemy-2.0.55-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c37c286877ca04b577fb02779ed77dbf884539dcb4712507e25ca4d8d48a6ec"}, + {file = "sqlalchemy-2.0.55-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6058240996c16cccc19dbd948aade826de3854171f45e045219dc9e9ea75ef"}, + {file = "sqlalchemy-2.0.55-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7694e736d63b476d81a0eae7b304d9b747a3f0a1b7dc10134655c4cccf5eb7"}, + {file = "sqlalchemy-2.0.55-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:11d7e28319efa0a52d4601ce4498df7e9c4014a740d9f0eae1d8dd6432a4ace2"}, + {file = "sqlalchemy-2.0.55-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cfa3afdaccf4ddb91de5dcffdc9298395b4a3c740fbae478b4491e96085051d2"}, + {file = "sqlalchemy-2.0.55-cp312-cp312-win32.whl", hash = "sha256:4ff08c8c2f701d2bcbe731833bfbb96195e3ce7a0e400e8b699f8e4a0020545f"}, + {file = "sqlalchemy-2.0.55-cp312-cp312-win_amd64.whl", hash = "sha256:a13820f47eda53d603f56feaadeab9787d0110b7fcbe6ab2ff2415bd3ea31c79"}, + {file = "sqlalchemy-2.0.55-py3-none-any.whl", hash = "sha256:d2954796165c08e2f70082e4577a4c3ce6f141c77e2e4b378b3b528653b54311"}, + {file = "sqlalchemy-2.0.55.tar.gz", hash = "sha256:ae40c563bc10a8d56f1fd1f39f069aeb5eb85ca26913b2314dd8d190139258fa"}, +] + +[[package]] +name = "sqlmodel" +version = "0.0.24" +requires_python = ">=3.7" +summary = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." +groups = ["default"] +dependencies = [ + "SQLAlchemy<2.1.0,>=2.0.14", + "pydantic<3.0.0,>=1.10.13", +] +files = [ + {file = "sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193"}, + {file = "sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423"}, +] + +[[package]] +name = "starlette" +version = "1.9.0" +requires_python = ">=3.11" +summary = "The little ASGI library that shines." +groups = ["default"] +dependencies = [ + "anyio<5,>=4.0.0", + "typing-extensions>=4.10.0; python_version < \"3.13\"", +] +files = [ + {file = "starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc"}, + {file = "starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318"}, +] + [[package]] name = "twikit" version = "2.3.3" @@ -275,13 +732,27 @@ files = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.16.0" requires_python = ">=3.9" summary = "Backported and Experimental Type Hints for Python 3.9+" groups = ["default"] files = [ - {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, - {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +requires_python = ">=3.10" +summary = "Runtime typing introspection tools" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.15.0", +] +files = [ + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, ] [[package]] @@ -320,3 +791,36 @@ files = [ {file = "webvtt-py-0.5.1.tar.gz", hash = "sha256:2040dd325277ddadc1e0c6cc66cbc4a1d9b6b49b24c57a0c3364374c3e8a3dc1"}, {file = "webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7"}, ] + +[[package]] +name = "yarl" +version = "1.25.1" +requires_python = ">=3.10" +summary = "Yet another URL library" +groups = ["default"] +dependencies = [ + "idna>=2.0", + "multidict>=4.0", + "propcache>=0.2.1", +] +files = [ + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"}, + {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"}, + {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"}, + {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"}, + {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"}, + {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"}, + {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"}, + {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"}, +] diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml index f26feba..9fef145 100644 --- a/extensions/twitter/pyproject.toml +++ b/extensions/twitter/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "datadot>=1.0.0,<2.0.0", "fastapi>=0.139.2,<0.140.0", "httpx>=0.28.1,<0.29.0", + "pydantic>=2.11.7,<3.0.0", + "PyJWT>=2.10.1,<3.0.0", "sqlmodel>=0.0.24,<0.0.25", "twikit>=2.3.3,<3.0.0", ] @@ -26,7 +28,7 @@ twitter = "extensions.twitter:Extension" name = "inkcre/twitter" nickname = "Twitter" host-sdk = "core-py" -host-sdk-version = ">=0.2.0 <0.4.0" +host-sdk-version = ">=0.3.1 <0.4.0" [tool.setuptools] packages = ["extensions.twitter"] diff --git a/extensions/twitter/reconcile.py b/extensions/twitter/reconcile.py new file mode 100644 index 0000000..f9f7908 --- /dev/null +++ b/extensions/twitter/reconcile.py @@ -0,0 +1,346 @@ +"""One transactional native Twitter archive shared by HTTP and Bookmark sources.""" + +from __future__ import annotations + +from collections.abc import Collection +from dataclasses import dataclass +import json +import typing +from urllib.parse import urlsplit + +import pydantic + +from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow +from app.schemas.info_base.block import BlockForm, BlockModel +from app.schemas.info_base.relation import RelationCreateForm + +from .archive_merge import merge_archive_record +from .archive_schema import ( + ArchiveBatch, + ArchiveMedia, + ArchivePost, + ArchiveQuery, + ArchiveUser, + QueryResponse, + SubmitResponse, + SubmitResult, + SubmitSummary, +) + + +POST_RESOLVER = "extensions.twitter.tweet.v2" +USER_RESOLVER = "extensions.twitter.user.v1" +MEDIA_RESOLVER = "extensions.twitter.media.v1" +LEGACY_POST_RESOLVER = "extensions.twitter.tweet.v1" +REFERENCE_SCHEMA = "extensions.twitter.reference.v1" +TWITTER_WRITE_LOCK = "extensions.twitter.reconcile" +KINDS = ("users", "posts", "media") +RESOLVERS = {"users": USER_RESOLVER, "posts": POST_RESOLVER, "media": MEDIA_RESOLVER} +MODELS = {"users": ArchiveUser, "posts": ArchivePost, "media": ArchiveMedia} +Record = dict[str, typing.Any] +Identity = tuple[str, str] + + +@dataclass(frozen=True) +class ArchiveWriteResult: + response: SubmitResponse + block_ids: dict[str, dict[str, int]] + + +def _json(value: object) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _block_id(block: BlockModel) -> int: + if block.id is None: + raise RuntimeError("Twitter Block has no persisted identity") + return block.id + + +def _references(post: Record) -> tuple[tuple[str, Identity, int | None], ...]: + refs: list[tuple[str, Identity, int | None]] = [] + if "authorId" in post: + refs.append(("author", ("users", post["authorId"]), None)) + article = post.get("article", {}) + media_ids = list(post.get("mediaIds", ())) + if "coverMediaId" in article: + media_ids.append(article["coverMediaId"]) + media_ids.extend(article.get("mediaIds", ())) + for position, media_id in enumerate(dict.fromkeys(media_ids)): + refs.append(("media", ("media", media_id), position)) + for field, kind in (("replyTo", "reply"), ("quote", "quote")): + if field in post: + refs.append((kind, ("posts", post[field]["postId"]), None)) + if "repostOf" in post: + refs.append(("repost", ("posts", post["repostOf"]), None)) + return tuple(refs) + + +def _reference_content(kind: str, position: int | None) -> str: + content: Record = {"schema": REFERENCE_SCHEMA, "type": kind} + if position is not None: + content["position"] = position + return _json(content) + + +def _is_reference(content: str) -> bool: + try: + value = json.loads(content) + except ValueError: + return False + if not isinstance(value, dict) or value.get("schema") != REFERENCE_SCHEMA: + return False + if value.get("type") == "media": + position = value.get("position") + return ( + set(value) == {"schema", "type", "position"} + and type(position) is int + and position >= 0 + ) + return set(value) == {"schema", "type"} and value.get("type") in { + "author", + "reply", + "quote", + "repost", + } + + +def _safe_cdn_url(value: str) -> bool: + if any( + character.isspace() or ord(character) < 32 or ord(character) == 127 + for character in value + ): + return False + try: + url = urlsplit(value) + return ( + url.scheme == "https" + and url.hostname in {"pbs.twimg.com", "video.twimg.com"} + and url.port in {None, 443} + and url.username is None + and url.password is None + and not url.fragment + ) + except ValueError: + return False + + +def media_content_pointer(media: Record) -> tuple[str, str] | None: + """Select captured CDN bytes without fetching or misclassifying HLS playlists.""" + if media.get("type") == "photo": + url = media["url"] + return ("core.image.v1", url) if _safe_cdn_url(url) else None + variants = [ + variant + for variant in media.get("video", {}).get("variants", ()) + if variant["type"] == "video/mp4" and _safe_cdn_url(variant["url"]) + ] + if not variants: + return None + selected = max(variants, key=lambda variant: variant.get("bitrate", -1)) + return "core.video.v1", selected["url"] + + +async def _load_roots( + identities: Collection[Identity], + uow: GraphUnitOfWork, + *, + include_legacy: bool = False, +) -> dict[Identity, BlockModel]: + result: dict[Identity, BlockModel] = {} + for kind in KINDS: + ids = {native_id for candidate_kind, native_id in identities if candidate_kind == kind} + resolvers = (RESOLVERS[kind],) + if include_legacy and kind == "posts": + resolvers += (LEGACY_POST_RESOLVER,) + candidates = await uow.blocks.find_json_field_many(resolvers, "id", ids) + for block in sorted( + candidates, + key=lambda block: (block.resolver == LEGACY_POST_RESOLVER, _block_id(block)), + ): + native_id = str(json.loads(block.content)["id"]) + result.setdefault((kind, native_id), block) + return result + + +async def persist_archive( + batch: ArchiveBatch, + uow: GraphUnitOfWork, + *, + preserve_existing_text: bool = False, +) -> ArchiveWriteResult: + """Merge a complete batch without committing the caller's graph transaction. + + Bookmark can supplement a native record, but its less detailed body must not + replace an already captured text; x-monkey keeps the native merge semantics. + """ + await uow.blocks.acquire_transaction_lock(TWITTER_WRITE_LOCK) + incoming: dict[Identity, Record] = {} + for kind in KINDS: + for record in getattr(batch, kind): + key = (kind, record.id) + incoming[key] = merge_archive_record( + incoming.get(key, {}), record.model_dump(mode="json", exclude_unset=True) + ) + identities = set(incoming) + for (kind, _), record in incoming.items(): + if kind == "posts": + identities.update(target for _, target, _ in _references(record)) + roots = await _load_roots(identities, uow, include_legacy=True) + changed: set[Identity] = set() + records: dict[Identity, Record] = {} + new: list[tuple[Identity, BlockForm]] = [] + updated: list[BlockModel] = [] + for key in sorted(identities): + root = roots.get(key) + existing: Record = ( + json.loads(root.content) + if root is not None and root.resolver != LEGACY_POST_RESOLVER + else {"id": key[1]} + ) + value: Record = incoming.get(key, {"id": key[1]}) + if preserve_existing_text and "text" in existing and "text" in value: + value = {field: item for field, item in value.items() if field != "text"} + merged = merge_archive_record(existing, value) + if key in incoming: + MODELS[key[0]].model_validate(merged) + records[key] = merged + if root is None: + new.append((key, BlockForm(resolver=RESOLVERS[key[0]], content=_json(merged)))) + changed.add(key) + elif key in incoming and ( + _json(merged) != _json(existing) or root.resolver != RESOLVERS[key[0]] + ): + root.content = _json(merged) + root.resolver = RESOLVERS[key[0]] + root.storage = None + updated.append(root) + changed.add(key) + for (key, _), root in zip( + new, await uow.blocks.create_many(form for _, form in new), strict=True + ): + roots[key] = root + await uow.blocks.save_many(updated) + + # Merged, retained references may be absent from this sparse input. + retained = { + target + for (kind, _), record in records.items() + if kind == "posts" + for _, target, _ in _references(record) + } - roots.keys() + if retained: + roots.update(await _load_roots(retained, uow, include_legacy=True)) + absent = sorted(retained - roots.keys()) + placeholders = await uow.blocks.create_many( + BlockForm(resolver=RESOLVERS[key[0]], content=_json({"id": key[1]})) for key in absent + ) + roots.update(zip(absent, placeholders, strict=True)) + + desired: set[tuple[int, int, str]] = set() + post_ids: set[int] = set() + media_ids: set[int] = set() + for key in incoming: + root_id = _block_id(roots[key]) + record = records[key] + if key[0] == "posts": + post_ids.add(root_id) + desired.update( + (root_id, _block_id(roots[target]), _reference_content(kind, position)) + for kind, target, position in _references(record) + ) + elif key[0] == "media": + media_ids.add(root_id) + pointer = media_content_pointer(record) + if pointer is not None: + resolver, url = pointer + candidates = await uow.blocks.matching_content(resolver, url) + content = next( + (block for block in sorted(candidates, key=_block_id) if block.storage == -1), + None, + ) + if content is None: + content = await uow.blocks.create( + BlockForm(resolver=resolver, content=url, storage=-1) + ) + desired.add((root_id, _block_id(content), "content")) + stale: list[int] = [] + for relation in await uow.relations.get_outgoing_many(post_ids | media_ids): + managed = (relation.from_ in post_ids and _is_reference(relation.content)) or ( + relation.from_ in media_ids and relation.content == "content" + ) + if not managed: + continue + identity = (relation.from_, relation.to_, relation.content) + if identity in desired: + desired.remove(identity) + elif relation.id is not None: + stale.append(relation.id) + await uow.relations.delete_many(stale) + await uow.relations.create_many( + RelationCreateForm(from_=source, to_=target, content=content) + for source, target, content in sorted(desired) + ) + results = { + kind: [ + SubmitResult( + id=record.id, + status="accepted" if (kind, record.id) in changed else "unchanged", + ) + for record in getattr(batch, kind) + ] + for kind in KINDS + } + total = sum(len(items) for items in results.values()) + accepted = sum(item.status == "accepted" for items in results.values() for item in items) + return ArchiveWriteResult( + response=SubmitResponse( + summary=SubmitSummary( + total=total, accepted=accepted, unchanged=total - accepted, partial=0, failed=0 + ), + **results, + ), + block_ids={ + kind: { + native_id: _block_id(root) + for (candidate_kind, native_id), root in roots.items() + if candidate_kind == kind + } + for kind in KINDS + }, + ) + + +async def submit_archive(batch: ArchiveBatch) -> SubmitResponse: + async with graph_uow() as uow: + result = await persist_archive(batch, uow) + return result.response + + +async def query_archive(query: ArchiveQuery) -> QueryResponse: + identities = {(kind, native_id) for kind in KINDS for native_id in getattr(query, kind)} + async with graph_uow() as uow: + roots = await _load_roots(identities, uow) + results: dict[str, list[Record]] = {kind: [] for kind in KINDS} + for kind in KINDS: + for native_id in getattr(query, kind): + root = roots.get((kind, native_id)) + if root is None or json.loads(root.content) == {"id": native_id}: + result: Record = {"id": native_id, "status": "missing"} + else: + try: + data = MODELS[kind].model_validate_json(root.content) + except pydantic.ValidationError: + result = { + "id": native_id, + "status": "failed", + "error": "Stored archive record is invalid", + } + else: + result = { + "id": native_id, + "status": "found", + "data": data.model_dump(mode="json", exclude_unset=True), + } + results[kind].append(result) + return QueryResponse.model_validate(results) diff --git a/extensions/twitter/resolver.py b/extensions/twitter/resolver.py index 7c83273..2db2c64 100644 --- a/extensions/twitter/resolver.py +++ b/extensions/twitter/resolver.py @@ -1,12 +1,17 @@ import json +import typing from typing import Optional as Opt +import pydantic + from app.business.info_base.resolver import Resolver, ResolverManager, TextProjectionContext from app.business.info_base.resolver.label import format_label from app.business.info_base.services import BlockService from app.schemas.info_base.block import BlockForm from app.schemas.info_base.main import StarsGraphForm from .schema import Tweet +from .archive_schema import ArchiveID, ArchiveMedia, ArchivePost, ArchiveUser +from .reconcile import MEDIA_RESOLVER, POST_RESOLVER, REFERENCE_SCHEMA, USER_RESOLVER class TweetResolver(Resolver[Tweet, str], rso_type="extensions.twitter.tweet.v1"): @@ -101,3 +106,151 @@ def create_graph(cls, content: Tweet) -> StarsGraphForm: block=block, out_arcs=(), ) + + +class ArchiveIdentity(pydantic.BaseModel): + """An exact source reference whose content has not been captured.""" + + model_config = pydantic.ConfigDict(extra="forbid", strict=True) + id: ArchiveID + + +Native = typing.TypeVar("Native", bound=pydantic.BaseModel) + + +async def _native_content( + resolver: Resolver, model: type[Native], *, refresh: bool +) -> Native | ArchiveIdentity: + raw = await resolver.get_raw_content(refresh=refresh) + value = json.loads(raw) + if isinstance(value, dict) and set(value) == {"id"}: + return ArchiveIdentity.model_validate(value) + return model.model_validate(value) + + +class ArchivePostResolver( + Resolver[ArchivePost | ArchiveIdentity, str], rso_type=POST_RESOLVER +): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> ArchivePost | ArchiveIdentity: + del materialize_missing + return await _native_content(self, ArchivePost, refresh=refresh) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, materialize_missing + post = await self.get_solved_content(refresh=refresh, materialize_missing=False) + parts = [f"https://x.com/i/status/{post.id}", post.id] + if isinstance(post, ArchiveIdentity): + return "\n".join(parts) + parts.append(f"author: {post.authorId}") + for relation in await self.get_relations(include_in=False, refresh=refresh): + try: + reference = json.loads(relation.content) + except ValueError: + continue + if reference != {"schema": REFERENCE_SCHEMA, "type": "author"}: + continue + author = await BlockService.get(relation.to_) + if author is not None and author.resolver == USER_RESOLVER: + value = json.loads(author.content) + if set(value) != {"id"}: + user = ArchiveUser.model_validate(value) + parts.append(f"{user.name} @{user.handle}") + break + if post.text is not None: + parts.append(post.text.text) + if post.article is not None: + parts.append(post.article.title) + if post.article.blocks is not None: + parts.extend(block.text for block in post.article.blocks) + elif post.article.preview is not None: + parts.append(post.article.preview) + return "\n".join(parts) + + async def get_label(self, *, refresh: bool = False) -> str: + post = await self.get_solved_content(refresh=refresh, materialize_missing=False) + text = ( + post.text.text if isinstance(post, ArchivePost) and post.text is not None else post.id + ) + return format_label("tweet", text or post.id, first_line=True) + + +class ArchiveUserResolver( + Resolver[ArchiveUser | ArchiveIdentity, str], rso_type=USER_RESOLVER +): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> ArchiveUser | ArchiveIdentity: + del materialize_missing + return await _native_content(self, ArchiveUser, refresh=refresh) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, materialize_missing + user = await self.get_solved_content(refresh=refresh, materialize_missing=False) + if isinstance(user, ArchiveIdentity): + return user.id + return "\n".join( + value + for value in ( + user.id, + f"{user.name} @{user.handle}", + f"https://x.com/{user.handle}", + user.bio.text if user.bio is not None else None, + user.location, + user.website, + ) + if value is not None + ) + + async def get_label(self, *, refresh: bool = False) -> str: + user = await self.get_solved_content(refresh=refresh, materialize_missing=False) + return format_label( + "twitter user", f"@{user.handle}" if isinstance(user, ArchiveUser) else user.id + ) + + +class ArchiveMediaResolver( + Resolver[ArchiveMedia | ArchiveIdentity, str], rso_type=MEDIA_RESOLVER +): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> ArchiveMedia | ArchiveIdentity: + del materialize_missing + return await _native_content(self, ArchiveMedia, refresh=refresh) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + del context, materialize_missing + media = await self.get_solved_content(refresh=refresh, materialize_missing=False) + if isinstance(media, ArchiveIdentity): + return media.id + return "\n".join( + value + for value in (media.id, media.alt, media.title, media.description) + if value is not None + ) + + async def get_label(self, *, refresh: bool = False) -> str: + media = await self.get_solved_content(refresh=refresh, materialize_missing=False) + label = ( + media.title or media.alt or media.id if isinstance(media, ArchiveMedia) else media.id + ) + return format_label("twitter media", label, first_line=True) diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py index 59bf219..6d6e340 100644 --- a/extensions/twitter/setup_flow.py +++ b/extensions/twitter/setup_flow.py @@ -98,6 +98,7 @@ class OAuthTransactionView(pydantic.BaseModel): class TwitterSetupStatus(pydantic.BaseModel): callback_url: str + x_monkey_base_url: str | None = None connected: bool user_id: str | None = None handle: str | None = None @@ -251,6 +252,8 @@ def reconcile(config_model, state_model): async def get_setup_status() -> TwitterSetupStatus: + from .archive_session import x_monkey_base_url + config = _config() state = await _reconcile_oauth_state() configured = bool(config.client_id and config.client_secret) @@ -263,6 +266,7 @@ async def get_setup_status() -> TwitterSetupStatus: ) return TwitterSetupStatus( callback_url=await _redirect_uri(), + x_monkey_base_url=await x_monkey_base_url(), connected=connected, user_id=account.user_id if connected and account is not None else None, handle=account.handle if connected and account is not None else None, @@ -671,3 +675,6 @@ async def delete_account(): methods=["GET"], response_class=HTMLResponse, ) + from .archive_session import router as archive_session_router + + router.include_router(archive_session_router, prefix="/x-monkey") diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index 9504fa8..181e3f6 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -3,6 +3,7 @@ "baseline": "peer-database-runtime-v1", "previous_manifest_sha256": "077705815948319b03286fa8550478710f8964263be19c35432d2a3462e76ed8", "revisions": { + "09a921a95314_remove_peer_jwt_lifetime_cap.py": "c0b62fc9fbf7b2c6ec9c6c3c27c3e80dbbd6022b55e7ccdf5ba9910169dddec3", "143c4f4adc85_add_sink_catalog_and_instances.py": "dc5151df7c73ec84077f7698cfb1e7fdd5596978270055de2812da539d02043d", "1e4c7a9b2d5f_add_block_lexical_records.py": "20cd7c361b0f822811d52beb692a255078e3842fd64d74287ee0c13d0bc8df26", "3d9593b0c855_add_job_submission_trace_context.py": "60d61b798da850ea24d301db3f97cb07e0d8d9695a6c5a9763b9ef4f990d0845", diff --git a/migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py b/migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py new file mode 100644 index 0000000..0b95200 --- /dev/null +++ b/migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py @@ -0,0 +1,87 @@ +"""Make JWT signing defaults independent of admission. + +Revision ID: 09a921a95314 +Revises: e6a1b2c3d4f5 +Create Date: 2026-10-19 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "09a921a95314" +down_revision: str | Sequence[str] | None = "e6a1b2c3d4f5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _replace_check_jwt(*, enforce_legacy_maximum: bool) -> None: + legacy_maximum = "OR expires_at - issued_at > 86400" if enforce_legacy_maximum else "" + op.execute( + f""" + CREATE OR REPLACE FUNCTION inkcre_internal.check_jwt() + RETURNS void + LANGUAGE plpgsql + SECURITY INVOKER + SET search_path = pg_catalog + AS $$ + DECLARE + claims jsonb; + issued_at numeric; + expires_at numeric; + current_epoch numeric := extract(epoch FROM statement_timestamp()); + BEGIN + claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb; + IF claims IS NULL + OR jsonb_typeof(claims -> 'role') IS DISTINCT FROM 'string' + OR claims ->> 'role' IS DISTINCT FROM 'authenticated' + OR jsonb_typeof(claims -> 'iss') IS DISTINCT FROM 'string' + OR claims ->> 'iss' IS DISTINCT FROM 'inkcre-peer' + OR jsonb_typeof(claims -> 'aud') IS DISTINCT FROM 'string' + OR claims ->> 'aud' IS DISTINCT FROM 'inkcre-api' + OR jsonb_typeof(claims -> 'iat') IS DISTINCT FROM 'number' + OR jsonb_typeof(claims -> 'exp') IS DISTINCT FROM 'number' + THEN + RAISE insufficient_privilege USING MESSAGE = 'invalid jwt claims'; + END IF; + + issued_at := (claims ->> 'iat')::numeric; + expires_at := (claims ->> 'exp')::numeric; + IF issued_at > current_epoch + 60 + OR expires_at <= current_epoch + OR expires_at <= issued_at + {legacy_maximum} + THEN + RAISE insufficient_privilege USING MESSAGE = 'invalid jwt lifetime'; + END IF; + END + $$ + """ + ) + + +def _set_contract_revision(value: str) -> None: + op.execute( + sa.text( + """ + UPDATE inkcre_internal.contract_state + SET contract_revision = :revision, updated_at = CURRENT_TIMESTAMP + WHERE singleton + """ + ).bindparams(revision=value) + ) + op.execute("NOTIFY pgrst, 'reload schema'") + + +def upgrade() -> None: + """Keep claim and time validation without a maximum token lifetime.""" + _replace_check_jwt(enforce_legacy_maximum=False) + _set_contract_revision("peer-jwt-signing-defaults-v1") + + +def downgrade() -> None: + """Restore the previous admission limit; existing longer tokens are rejected.""" + _replace_check_jwt(enforce_legacy_maximum=True) + _set_contract_revision("extension-peer-participation-v2") diff --git a/scripts/dev_database.py b/scripts/dev_database.py index 6b94826..61535fd 100644 --- a/scripts/dev_database.py +++ b/scripts/dev_database.py @@ -161,7 +161,6 @@ def _write_runtime_files( "issuer": "inkcre-peer", "audience": "inkcre-api", "required_claims": ["role", "iss", "aud", "iat", "exp"], - "maximum_lifetime_seconds": 86400, }, } published_ports = ( diff --git a/tests/extensions/twitter/fixtures/native_archive.json b/tests/extensions/twitter/fixtures/native_archive.json new file mode 100644 index 0000000..730ecee --- /dev/null +++ b/tests/extensions/twitter/fixtures/native_archive.json @@ -0,0 +1,406 @@ +{ + "users": [ + { + "id": "17", + "handle": "author", + "name": "作者🌏", + "createdAt": "2024-02-29T12:00:00Z", + "avatarUrl": "https://pbs.twimg.com/avatar.jpg", + "avatarShape": "Hexagon", + "bannerUrl": "https://pbs.twimg.com/banner.jpg", + "bio": { + "text": "正文🌏 @author #X $BTC 01:30 https://t.co/x", + "visible": [ + 0, + 9 + ], + "entities": [ + { + "type": "mention", + "range": [ + 4, + 11 + ], + "userId": "17" + }, + { + "type": "hashtag", + "range": [ + 12, + 14 + ] + }, + { + "type": "cashtag", + "range": [ + 15, + 19 + ], + "name": "Bitcoin", + "ticker": "BTC" + }, + { + "type": "timestamp", + "range": [ + 20, + 25 + ] + }, + { + "type": "url", + "range": [ + 26, + 40 + ], + "expanded": "https://example.org/source" + } + ], + "styles": [ + { + "range": [ + 0, + 2 + ], + "styles": [ + "Bold", + "Italic" + ] + } + ] + }, + "location": "上海", + "website": "https://example.org/", + "pinnedPostIds": [ + "31" + ], + "protected": false, + "sensitive": true, + "interstitial": "sensitive_media", + "withheldIn": [ + "SA", + "AE" + ], + "verified": { + "blue": false, + "type": "Business" + }, + "affiliation": { + "name": "示例", + "url": "https://example.org/", + "badgeUrl": "https://pbs.twimg.com/badge.jpg", + "kind": "AutomatedLabel", + "ownerId": "18" + }, + "parody": "Commentary", + "professional": { + "type": "Business", + "categories": [ + "Technology" + ] + }, + "counts": { + "followers": 0, + "following": 1, + "posts": 2, + "mediaPosts": 3, + "likes": 4 + }, + "countsObservedAt": { + "followers": "2026-10-19T01:02:03.1200Z", + "following": "2026-10-19T01:02:03Z", + "posts": "2026-10-19T01:02:00Z", + "mediaPosts": "2026-10-19T01:02:03.000001Z", + "likes": "2026-10-19T01:02:03.0000017Z" + } + } + ], + "posts": [ + { + "id": "31", + "authorId": "17", + "createdAt": "2026-10-18T12:34:56Z", + "repostOf": "32", + "conversationId": "29", + "replyTo": { + "postId": "29", + "userId": "18", + "handle": "prior", + "tombstone": "这个帖子已被其作者删除。" + }, + "quote": { + "postId": "30", + "url": "https://x.com/prior/status/30", + "tombstone": "这个账号的帖子受保护。" + }, + "text": { + "text": "正文🌏 @author #X $BTC 01:30 https://t.co/x", + "visible": [ + 0, + 9 + ], + "entities": [ + { + "type": "mention", + "range": [ + 4, + 11 + ], + "userId": "17" + }, + { + "type": "hashtag", + "range": [ + 12, + 14 + ] + }, + { + "type": "cashtag", + "range": [ + 15, + 19 + ], + "name": "Bitcoin", + "ticker": "BTC" + }, + { + "type": "timestamp", + "range": [ + 20, + 25 + ] + }, + { + "type": "url", + "range": [ + 26, + 40 + ], + "expanded": "https://example.org/source" + } + ], + "styles": [ + { + "range": [ + 0, + 2 + ], + "styles": [ + "Bold", + "Italic" + ] + } + ] + }, + "lang": "zh", + "source": "X Web App", + "mediaIds": [ + "41" + ], + "card": { + "type": "poll2choice_text_only", + "url": "card://31", + "title": "问题", + "description": "详细说明", + "domain": "example.org", + "image": { + "url": "https://example.org/image.png", + "width": 640, + "height": 480, + "alt": "示例图" + }, + "siteUserId": "18", + "creatorUserId": "17", + "player": { + "url": "https://example.org/video", + "width": 640, + "height": 480 + }, + "poll": { + "choices": [ + { + "label": "是", + "count": 0 + }, + { + "label": "否", + "count": 3 + } + ], + "endsAt": "2026-10-20T00:00:00Z", + "final": false, + "updatedAt": "2026-10-19T01:02:03Z" + }, + "extra": { + "app_name": "example", + "is_enabled": false + } + }, + "article": { + "id": "51", + "title": "长文", + "preview": "摘要", + "coverMediaId": "41", + "publishedAt": "2026-10-18T12:34:56Z", + "modifiedAt": "2026-10-18T13:34:56Z", + "blocks": [ + { + "type": "unstyled", + "text": "文章🌏", + "styles": [ + [ + 0, + 2, + "BOLD" + ] + ], + "entities": [ + [ + 2, + 2, + 0 + ] + ], + "data": { + "future": { + "items": [ + null, + true, + 1, + 2.5, + "字" + ] + } + } + } + ], + "entities": [ + { + "type": "MEDIA", + "data": { + "mediaId": "41", + "unknown": null + } + } + ], + "mediaIds": [ + "41" + ] + }, + "place": { + "id": "place:shanghai", + "name": "上海", + "fullName": "上海,中国", + "country": "中国", + "countryCode": "CN", + "type": "city", + "bbox": [ + 120, + 30.5, + 122.5, + 32 + ] + }, + "counts": { + "replies": 0, + "reposts": 1, + "quotes": 2, + "likes": 3, + "bookmarks": 4, + "views": 5 + }, + "countsObservedAt": { + "replies": "2026-10-19T01:02:03.100Z", + "reposts": "2026-10-19T01:02:03.200Z", + "quotes": "2026-10-19T01:02:03.300Z", + "likes": "2026-10-19T01:02:03.400Z", + "bookmarks": "2026-10-19T01:02:03.500Z", + "views": "2026-10-19T01:02:03.600Z" + }, + "editIds": [ + "30", + "31" + ], + "communityNote": { + "id": "61", + "title": "补充背景", + "text": { + "text": "来源已核实" + }, + "lang": "zh", + "url": "https://example.org/note" + }, + "sensitive": false, + "contentWarning": { + "title": "敏感内容", + "text": "作者已标注" + }, + "community": { + "id": "71", + "name": "示例社群" + }, + "replyPolicy": "ByInvitation", + "paidPromotion": true, + "aiGenerated": { + "media": true, + "source": "C2paClient" + } + } + ], + "media": [ + { + "id": "41", + "type": "video", + "url": "https://pbs.twimg.com/cover.jpg", + "width": 1920, + "height": 1080, + "alt": "演示视频", + "crops": [ + [ + 0, + 0, + 640, + 480 + ] + ], + "faces": [ + [ + 1, + 2, + 30, + 40 + ] + ], + "tags": [ + { + "userId": "18", + "handle": "prior" + } + ], + "video": { + "durationMs": 1234, + "variants": [ + { + "url": "https://video.twimg.com/a.mp4", + "type": "video/mp4", + "bitrate": 256000 + }, + { + "url": "https://video.twimg.com/a.m3u8", + "type": "application/x-mpegURL" + } + ] + }, + "sourcePostId": "31", + "sourceUserId": "17", + "sensitive": [ + "adult_content", + "other" + ], + "unavailable": "Geoblocked", + "title": "标题", + "description": "说明", + "link": "https://example.org/video" + } + ] +} diff --git a/tests/extensions/twitter/integration/test_archive_graph.py b/tests/extensions/twitter/integration/test_archive_graph.py new file mode 100644 index 0000000..68d0b6f --- /dev/null +++ b/tests/extensions/twitter/integration/test_archive_graph.py @@ -0,0 +1,568 @@ +"""Native Twitter facts survive sparse, concurrent and cross-source writes.""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import typing +from types import SimpleNamespace +from unittest import mock +import uuid + +import pytest +import sqlalchemy +import sqlmodel +import twikit + +from app.business.info_base.resolver import ResolverManager +from app.business.info_base.storage import HTTPStorage +from app.business.source import SourceManager +from app.persistence.info_base.repository import BlockRepository, RelationRepository +from app.persistence.info_base.uow import graph_uow +from app.schemas.info_base.block import BlockForm, BlockModel +from app.schemas.info_base.relation import RelationModel +from app.schemas.info_base.storage import StorageBlobModel +from app.schemas.job import JobModel +from app.schemas.source import SourceModel +from extensions.twitter.api import ( + TwitterAPI, + TwitterAPIResult, + _official_archive, + _twikit_archive, +) +from extensions.twitter.archive_schema import ArchiveBatch, ArchiveQuery +from extensions.twitter.bookmark import CollectConfig, Source +from extensions.twitter.reconcile import ( + MEDIA_RESOLVER, + POST_RESOLVER, + REFERENCE_SCHEMA, + USER_RESOLVER, + persist_archive, + query_archive, + submit_archive, +) +from extensions.twitter.resolver import TweetResolver +from extensions.twitter.schema import Tweet +from tests.database import TestSession, seed_block, seed_relation + + +pytestmark = pytest.mark.skipif( + not os.getenv("INKCRE_TEST_DATABASE_URL"), + reason="requires an explicitly selected migrated disposable PostgreSQL runtime", +) + + +@pytest.fixture +def native() -> dict[str, typing.Any]: + value = json.loads( + (Path(__file__).parents[1] / "fixtures" / "native_archive.json").read_text() + ) + offset = int(uuid.uuid4().hex[:15], 16) + 10**20 + + def distinct(item): + if isinstance(item, str): + if item.isascii() and item.isdecimal(): + return str(int(item) + offset) + if item.startswith(("https://pbs.twimg.com/", "https://video.twimg.com/")): + return f"{item}{'&' if '?' in item else '?'}twitter_test={offset}" + if isinstance(item, list): + return [distinct(child) for child in item] + if isinstance(item, dict): + return {key: distinct(child) for key, child in item.items()} + return item + + return distinct(value) + + +@pytest.fixture +def tracked() -> typing.Iterator[set[int]]: + ids: set[int] = set() + yield ids + if ids: + with TestSession() as db: + content_ids = db.exec( + sqlmodel.select(RelationModel.to_) + .join(BlockModel, sqlmodel.col(BlockModel.id) == RelationModel.from_) + .where( + sqlmodel.col(BlockModel.id).in_(tuple(ids)), + BlockModel.resolver == MEDIA_RESOLVER, + RelationModel.content == "content", + ) + ).all() + ids.update(content_ids) + db.connection().execute( + sqlalchemy.delete(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tuple(ids))) + ) + db.commit() + + +async def _write(batch: ArchiveBatch, tracked: set[int]): + async with graph_uow() as uow: + result = await persist_archive(batch, uow) + tracked.update(block_id for ids in result.block_ids.values() for block_id in ids.values()) + return result + + +def _minimal(post: dict) -> dict: + return {field: post[field] for field in ("id", "authorId", "createdAt")} + + +def _record(native_id: str) -> BlockModel: + with TestSession() as db: + result = db.exec( + sqlmodel.select(BlockModel).where( + BlockModel.resolver == POST_RESOLVER, + sqlalchemy.cast(BlockModel.content, sqlalchemy.JSON)["id"].as_string() == native_id, + ) + ).one() + return result + + +def test_native_roundtrip_projection_and_media_are_lazy(async_runner, native, tracked): + # Importing the module registers all exact decoders, including legacy v1. + from extensions.twitter import resolver # noqa: F401 + + batch = ArchiveBatch.model_validate(native) + query = ArchiveQuery( + **{ + kind: [record.id for record in getattr(batch, kind)] + for kind in ("users", "posts", "media") + } + ) + with TestSession() as db: + blob_count = db.exec( + sqlmodel.select(sqlmodel.func.count()).select_from(StorageBlobModel) + ).one() + job_count = db.exec(sqlmodel.select(sqlmodel.func.count()).select_from(JobModel)).one() + with mock.patch.object( + HTTPStorage, "get_raw_content", new_callable=mock.AsyncMock + ) as fetch: + fetch.side_effect = AssertionError("metadata read downloaded external media") + result = async_runner.run(_write(batch, tracked)) + assert result.response.summary.accepted == 3 + response = async_runner.run(query_archive(query)) + for kind in ("users", "posts", "media"): + assert [item.status for item in getattr(response, kind)] == ["found"] + assert [ + item.data.model_dump(mode="json", exclude_unset=True) + for item in getattr(response, kind) + ] == native[kind] + post = _record(batch.posts[0].id) + text = async_runner.run(ResolverManager.get(post).get_text(materialize_missing=False)) + assert native["posts"][0]["article"]["title"] in text + assert native["users"][0]["handle"] in text + assert native["posts"][0]["id"] in text + assert fetch.await_count == 0 + replay = async_runner.run(_write(batch, tracked)) + assert replay.response.summary.unchanged == 3 + assert replay.block_ids == result.block_ids + pure_repost = { + **_minimal(native["posts"][0]), + "id": str(int(batch.posts[0].id) + 1000), + "repostOf": batch.posts[0].id, + } + async_runner.run(_write(ArchiveBatch.model_validate({"posts": [pure_repost]}), tracked)) + repost_response = async_runner.run(query_archive(ArchiveQuery(posts=[pure_repost["id"]]))) + assert repost_response.posts[0].data is not None + assert ( + repost_response.posts[0].data.model_dump(mode="json", exclude_unset=True) == pure_repost + ) + assert batch.posts[0].quote is not None + missing = async_runner.run( + query_archive(ArchiveQuery(posts=[batch.posts[0].quote.postId])) + ) + assert missing.posts[0].status == "missing" + with TestSession() as db: + assert ( + db.exec(sqlmodel.select(sqlmodel.func.count()).select_from(StorageBlobModel)).one() + == blob_count + ) + assert ( + db.exec(sqlmodel.select(sqlmodel.func.count()).select_from(JobModel)).one() + == job_count + ) + media_id = result.block_ids["media"][batch.media[0].id] + relation = db.exec( + sqlmodel.select(RelationModel).where( + RelationModel.from_ == media_id, RelationModel.content == "content" + ) + ).one() + content = db.get(BlockModel, relation.to_) + assert content is not None + assert content.storage == -1 + assert content.resolver == "core.video.v1" + assert content.content.startswith("https://video.twimg.com/") + + +def test_sparse_counter_history_and_managed_edges(async_runner, native, tracked): + result = async_runner.run(_write(ArchiveBatch.model_validate(native), tracked)) + post = native["posts"][0] + baseline = _minimal(post) + updates = [ + ( + {"counts": {"likes": 100}, "countsObservedAt": {"likes": "2026-10-20T01:00:00Z"}}, + 100, + "2026-10-20T01:00:00Z", + "accepted", + ), + ( + {"counts": {"likes": 90}, "countsObservedAt": {"likes": "2026-10-19T01:00:00Z"}}, + 100, + "2026-10-20T01:00:00Z", + "unchanged", + ), + ( + { + "counts": {"likes": 100}, + "countsObservedAt": {"likes": "2026-10-20T02:00:00.1200Z"}, + }, + 100, + "2026-10-20T02:00:00.1200Z", + "accepted", + ), + ({"counts": {"likes": 101}}, 101, None, "accepted"), + ] + for patch, expected, observed, status in updates: + response = async_runner.run( + submit_archive(ArchiveBatch.model_validate({"posts": [{**baseline, **patch}]})) + ) + assert response.posts[0].status == status + stored = json.loads(_record(post["id"]).content) + assert stored["counts"]["likes"] == expected + assert stored.get("countsObservedAt", {}).get("likes") == observed + assert stored["text"] == post["text"] + assert stored["article"] == post["article"] + assert _record(post["id"]).id == result.block_ids["posts"][post["id"]] + + # A changed media array replaces its edges without touching user-authored links. + root_id = result.block_ids["posts"][post["id"]] + user_link = seed_relation( + root_id, result.block_ids["users"][post["authorId"]], "my citation" + ) + old_media_id = post["mediaIds"][0] + replacement = str(int(old_media_id) + 1000) + update = ArchiveBatch.model_validate({"posts": [{**baseline, "mediaIds": [replacement]}]}) + async_runner.run(_write(update, tracked)) + with TestSession() as db: + assert db.get(RelationModel, user_link.id) is not None + media_refs = [] + for relation in db.exec( + sqlmodel.select(RelationModel).where(RelationModel.from_ == root_id) + ).all(): + try: + grammar = json.loads(relation.content) + except ValueError: + continue + if grammar.get("schema") == REFERENCE_SCHEMA and grammar["type"] == "media": + target = db.get(BlockModel, relation.to_) + assert target is not None + media_refs.append((grammar["position"], json.loads(target.content)["id"])) + expected = list( + dict.fromkeys( + [replacement, post["article"]["coverMediaId"], *post["article"]["mediaIds"]] + ) + ) + assert sorted(media_refs) == list(enumerate(expected)) + + +def test_bookmark_joins_legacy_and_native_identity_without_overwriting_text( + async_runner, native, tracked +): + post = native["posts"][0] + legacy = seed_block( + TweetResolver.create_block(Tweet(id=int(post["id"]), text="旧加工 [photo]")) + ) + citation = seed_block(BlockForm(resolver="core.text.v1", content="user citation")) + assert legacy.id is not None and citation.id is not None + tracked.update((legacy.id, citation.id)) + reference = seed_relation(citation.id, legacy.id, "reference") + old_attachment = seed_relation(legacy.id, citation.id, "attachment:photo:legacy") + result = async_runner.run(_write(ArchiveBatch.model_validate(native), tracked)) + assert result.block_ids["posts"][post["id"]] == legacy.id + with TestSession() as db: + persisted_reference = db.get(RelationModel, reference.id) + assert persisted_reference is not None and persisted_reference.to_ == legacy.id + assert db.get(RelationModel, old_attachment.id) is not None + + source_type = f"{Source.__module__}.{Source.__qualname__}" + async_runner.run(SourceManager.sync_source_types_async({source_type: Source})) + source = async_runner.run( + SourceManager.create(source_type, nickname="test Twitter bookmarks") + ) + sparse = {**_minimal(post), "text": {"text": "provider has less detail"}, "lang": "en"} + provider = mock.Mock() + provider.get_bookmarks = mock.AsyncMock( + return_value=TwitterAPIResult( + batch=ArchiveBatch.model_validate({"posts": [sparse]}), primary_post_ids=(post["id"],) + ) + ) + try: + with mock.patch.object( + TwitterAPI, "new", new_callable=mock.AsyncMock, return_value=provider + ): + async_runner.run( + Source(source.id).collect( + JobModel(type="core.source.collect.v1", parameters={}), CollectConfig() + ) + ) + stored = json.loads(_record(post["id"]).content) + assert stored["text"] == post["text"] + assert stored["lang"] == "en" + with TestSession() as db: + persisted_source = db.get(SourceModel, source.id) + assert persisted_source is not None and persisted_source.state is not None + assert persisted_source.state["latest_tweet_id"] == post["id"] + finally: + with TestSession() as db: + db.delete(db.get(SourceModel, source.id)) + db.commit() + + +def test_real_transaction_lock_preserves_concurrent_supplements_and_rollback( + async_runner, native, tracked +): + baseline = _minimal(native["posts"][0]) + original_lock = BlockRepository.acquire_transaction_lock + + async def exercise(): + locked, attempted, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + + async def coordinated(repository, namespace): + task = asyncio.current_task() + assert task is not None + if task.get_name() == "second": + attempted.set() + await original_lock(repository, namespace) + if task.get_name() == "first": + locked.set() + await release.wait() + + with mock.patch.object(BlockRepository, "acquire_transaction_lock", coordinated): + first = asyncio.create_task( + submit_archive( + ArchiveBatch.model_validate({"posts": [{**baseline, "source": "client"}]}) + ), + name="first", + ) + await locked.wait() + second = asyncio.create_task( + submit_archive( + ArchiveBatch.model_validate({"posts": [{**baseline, "lang": "zh"}]}) + ), + name="second", + ) + await attempted.wait() + release.set() + await asyncio.gather(first, second) + async with graph_uow() as uow: + roots = await uow.blocks.find_json_field_many( + (POST_RESOLVER, USER_RESOLVER), "id", (baseline["id"], baseline["authorId"]) + ) + tracked.update(block.id for block in roots) + posts = [root for root in roots if root.resolver == POST_RESOLVER] + assert len(posts) == 1 + stored = json.loads(posts[0].content) + assert stored["source"] == "client" and stored["lang"] == "zh" + + failed: dict[str, typing.Any] = { + **baseline, + "id": str(int(baseline["id"]) + 1000), + "quote": {"postId": str(int(baseline["id"]) + 1001)}, + } + batch = ArchiveBatch.model_validate({"posts": [failed]}) + with mock.patch.object( + RelationRepository, + "create_many", + new_callable=mock.AsyncMock, + side_effect=RuntimeError("deterministic persistence failure"), + ): + with pytest.raises(RuntimeError, match="deterministic persistence failure"): + await submit_archive(batch) + absent = await query_archive( + ArchiveQuery(posts=[failed["id"], failed["quote"]["postId"]]) + ) + assert [item.status for item in absent.posts] == ["missing", "missing"] + await _write(batch, tracked) + assert (await query_archive(ArchiveQuery(posts=[failed["id"]]))).posts[ + 0 + ].status == "found" + + async_runner.run(exercise()) + + +def test_batch_lookup_tolerates_non_json_and_reports_locatable_corruption( + async_runner, native, tracked +): + batch = ArchiveBatch.model_validate(native) + async_runner.run(_write(batch, tracked)) + unknown = str(int(batch.posts[0].id) + 1000) + broken = [ + seed_block(BlockForm(resolver="core.text.v1", content="ordinary non-JSON text")), + seed_block(BlockForm(resolver=POST_RESOLVER, content="broken JSON {")), + seed_block( + BlockForm( + resolver=POST_RESOLVER, + content=json.dumps({"id": unknown, "authorId": batch.posts[0].authorId}), + ) + ), + ] + tracked.update(block.id for block in broken) + response = async_runner.run( + query_archive(ArchiveQuery(posts=[batch.posts[0].id, unknown])) + ) + assert [item.status for item in response.posts] == ["found", "failed"] + + +def test_media_pointers_use_only_captured_https_cdn_mp4(async_runner, native, tracked): + base = int(native["media"][0]["id"]) + 1000 + records: list[dict[str, typing.Any]] = [ + {"id": str(base), "type": "photo", "url": "https://example.org/image.jpg"}, + { + "id": str(base + 1), + "type": "video", + "url": "https://pbs.twimg.com/poster.jpg", + "video": { + "variants": [ + {"url": "https://video.twimg.com/list.m3u8", "type": "application/x-mpegURL"} + ] + }, + }, + {"id": str(base + 2), "type": "photo", "url": "https://pbs.twimg.com/\nimage.jpg"}, + { + "id": str(base + 3), + "type": "video", + "url": "https://pbs.twimg.com/poster.jpg", + "video": { + "variants": [ + { + "url": f"https://video.twimg.com/{base}-low.mp4", + "type": "video/mp4", + "bitrate": 1, + }, + {"url": "https://example.org/foreign.mp4", "type": "video/mp4", "bitrate": 99}, + { + "url": f"https://video.twimg.com/{base}-high.mp4", + "type": "video/mp4", + "bitrate": 2, + }, + ] + }, + }, + ] + result = async_runner.run( + _write(ArchiveBatch.model_validate({"media": records}), tracked) + ) + response = async_runner.run( + query_archive(ArchiveQuery(media=[record["id"] for record in records])) + ) + assert [item.status for item in response.media] == ["found"] * 4 + assert [ + item.data.model_dump(mode="json", exclude_unset=True) for item in response.media + ] == records + with TestSession() as db: + content = db.exec( + sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.from_).in_(tuple(result.block_ids["media"].values())), + RelationModel.content == "content", + ) + ).all() + assert len(content) == 1 + pointer = db.get(BlockModel, content[0].to_) + assert ( + pointer is not None and pointer.content == f"https://video.twimg.com/{base}-high.mp4" + ) + + +def test_official_provider_keeps_source_text_and_exact_id_semantics( + async_runner, native, tracked +): + root = native["posts"][0] + batch = _official_archive( + [ + { + "id": root["id"], + "author_id": root["authorId"], + "created_at": root["createdAt"], + "text": "@reply 原始🌏 & https://t.co/media", + "conversation_id": "1", + "referenced_tweets": [{"type": "replied_to", "id": "2"}], + } + ], + {}, + "2026-10-19T01:00:00Z", + ) + assert batch.posts[0].text is not None + assert batch.posts[0].text.text == "@reply 原始🌏 & https://t.co/media" + assert batch.posts[0].conversationId == "1" + assert batch.posts[0].replyTo is not None and batch.posts[0].replyTo.postId == "2" + result = async_runner.run(_write(batch, tracked)) + assert result.response.posts[0].status == "accepted" + + +def test_twikit_bookmark_first_then_native_enrichment_keeps_one_root( + async_runner, native, tracked +): + post = native["posts"][0] + author = SimpleNamespace(id=post["authorId"], screen_name="source_handle", name="source") + raw = { + "rest_id": post["id"], + "legacy": { + "created_at": "Sun Oct 18 12:34:56 +0000 2026", + "full_text": "truncated legacy…", + "lang": "zh", + "conversation_id_str": str(int(post["id"]) - 1), + "in_reply_to_status_id_str": str(int(post["id"]) - 2), + "entities": {"media": [], "urls": []}, + }, + "note_tweet": { + "note_tweet_results": { + "result": { + "text": "@mention 完整🌏 & https://t.co/source", + } + } + }, + } + # Exercise the installed provider's real getters, including note-tweet selection. + tweet = twikit.Tweet(mock.Mock(), raw, typing.cast(twikit.User, author)) + batch = _twikit_archive((tweet,), "2026-10-19T01:00:00Z") + assert batch.posts[0].authorId == author.id + assert batch.posts[0].replyTo is not None and batch.posts[0].text is not None + assert batch.posts[0].conversationId != batch.posts[0].replyTo.postId + assert batch.posts[0].text.text == "@mention 完整🌏 & https://t.co/source" + source_type = f"{Source.__module__}.{Source.__qualname__}" + async_runner.run(SourceManager.sync_source_types_async({source_type: Source})) + source = async_runner.run(SourceManager.create(source_type, nickname="Twikit first")) + provider = mock.Mock() + provider.get_bookmarks = mock.AsyncMock( + return_value=TwitterAPIResult(batch=batch, primary_post_ids=(post["id"],)) + ) + try: + with mock.patch.object( + TwitterAPI, "new", new_callable=mock.AsyncMock, return_value=provider + ): + async_runner.run( + Source(source.id).collect( + JobModel(type="core.source.collect.v1", parameters={}), CollectConfig() + ) + ) + root = _record(post["id"]) + finally: + # Track native identities as well as the root created inside Source's UoW. + async def remember(): + async with graph_uow() as uow: + rows = await uow.blocks.find_json_field_many( + (POST_RESOLVER, USER_RESOLVER), + "id", + (post["id"], author.id, raw["legacy"]["in_reply_to_status_id_str"]), + ) + tracked.update(row.id for row in rows) + + async_runner.run(remember()) + with TestSession() as db: + db.delete(db.get(SourceModel, source.id)) + db.commit() + enriched = async_runner.run(_write(ArchiveBatch.model_validate(native), tracked)) + assert enriched.block_ids["posts"][post["id"]] == root.id + assert json.loads(_record(post["id"]).content)["text"] == post["text"] diff --git a/tests/extensions/twitter/integration/test_archive_http_roundtrip.py b/tests/extensions/twitter/integration/test_archive_http_roundtrip.py new file mode 100644 index 0000000..dbcd3a8 --- /dev/null +++ b/tests/extensions/twitter/integration/test_archive_http_roundtrip.py @@ -0,0 +1,156 @@ +"""The published x-monkey HTTP surface round-trips native records in PostgreSQL.""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +from types import SimpleNamespace +import uuid + +import pytest +import sqlalchemy +import sqlmodel + +from app.persistence.info_base.uow import graph_uow +from app.business.peer import PeerManager +from app.business.source import SourceManager +from app.schemas.info_base.block import BlockModel +from app.schemas.info_base.relation import RelationModel +from extensions.twitter.archive_session import SESSION_COOKIE_NAME, _issue_session_token +from extensions.twitter.reconcile import MEDIA_RESOLVER, POST_RESOLVER, USER_RESOLVER +from tests.database import TestSession +from tests.extensions.runtime_support import publish_extension + +pytestmark = pytest.mark.skipif( + not os.getenv("INKCRE_TEST_DATABASE_URL"), + reason="requires an explicitly selected migrated disposable PostgreSQL runtime", +) + + +def _distinct_fixture() -> dict: + path = Path(__file__).parents[1] / "fixtures" / "native_archive.json" + value = json.loads(path.read_text()) + offset = int(uuid.uuid4().hex[:15], 16) + 10**20 + + def distinct(item): + if isinstance(item, str): + if item.isascii() and item.isdecimal(): + return str(int(item) + offset) + if item.startswith(("https://pbs.twimg.com/", "https://video.twimg.com/")): + return f"{item}{'&' if '?' in item else '?'}twitter_http_test={offset}" + if isinstance(item, list): + return [distinct(child) for child in item] + if isinstance(item, dict): + return {key: distinct(child) for key, child in item.items()} + return item + + return distinct(value) + + +def _all_archive_ids(native: dict) -> tuple[str, ...]: + return tuple( + record["id"] for kind in ("users", "posts", "media") for record in native[kind] + ) + + +async def _cleanup_archive(native_ids: tuple[str, ...]) -> None: + async with graph_uow() as uow: + roots = await uow.blocks.find_json_field_many( + (POST_RESOLVER, USER_RESOLVER, MEDIA_RESOLVER), "id", native_ids + ) + tracked_ids = {block.id for block in roots if block.id is not None} + relations = await uow.relations.get_outgoing_many(tracked_ids) + tracked_ids.update(relation.to_ for relation in relations) + + if not tracked_ids: + return + with TestSession() as db: + for relation in db.exec( + sqlmodel.select(RelationModel).where( + sqlalchemy.or_( + sqlmodel.col(RelationModel.from_).in_(tracked_ids), + sqlmodel.col(RelationModel.to_).in_(tracked_ids), + ) + ) + ).all(): + db.delete(relation) + for block in db.exec( + sqlmodel.select(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tracked_ids)) + ).all(): + db.delete(block) + db.commit() + + +def test_real_http_submit_query_preserves_native_wire_payload(monkeypatch): + from extensions.twitter import Extension + + native = _distinct_fixture() + simple_post_id = str(int(uuid.uuid4().hex[:15], 16) + 10**30) + simple_author_id = str(int(simple_post_id) + 1) + native_ids = (*_all_archive_ids(native), simple_post_id) + + async def sync_source_types(): + return None + + async def current_config(): + return SimpleNamespace(http_public_base_url="https://core.example") + + monkeypatch.setattr(SourceManager, "sync_source_types_async", sync_source_types) + monkeypatch.setattr(PeerManager, "get_current_config_async", current_config) + token, _ = _issue_session_token() + published = publish_extension(Extension) + try: + with published.client as client: + client.cookies.set(SESSION_COOKIE_NAME, token) + headers = {"Origin": "https://x.com", "X-Tweet-DB-Client": "x-monkey"} + simple_post = { + "id": simple_post_id, + "authorId": simple_author_id, + "createdAt": "2026-10-19T12:00:00Z", + "text": {"text": "HTTP to PostgreSQL round trip"}, + } + simple_submit = client.post( + "/twitter/x-monkey/api/v1/archive/submit", + json={"posts": [simple_post]}, + headers=headers, + ) + assert simple_submit.status_code == 200, simple_submit.text + simple_query = client.post( + "/twitter/x-monkey/api/v1/archive/query", + json={"posts": [simple_post_id]}, + headers=headers, + ) + assert simple_query.status_code == 200, simple_query.text + assert simple_query.json()["posts"] == [ + {"id": simple_post_id, "status": "found", "data": simple_post} + ] + + submitted = client.post( + "/twitter/x-monkey/api/v1/archive/submit", + json=native, + headers=headers, + ) + assert submitted.status_code == 200, submitted.text + assert submitted.json()["summary"]["total"] == sum( + len(native[kind]) for kind in ("users", "posts", "media") + ) + + query = { + kind: [record["id"] for record in native[kind]] + for kind in ("users", "posts", "media") + } + queried = client.post( + "/twitter/x-monkey/api/v1/archive/query", + json=query, + headers=headers, + ) + assert queried.status_code == 200, queried.text + response = queried.json() + for kind in ("users", "posts", "media"): + assert [item["status"] for item in response[kind]] == ["found"] * len(native[kind]) + assert [item["data"] for item in response[kind]] == native[kind] + finally: + published.unpublish() + asyncio.run(_cleanup_archive(native_ids)) diff --git a/tests/extensions/twitter/test_archive_contract.py b/tests/extensions/twitter/test_archive_contract.py new file mode 100644 index 0000000..0dafd85 --- /dev/null +++ b/tests/extensions/twitter/test_archive_contract.py @@ -0,0 +1,243 @@ +"""Observable native archive round trips and x-monkey capture merge semantics.""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +import pydantic +import pytest + +from extensions.twitter.archive_merge import merge_archive_record +from extensions.twitter.archive_schema import ( + ArchiveBatch, + ArchivePost, + ArchiveQuery, + QueryResponse, + SessionResponse, + SubmitResponse, +) + + +@pytest.fixture +def full_archive() -> dict[str, Any]: + fixture_path = Path(__file__).parent / "fixtures" / "native_archive.json" + return json.loads(fixture_path.read_text(encoding="utf-8")) + + +def test_native_batch_preserves_complete_source_and_field_presence(full_archive): + original = copy.deepcopy(full_archive) + batch = ArchiveBatch.model_validate(full_archive) + assert batch.model_dump(mode="json", exclude_unset=True) == original + assert json.loads(batch.model_dump_json(exclude_unset=True)) == original + assert full_archive == original + assert batch.posts[0].communityNote is not None + assert batch.posts[0].communityNote.text.model_dump(exclude_unset=True) == { + "text": "来源已核实" + } + + +def test_missing_and_explicit_empty_batches_remain_distinct(): + batch = ArchiveBatch.model_validate({}) + query = ArchiveQuery.model_validate({}) + assert batch.users == batch.posts == batch.media == [] + assert query.users == query.posts == query.media == [] + assert batch.model_dump(mode="json", exclude_unset=True) == {} + assert query.model_dump(mode="json", exclude_unset=True) == {} + assert ArchiveBatch(posts=[]).model_dump(mode="json", exclude_unset=True) == {"posts": []} + + +@pytest.mark.parametrize( + "invalid_id", ["0", "01", "-1", "1.0", " 1", "1\n", "11", 1, True] +) +def test_ids_are_positive_decimal_strings(invalid_id): + with pytest.raises(pydantic.ValidationError): + ArchiveQuery.model_validate({"posts": [invalid_id]}) + + +@pytest.mark.parametrize( + ("path", "invalid"), + [ + (("users", 0, "counts", "likes"), True), + (("users", 0, "counts", "likes"), -1), + (("users", 0, "counts", "likes"), 1.0), + (("users", 0, "counts", "likes"), "1"), + (("posts", 0, "createdAt"), "2026-02-29T00:00:00Z"), + (("posts", 0, "createdAt"), "2026-10-19T00:00:00.123Z"), + (("posts", 0, "createdAt"), "2026-10-19T00:00:00+00:00"), + (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T24:00:00Z"), + (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T01:00:00+08:00"), + (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T01:00:00"), + (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T01:00Z"), + (("users", 0, "countsObservedAt", "posts"), "2026-10-19T01:02Z"), + (("posts", 0, "text", "visible"), [9, 2]), + (("posts", 0, "text", "visible"), [False, 2]), + (("posts", 0, "text", "entities"), []), + (("posts", 0, "mediaIds"), []), + (("posts", 0, "editIds"), ["31"]), + (("posts", 0, "card", "poll", "choices"), [{"label": "only one"}]), + (("posts", 0, "card", "extra"), {"unexpected": 1}), + (("posts", 0, "place", "bbox"), [0, True, 2, 3]), + (("posts", 0, "place", "bbox"), [0, float("inf"), 2, 3]), + (("posts", 0, "quote", "tombstone"), ""), + (("media", 0, "video", "variants"), []), + (("media", 0, "crops"), [[0, 0, -1, 10]]), + (("posts", 0, "text"), None), + (("users", 0, "verified", "blue"), None), + (("posts", 0, "article", "blocks", 0, "unknown"), True), + (("media", 0, "unknown"), "discarding this would lose information"), + ], +) +def test_invalid_native_records_fail_instead_of_coercing_or_discarding( + full_archive, path, invalid +): + current = full_archive + for part in path[:-1]: + current = current[part] + current[path[-1]] = invalid + with pytest.raises(pydantic.ValidationError): + ArchiveBatch.model_validate(full_archive) + + +@pytest.mark.parametrize("kind", ["users", "posts", "media"]) +def test_query_and_submission_have_per_kind_limits(full_archive, kind): + assert len(getattr(ArchiveQuery.model_validate({kind: ["1"] * 1000}), kind)) == 1000 + with pytest.raises(pydantic.ValidationError): + ArchiveQuery.model_validate({kind: ["1"] * 1001}) + record = full_archive[kind][0] + assert len(getattr(ArchiveBatch.model_validate({kind: [record] * 1000}), kind)) == 1000 + with pytest.raises(pydantic.ValidationError): + ArchiveBatch.model_validate({kind: [record] * 1001}) + + +def test_query_submit_and_session_wire_roundtrip(full_archive): + query = { + "users": [{"id": "17", "status": "found", "data": full_archive["users"][0]}], + "posts": [{"id": "31", "status": "missing"}], + "media": [{"id": "41", "status": "failed", "error": "invalid stored archive"}], + } + assert ( + QueryResponse.model_validate(query).model_dump(mode="json", exclude_unset=True) == query + ) + submission = { + "summary": {"total": 4, "accepted": 1, "unchanged": 1, "partial": 1, "failed": 1}, + "users": [{"id": "17", "status": "accepted"}], + "posts": [{"id": "31", "status": "unchanged"}, {"id": "32", "status": "partial"}], + "media": [{"id": "41", "status": "failed", "reason": "invalid media"}], + } + assert ( + SubmitResponse.model_validate(submission).model_dump(mode="json", exclude_unset=True) + == submission + ) + session = { + "authenticated": False, + "registered": False, + "username": None, + "expires_at": None, + "account_url": None, + } + assert SessionResponse.model_validate(session).model_dump(mode="json") == session + with pytest.raises(pydantic.ValidationError): + ArchivePost.model_validate({"id": "31", "authorId": "17", "createdAt": None}) + + +def test_merge_is_recursive_replaces_arrays_and_does_not_mutate_inputs(): + existing: dict[str, Any] = { + "id": "31", + "text": {"text": "正文", "visible": [0, 2], "entities": [{"type": "hashtag"}]}, + "article": {"title": "长文", "blocks": [{"text": "第一版"}]}, + } + incoming: dict[str, Any] = { + "text": {"text": "修订正文"}, + "article": {"blocks": [{"text": "完整新版"}]}, + } + old_existing, old_incoming = copy.deepcopy(existing), copy.deepcopy(incoming) + merged: dict[str, Any] = merge_archive_record(existing, incoming) + assert merged == { + "id": "31", + "text": {"text": "修订正文", "visible": [0, 2], "entities": [{"type": "hashtag"}]}, + "article": {"title": "长文", "blocks": [{"text": "完整新版"}]}, + } + merged["article"]["blocks"][0]["text"] = "调用方修改" + merged["text"]["entities"][0]["type"] = "调用方修改" + assert existing == old_existing + assert incoming == old_incoming + + +def test_counter_observations_merge_independently_and_compare_instants(): + existing: dict[str, Any] = { + "counts": {"likes": 10, "views": 100, "quotes": 3}, + "countsObservedAt": { + "likes": "2026-10-19T01:00:00.900Z", + "views": "2026-10-19T01:00:00Z", + "quotes": "2026-10-19T01:00:00Z", + }, + } + merged = merge_archive_record( + existing, + { + "lang": "zh", + "counts": {"likes": 9, "views": 110, "quotes": 2, "bookmarks": 0}, + "countsObservedAt": { + "likes": "2026-10-19T01:00:00Z", + "views": "2026-10-19T01:00:00.100Z", + "quotes": "2026-10-19T01:00:00Z", + }, + }, + ) + assert merged == { + "lang": "zh", + "counts": {"likes": 10, "views": 110, "quotes": 2, "bookmarks": 0}, + "countsObservedAt": { + "likes": "2026-10-19T01:00:00.900Z", + "views": "2026-10-19T01:00:00.100Z", + "quotes": "2026-10-19T01:00:00Z", + }, + } + + +def test_untimed_changes_remove_only_the_changed_counters_previous_time(): + existing: dict[str, Any] = { + "counts": {"likes": 10, "views": 100}, + "countsObservedAt": {"likes": "2026-10-19T01:00:00Z", "views": "2026-10-19T01:00:00Z"}, + } + assert merge_archive_record(existing, {"counts": {"likes": 11, "views": 100}}) == { + "counts": {"likes": 11, "views": 100}, + "countsObservedAt": {"views": "2026-10-19T01:00:00Z"}, + } + assert merge_archive_record(existing, {"counts": {"likes": 11, "views": 101}}) == { + "counts": {"likes": 11, "views": 101} + } + + +def test_equal_counts_keep_new_observation_metadata_and_replay_is_unchanged(): + existing: dict[str, Any] = { + "counts": {"likes": 10}, + "countsObservedAt": {"likes": "2026-10-19T01:00:00Z"}, + } + incoming: dict[str, Any] = { + "counts": {"likes": 10}, + "countsObservedAt": {"likes": "2026-10-19T01:00:00.001Z"}, + } + merged = merge_archive_record(existing, incoming) + assert merged == incoming + assert merged != existing + assert merge_archive_record(merged, incoming) == merged + assert ( + merge_archive_record(existing, {"countsObservedAt": incoming["countsObservedAt"]}) + == existing + ) + + +def test_capture_time_comparison_matches_javascript_millisecond_precision(): + existing: dict[str, Any] = { + "counts": {"likes": 10}, + "countsObservedAt": {"likes": "2026-10-19T01:00:00.1009Z"}, + } + incoming: dict[str, Any] = { + "counts": {"likes": 11}, + "countsObservedAt": {"likes": "2026-10-19T01:00:00.1001Z"}, + } + assert merge_archive_record(existing, incoming) == incoming diff --git a/tests/extensions/twitter/test_archive_http.py b/tests/extensions/twitter/test_archive_http.py new file mode 100644 index 0000000..d433d84 --- /dev/null +++ b/tests/extensions/twitter/test_archive_http.py @@ -0,0 +1,256 @@ +"""HTTP boundary checks for the x-monkey-compatible remote archive API.""" + +from __future__ import annotations + +import time +from types import SimpleNamespace + +from fastapi import FastAPI +from fastapi.testclient import TestClient +import jwt +import pytest + +from app.business.peer import PeerManager +from app.database_contract.constants import JWT_ALGORITHM +from app.middleware import create_peer_jwt +from app.settings import Settings, settings +from extensions.twitter import archive_http, archive_session +from extensions.twitter.archive_schema import QueryResponse, SubmitResponse, SubmitSummary +from tests.extensions.runtime_support import publish_extension + + +@pytest.fixture +def archive_client(monkeypatch): + async def current_config(): + return SimpleNamespace(http_public_base_url="https://core.example/prefix") + + monkeypatch.setattr(PeerManager, "get_current_config_async", current_config) + app = FastAPI() + app.include_router(archive_session.router, prefix="/twitter/x-monkey") + app.include_router(archive_http.router, prefix="/twitter/x-monkey") + return TestClient(app, base_url="https://core.example") + + +def _session_cookie() -> str: + token, _ = archive_session._issue_session_token() + return token + + +def test_session_lifetime_defaults_to_seven_days_and_supports_environment_override( + monkeypatch, +): + required = { + "database_url": "postgresql+psycopg://unused:unused@127.0.0.1:1/unused", + "jwt_secret": "test-only-jwt-secret-at-least-32-bytes", + } + monkeypatch.delenv("TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS", raising=False) + default = Settings(_env_file=None, **required) + assert default.twitter_x_monkey_jwt_lifetime_seconds == 7 * 24 * 60 * 60 + + monkeypatch.setenv("TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS", "3600") + overridden = Settings(_env_file=None, **required) + assert overridden.twitter_x_monkey_jwt_lifetime_seconds == 3600 + + +def test_connect_exchanges_peer_token_for_scoped_http_only_cookie(archive_client): + response = archive_client.post( + "/twitter/x-monkey/session", + data={"token": create_peer_jwt(settings.jwt_secret)}, + headers={ + "Origin": "https://web.example", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert ( + response.headers["location"] == "https://core.example/prefix/twitter/x-monkey/account" + ) + cookie = response.headers["set-cookie"] + assert "inkcre_twitter_archive=" in cookie + assert "HttpOnly" in cookie and "Secure" in cookie and "samesite=none" in cookie.lower() + assert "Path=/prefix/twitter/x-monkey/" in cookie + + +def test_session_get_needs_no_json_or_client_header_and_accepts_extension_origin( + archive_client, +): + response = archive_client.get( + "/twitter/x-monkey/api/v1/session", + headers={ + "Origin": "chrome-extension://abcdefghijklmnop", + "Accept": "application/json", + "Cookie": f"{archive_session.SESSION_COOKIE_NAME}={_session_cookie()}", + "Sec-Fetch-Mode": "cors", + }, + ) + + assert response.status_code == 200 + assert response.json()["authenticated"] is True + assert ( + response.json()["account_url"] == "https://core.example/prefix/twitter/x-monkey/account" + ) + assert "nosniff" in response.headers["x-content-type-options"] + assert "set-cookie" not in response.headers + + +def test_session_renews_cookie_inside_half_life(archive_client, monkeypatch): + now = int(time.time()) + lifetime = settings.twitter_x_monkey_jwt_lifetime_seconds + claims = jwt.decode( + _session_cookie(), + options={"verify_signature": False, "verify_exp": False}, + ) + claims["iat"] = now + claims["exp"] = now + lifetime // 2 - 1 + token = jwt.encode(claims, settings.jwt_secret, algorithm=JWT_ALGORITHM) + monkeypatch.setattr(archive_session.time, "time", lambda: now) + + response = archive_client.get( + "/twitter/x-monkey/api/v1/session", + headers={ + "Cookie": f"{archive_session.SESSION_COOKIE_NAME}={token}", + "Sec-Fetch-Mode": "cors", + }, + ) + + assert response.status_code == 200 + cookie = response.headers["set-cookie"].split(";", 1)[0] + renewed_token = cookie.split("=", 1)[1] + renewed_claims = archive_session._decode_peer_token(renewed_token) + assert renewed_claims is not None + assert renewed_claims["exp"] == now + lifetime + + +@pytest.mark.parametrize( + "origin", ["null", "https://[invalid", "http://x.com", "https://unknown.example"] +) +def test_session_rejects_null_insecure_or_unknown_origin(archive_client, origin): + response = archive_client.get( + "/twitter/x-monkey/api/v1/session", + headers={"Origin": origin, "Sec-Fetch-Mode": "cors"}, + ) + + assert response.status_code == 403 + + +def test_disconnect_is_limited_to_core_origin_and_clears_the_cookie(archive_client): + headers = { + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Dest": "document", + } + foreign = archive_client.post( + "/twitter/x-monkey/disconnect", + headers={**headers, "Origin": "https://web.example"}, + ) + assert foreign.status_code == 403 + + response = archive_client.post( + "/twitter/x-monkey/disconnect", + headers={**headers, "Origin": "https://core.example"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert ( + response.headers["location"] == "https://core.example/prefix/twitter/x-monkey/account" + ) + cookie = response.headers["set-cookie"] + assert "inkcre_twitter_archive=" in cookie + assert "Max-Age=0" in cookie + + +def test_query_requires_client_headers_and_session_cookie(archive_client, monkeypatch): + async def query(body): + assert body.posts == ["9007199254740993"] + return QueryResponse(users=[], posts=[], media=[]) + + monkeypatch.setattr(archive_http, "query_archive", query) + response = archive_client.post( + "/twitter/x-monkey/api/v1/archive/query", + json={"posts": ["9007199254740993"]}, + headers={ + "X-Tweet-DB-Client": "x-monkey", + "Cookie": f"{archive_session.SESSION_COOKIE_NAME}={_session_cookie()}", + "Origin": "https://x.com", + }, + ) + assert response.status_code == 200 + + no_cookie = archive_client.post( + "/twitter/x-monkey/api/v1/archive/query", + json={"posts": ["1"]}, + headers={"X-Tweet-DB-Client": "x-monkey", "Origin": "https://x.com"}, + ) + assert no_cookie.status_code == 401 + + no_client_header = archive_client.post( + "/twitter/x-monkey/api/v1/archive/query", + json={"posts": ["1"]}, + headers={"Cookie": f"{archive_session.SESSION_COOKIE_NAME}={_session_cookie()}"}, + ) + assert no_client_header.status_code == 400 + + +def test_submit_passes_validated_batch_to_native_protocol(archive_client, monkeypatch): + async def submit(batch): + assert batch.model_dump(exclude_unset=True) == {"posts": []} + return SubmitResponse( + summary=SubmitSummary(accepted=0, unchanged=0, partial=0, failed=0, total=0), + users=[], + posts=[], + media=[], + ) + + monkeypatch.setattr(archive_http, "submit_archive", submit) + response = archive_client.post( + "/twitter/x-monkey/api/v1/archive/submit", + json={"posts": []}, + headers={ + "X-Tweet-DB-Client": "x-monkey", + "Cookie": f"{archive_session.SESSION_COOKIE_NAME}={_session_cookie()}", + "Origin": "chrome-extension://abcdefghijklmnop", + }, + ) + assert response.status_code == 200 + assert response.json()["summary"]["total"] == 0 + + +def test_session_does_not_renew_for_no_cors_subresource(archive_client): + response = archive_client.get( + "/twitter/x-monkey/api/v1/session", + headers={ + "Cookie": f"{archive_session.SESSION_COOKIE_NAME}={_session_cookie()}", + "Sec-Fetch-Mode": "no-cors", + }, + ) + assert response.status_code == 403 + assert "set-cookie" not in response.headers + + +def test_extension_publishes_claimed_archive_routes(monkeypatch): + from extensions.twitter import Extension + from app.business.source import SourceManager + + async def sync_source_types(): + return None + + async def current_config(): + return SimpleNamespace(http_public_base_url="https://core.example") + + monkeypatch.setattr(SourceManager, "sync_source_types_async", sync_source_types) + monkeypatch.setattr(PeerManager, "get_current_config_async", current_config) + + published = publish_extension(Extension) + try: + published.client.cookies.set(archive_session.SESSION_COOKIE_NAME, _session_cookie()) + response = published.client.get("/twitter/x-monkey/api/v1/session") + assert response.status_code == 200 + assert response.json()["authenticated"] is True + assert response.json()["account_url"] == "https://core.example/twitter/x-monkey/account" + + core_endpoint = published.client.get("/twitter/setup") + assert core_endpoint.status_code == 401 + finally: + published.unpublish() From d06f562d59a8c1400ef1702eb9fc2dceeec7e257 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 16:37:31 +0800 Subject: [PATCH 03/15] =?UTF-8?q?fix(twitter):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=AA=92=E4=BD=93=E5=86=85=E5=AE=B9=E6=8C=87?= =?UTF-8?q?=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在同一事务按 resolver 与 URL 集合定位和创建 HTTP 指针 - 保留 Storage 身份、稳定复用与相同 URL 去重行为 - 用代表批次往返上界和指针复用回归防止远程数据库超时 --- app/persistence/info_base/repository.py | 17 ++++ docs/30-unit-tdd/twitter-extension.md | 2 + extensions/twitter/reconcile.py | 30 +++--- .../twitter/integration/test_archive_graph.py | 97 +++++++++++++++++++ 4 files changed, 135 insertions(+), 11 deletions(-) diff --git a/app/persistence/info_base/repository.py b/app/persistence/info_base/repository.py index f87b6c0..627de3a 100644 --- a/app/persistence/info_base/repository.py +++ b/app/persistence/info_base/repository.py @@ -254,6 +254,23 @@ async def matching_content(self, resolver: str, content: str) -> tuple[BlockMode ) ) + async def matching_content_many( + self, resolver: str, contents: typing.Collection[str] + ) -> tuple[BlockModel, ...]: + """Find a bounded set of exact content identities in one database round trip.""" + if not contents: + return () + return tuple( + await self._session.scalars( + sqlmodel.select(BlockModel) + .where( + BlockModel.resolver == resolver, + sqlmodel.col(BlockModel.content).in_(tuple(contents)), + ) + .order_by(sqlmodel.col(BlockModel.id)) + ) + ) + async def without_outgoing_relation( self, resolvers: typing.Collection[str], content: str, *, limit: int ) -> tuple[BlockModel, ...]: diff --git a/docs/30-unit-tdd/twitter-extension.md b/docs/30-unit-tdd/twitter-extension.md index b81f4e3..9374621 100644 --- a/docs/30-unit-tdd/twitter-extension.md +++ b/docs/30-unit-tdd/twitter-extension.md @@ -6,6 +6,8 @@ Python Twitter distribution 把 x-monkey 提交和 Bookmark 采集写入同一 两条写入路径都先取得 `extensions.twitter.reconcile` 全局事务 advisory 锁,再批量重读原生身份。根内容和受管关系一起提交;异常让整批回滚,调用方可安全重放。该锁仅约束正常 Twitter 写入入口,不能替代所有 info-base 原始写入的约束。provider 和媒体网络访问都不能放进此事务。 +媒体内容指针按 `(resolver, URL)` 去重后批量定位和创建,仍要求匹配 HTTP Storage;相同 URL 复用同一内容 Block。不要在每个媒体项内单独查询、创建和 flush:Core 与数据库跨网络部署时,这会把一次正常批次放大为数百次串行往返,并在持锁期间耗尽客户端的同步请求期限。 + 匹配先选新版 resolver,再按 Block ID 稳定选择旧 `tweet.v1`。旧根重新采集后原地更新,保留现有 Block ID、引用和旧附件关系;未重新采集的旧根仍由 v1 decoder 读取。旧加工正文不足以重建 native RichText,不能用它伪造新记录。Bookmark provider 适配保留实际源正文;共同合并在已有 native 正文时不让较少详情的 Bookmark 正文替换它,其他可证明字段仍可补充。x-monkey 提交继续使用原生稀疏合并语义。 身份引用严格保存为 `{id}`,不补作者、时间或正文。query 对完整 native 记录返回 `found`,身份引用返回 `missing`,能按 ID 定位但结构损坏的记录返回 `failed`。批量身份查询使用有效 JSON 的条件表达式隔离普通文本和损坏 JSON;无法解析且无法定位 ID 的内容不会命中某个原生 ID,不会导致其他正常记录的查询失败。 diff --git a/extensions/twitter/reconcile.py b/extensions/twitter/reconcile.py index f9f7908..9b7a794 100644 --- a/extensions/twitter/reconcile.py +++ b/extensions/twitter/reconcile.py @@ -240,6 +240,7 @@ async def persist_archive( desired: set[tuple[int, int, str]] = set() post_ids: set[int] = set() media_ids: set[int] = set() + media_pointers: dict[int, tuple[str, str]] = {} for key in incoming: root_id = _block_id(roots[key]) record = records[key] @@ -253,17 +254,24 @@ async def persist_archive( media_ids.add(root_id) pointer = media_content_pointer(record) if pointer is not None: - resolver, url = pointer - candidates = await uow.blocks.matching_content(resolver, url) - content = next( - (block for block in sorted(candidates, key=_block_id) if block.storage == -1), - None, - ) - if content is None: - content = await uow.blocks.create( - BlockForm(resolver=resolver, content=url, storage=-1) - ) - desired.add((root_id, _block_id(content), "content")) + media_pointers[root_id] = pointer + + contents: dict[tuple[str, str], BlockModel] = {} + for resolver in sorted({pointer[0] for pointer in media_pointers.values()}): + urls = {url for kind, url in media_pointers.values() if kind == resolver} + for block in await uow.blocks.matching_content_many(resolver, urls): + if block.storage == -1: + contents.setdefault((block.resolver, block.content), block) + missing_pointers = sorted(set(media_pointers.values()) - contents.keys()) + created = await uow.blocks.create_many( + BlockForm(resolver=resolver, content=url, storage=-1) + for resolver, url in missing_pointers + ) + contents.update(zip(missing_pointers, created, strict=True)) + desired.update( + (root_id, _block_id(contents[pointer]), "content") + for root_id, pointer in media_pointers.items() + ) stale: list[int] = [] for relation in await uow.relations.get_outgoing_many(post_ids | media_ids): managed = (relation.from_ in post_ids and _is_reference(relation.content)) or ( diff --git a/tests/extensions/twitter/integration/test_archive_graph.py b/tests/extensions/twitter/integration/test_archive_graph.py index 68d0b6f..fd7b221 100644 --- a/tests/extensions/twitter/integration/test_archive_graph.py +++ b/tests/extensions/twitter/integration/test_archive_graph.py @@ -13,6 +13,7 @@ import pytest import sqlalchemy +from sqlalchemy import event import sqlmodel import twikit @@ -475,6 +476,102 @@ def test_media_pointers_use_only_captured_https_cdn_mp4(async_runner, native, tr ) +def test_representative_batch_keeps_database_round_trips_bounded( + async_runner, native, tracked +): + from app.engine import ASYNC_DB_ENGINE + + base = int(native["posts"][0]["id"]) + raw: dict[str, list[dict[str, typing.Any]]] = {"users": [], "posts": [], "media": []} + for index in range(200): + user, post, media, quoted = ( + str(base + offset + index) for offset in (0, 1000, 2000, 3000) + ) + raw["users"].append({"id": user, "handle": f"author{index}", "name": f"Author {index}"}) + raw["posts"].append( + { + "id": post, + "authorId": user, + "createdAt": "2026-10-19T01:00:00Z", + "text": {"text": "benchmark " + "正文 " * 40}, + "mediaIds": [media], + "quote": {"postId": quoted}, + } + ) + raw["media"].append( + { + "id": media, + "type": "photo", + "url": f"https://pbs.twimg.com/batch-{base}-{index}.jpg", + } + ) + calls: list[str] = [] + + def count_round_trip(_connection, _cursor, statement, _parameters, _context, _many): + calls.append(statement.split(None, 1)[0]) + + event.listen(ASYNC_DB_ENGINE.sync_engine, "before_cursor_execute", count_round_trip) + try: + result = async_runner.run(_write(ArchiveBatch.model_validate(raw), tracked)) + finally: + event.remove(ASYNC_DB_ENGINE.sync_engine, "before_cursor_execute", count_round_trip) + assert result.response.summary.accepted == 600 + # Hundreds of per-media queries exceed the client's 15 s budget on remote DBs. + assert len(calls) < 20, {kind: calls.count(kind) for kind in set(calls)} + assert len(result.block_ids["posts"]) == 400 + with TestSession() as db: + contents = db.exec( + sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.from_).in_(tuple(result.block_ids["media"].values())), + RelationModel.content == "content", + ) + ).all() + assert len(contents) == 200 + assert len({relation.to_ for relation in contents}) == 200 + + +def test_media_pointer_reuse_keeps_storage_identity_and_shared_urls( + async_runner, native, tracked +): + base = int(native["media"][0]["id"]) + 1000 + existing_url = f"https://pbs.twimg.com/existing-{base}.jpg" + new_url = f"https://pbs.twimg.com/new-{base}.jpg" + inline = seed_block(BlockForm(resolver="core.image.v1", content=existing_url)) + existing = seed_block( + BlockForm(resolver="core.image.v1", content=existing_url, storage=-1) + ) + duplicate = seed_block( + BlockForm(resolver="core.image.v1", content=existing_url, storage=-1) + ) + assert inline.id is not None and existing.id is not None and duplicate.id is not None + tracked.update((inline.id, existing.id, duplicate.id)) + records = [ + {"id": str(base + index), "type": "photo", "url": url} + for index, url in enumerate((existing_url, existing_url, new_url, new_url)) + ] + result = async_runner.run( + _write(ArchiveBatch.model_validate({"media": records}), tracked) + ) + with TestSession() as db: + links = db.exec( + sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.from_).in_(tuple(result.block_ids["media"].values())), + RelationModel.content == "content", + ) + ).all() + content_for = {relation.from_: relation.to_ for relation in links} + media_ids = result.block_ids["media"] + assert content_for[media_ids[str(base)]] == existing.id + assert content_for[media_ids[str(base + 1)]] == existing.id + assert content_for[media_ids[str(base + 2)]] == content_for[media_ids[str(base + 3)]] + created = db.exec( + sqlmodel.select(BlockModel).where( + BlockModel.resolver == "core.image.v1", BlockModel.content == new_url + ) + ).all() + assert len(created) == 1 and created[0].storage == -1 + + def test_official_provider_keeps_source_text_and_exact_id_semantics( async_runner, native, tracked ): From 7773d98729314795f65bdd16d080bda34bb58c2d Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 17:44:07 +0800 Subject: [PATCH 04/15] =?UTF-8?q?docs(shared):=20=E5=90=8C=E6=AD=A5=20Twit?= =?UTF-8?q?ter=20=E5=AA=92=E4=BD=93=E6=8C=89=E9=9C=80=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index 6cf77bc..5221c88 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 6cf77bc7506d1b89afc18e52645e9f010164d554 +Subproject commit 5221c88cbc88b71a2f896d4fd20774a61aa54288 From 090429e53eb319b07aabf112c64adeb20e854172 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 17:49:14 +0800 Subject: [PATCH 05/15] =?UTF-8?q?fix(twitter):=20=E4=BB=85=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=8E=9F=E7=94=9F=E5=AA=92=E4=BD=93=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=BB=A5=E4=BF=9D=E6=8C=81=E6=8C=89=E9=9C=80=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不预建会被全库索引读取的普通媒体内容块 - 移除不再使用的批量内容指针查询 - 通过真实词法维护和检索验证归档与 Bookmark 零媒体读取 --- app/persistence/info_base/repository.py | 17 -- docs/30-unit-tdd/twitter-extension.md | 10 +- extensions/twitter/reconcile.py | 67 +------- .../twitter/integration/test_archive_graph.py | 153 +++++++++--------- 4 files changed, 87 insertions(+), 160 deletions(-) diff --git a/app/persistence/info_base/repository.py b/app/persistence/info_base/repository.py index 627de3a..f87b6c0 100644 --- a/app/persistence/info_base/repository.py +++ b/app/persistence/info_base/repository.py @@ -254,23 +254,6 @@ async def matching_content(self, resolver: str, content: str) -> tuple[BlockMode ) ) - async def matching_content_many( - self, resolver: str, contents: typing.Collection[str] - ) -> tuple[BlockModel, ...]: - """Find a bounded set of exact content identities in one database round trip.""" - if not contents: - return () - return tuple( - await self._session.scalars( - sqlmodel.select(BlockModel) - .where( - BlockModel.resolver == resolver, - sqlmodel.col(BlockModel.content).in_(tuple(contents)), - ) - .order_by(sqlmodel.col(BlockModel.id)) - ) - ) - async def without_outgoing_relation( self, resolvers: typing.Collection[str], content: str, *, limit: int ) -> tuple[BlockModel, ...]: diff --git a/docs/30-unit-tdd/twitter-extension.md b/docs/30-unit-tdd/twitter-extension.md index 9374621..61630cf 100644 --- a/docs/30-unit-tdd/twitter-extension.md +++ b/docs/30-unit-tdd/twitter-extension.md @@ -6,7 +6,7 @@ Python Twitter distribution 把 x-monkey 提交和 Bookmark 采集写入同一 两条写入路径都先取得 `extensions.twitter.reconcile` 全局事务 advisory 锁,再批量重读原生身份。根内容和受管关系一起提交;异常让整批回滚,调用方可安全重放。该锁仅约束正常 Twitter 写入入口,不能替代所有 info-base 原始写入的约束。provider 和媒体网络访问都不能放进此事务。 -媒体内容指针按 `(resolver, URL)` 去重后批量定位和创建,仍要求匹配 HTTP Storage;相同 URL 复用同一内容 Block。不要在每个媒体项内单独查询、创建和 flush:Core 与数据库跨网络部署时,这会把一次正常批次放大为数百次串行往返,并在持锁期间耗尽客户端的同步请求期限。 +原生身份、Blocks 和引用边使用批量查询与创建。不要在每个媒体项内单独查询、创建和 flush:Core 与数据库跨网络部署时,这会把一次正常批次放大为数百次串行往返,并在持锁期间耗尽客户端的同步请求期限。 匹配先选新版 resolver,再按 Block ID 稳定选择旧 `tweet.v1`。旧根重新采集后原地更新,保留现有 Block ID、引用和旧附件关系;未重新采集的旧根仍由 v1 decoder 读取。旧加工正文不足以重建 native RichText,不能用它伪造新记录。Bookmark provider 适配保留实际源正文;共同合并在已有 native 正文时不让较少详情的 Bookmark 正文替换它,其他可证明字段仍可补充。x-monkey 提交继续使用原生稀疏合并语义。 @@ -14,10 +14,12 @@ Python Twitter distribution 把 x-monkey 提交和 Bookmark 采集写入同一 原生观察时间保存输入字符串,不在序列化时改写精度。逐计数比较遵守上游的毫秒时间比较规则;较旧观察不覆盖新值,相同值的新时间也更新持久记录,无时间的新值删除该计数的旧时间标签。`unchanged` 表示没有持久变化,因此仅观察时间更新仍为 `accepted`。 -新版引用边只维护约定的 reference schema,旧 `attachment:*`、`entities:url` 和用户关系保留。媒体引用顺序是 `post.mediaIds`、文章封面、`article.mediaIds` 依次合并去重,首次出现保留。Media 根通过 `content` 指向普通图片或视频内容 Block;关系改变不删除仍可能被别处引用的内容 Block。 +新版引用边只维护约定的 reference schema,旧 `attachment:*`、`entities:url` 和用户关系保留。媒体引用顺序是 `post.mediaIds`、文章封面、`article.mediaIds` 依次合并去重,首次出现保留。Media 根只保存原生 JSON;写入不创建 `core.image.v1`、`core.video.v1` 或 Media→`content` 关系。较早版本产生的内容块和边保留,须先核对其他引用及来源,再由明确的数据清理处理,正常重放不会删除它们。 -媒体原生 URL、variants 和 alt 留在元数据中。只有 HTTPS 的 `pbs.twimg.com`、`video.twimg.com` 地址能够形成 HTTP Storage 指针;视频和 GIF 选择已捕获的最高 bitrate MP4,HLS 仅保留元数据。没有合适地址时仍保存原生事实,但不制造可读取的内容指针。保存、query、文本投影和索引不展开媒体;用户读取内容 Block 时才由现有 HTTP Storage 应用超时和体积限制。本版本不保存媒体本体,也不创建下载 Job,外部来源失效后仍可读取文字与元数据。 +媒体原生 URL、variants 和 alt 按捕获值保留,Python 不筛选或规范化这些字段。保存、query、文本投影和索引只消费元数据;用户显式展开媒体时,消费端 Twitter Distribution 才从已捕获 URL 经现有 HTTP Storage 读取,并在这个边界校验 HTTPS 精确 CDN hosts、为视频和 GIF 选择最高 bitrate MP4、应用超时与体积限制。HLS 继续保留为元数据。本版本不保存媒体本体,也不创建下载 Job,外部来源失效后仍可读取文字与元数据。 + +此边界通过减少持久块实现。Core 的普通图片和视频 resolver 在 lexical 索引中仍可读取字节并生成 OCR/STT;如果 Twitter 提前建立这类内容块,独立的索引扫描会触发读取,即使 Post/Media 自身的投影不展开媒体。Twitter 原生记录因此不预建内容块,其他来源的通用 lexical/OCR/STT 行为保持原有合同。 Python 的 v2 Post 文本投影包含原帖 ID、链接、已捕获作者、正文和长文;User/Media 投影使用自身已经捕获的文字。label 保持 Block-local,不为投影或标签下载媒体。检索沿用现有独立索引维护流程,不在提交中隐式维护索引。 -数据回归入口为 `pdm run python -m pytest -q tests/extensions/twitter`。真实数据库场景要求显式提供 `INKCRE_TEST_DATABASE_URL`,使用本任务独占、已迁移的可丢弃 PostgreSQL runtime;测试只清理自己创建的精确 IDs,不重置开发库。固定 fixture 同时用于原生 roundtrip 和独立上游合同验证,真实 userscript、HTTP 会话及 Web 消费仍需要各自的纵向验收。 +数据回归入口为 `pdm run python -m pytest -q tests/extensions/twitter`。真实数据库场景要求显式提供 `INKCRE_TEST_DATABASE_URL`,使用本任务独占、已迁移的可丢弃 PostgreSQL runtime;测试只清理自己创建的精确 IDs,不重置开发库。原生 roundtrip 与真实 Twikit Bookmark 回归执行 `LexicalRetrievalManager.maintain` 的实际扫描、投影和检索链,在 HTTP Storage 字节边界断言零调用;同时检查归档不额外创建内容块、blob 或 Job。固定 fixture 同时用于原生 roundtrip 和独立上游合同验证,真实 userscript、HTTP 会话及 Web 消费仍需要各自的纵向验收。 diff --git a/extensions/twitter/reconcile.py b/extensions/twitter/reconcile.py index 9b7a794..328f946 100644 --- a/extensions/twitter/reconcile.py +++ b/extensions/twitter/reconcile.py @@ -6,7 +6,6 @@ from dataclasses import dataclass import json import typing -from urllib.parse import urlsplit import pydantic @@ -105,42 +104,6 @@ def _is_reference(content: str) -> bool: } -def _safe_cdn_url(value: str) -> bool: - if any( - character.isspace() or ord(character) < 32 or ord(character) == 127 - for character in value - ): - return False - try: - url = urlsplit(value) - return ( - url.scheme == "https" - and url.hostname in {"pbs.twimg.com", "video.twimg.com"} - and url.port in {None, 443} - and url.username is None - and url.password is None - and not url.fragment - ) - except ValueError: - return False - - -def media_content_pointer(media: Record) -> tuple[str, str] | None: - """Select captured CDN bytes without fetching or misclassifying HLS playlists.""" - if media.get("type") == "photo": - url = media["url"] - return ("core.image.v1", url) if _safe_cdn_url(url) else None - variants = [ - variant - for variant in media.get("video", {}).get("variants", ()) - if variant["type"] == "video/mp4" and _safe_cdn_url(variant["url"]) - ] - if not variants: - return None - selected = max(variants, key=lambda variant: variant.get("bitrate", -1)) - return "core.video.v1", selected["url"] - - async def _load_roots( identities: Collection[Identity], uow: GraphUnitOfWork, @@ -239,8 +202,6 @@ async def persist_archive( desired: set[tuple[int, int, str]] = set() post_ids: set[int] = set() - media_ids: set[int] = set() - media_pointers: dict[int, tuple[str, str]] = {} for key in incoming: root_id = _block_id(roots[key]) record = records[key] @@ -250,33 +211,9 @@ async def persist_archive( (root_id, _block_id(roots[target]), _reference_content(kind, position)) for kind, target, position in _references(record) ) - elif key[0] == "media": - media_ids.add(root_id) - pointer = media_content_pointer(record) - if pointer is not None: - media_pointers[root_id] = pointer - - contents: dict[tuple[str, str], BlockModel] = {} - for resolver in sorted({pointer[0] for pointer in media_pointers.values()}): - urls = {url for kind, url in media_pointers.values() if kind == resolver} - for block in await uow.blocks.matching_content_many(resolver, urls): - if block.storage == -1: - contents.setdefault((block.resolver, block.content), block) - missing_pointers = sorted(set(media_pointers.values()) - contents.keys()) - created = await uow.blocks.create_many( - BlockForm(resolver=resolver, content=url, storage=-1) - for resolver, url in missing_pointers - ) - contents.update(zip(missing_pointers, created, strict=True)) - desired.update( - (root_id, _block_id(contents[pointer]), "content") - for root_id, pointer in media_pointers.items() - ) stale: list[int] = [] - for relation in await uow.relations.get_outgoing_many(post_ids | media_ids): - managed = (relation.from_ in post_ids and _is_reference(relation.content)) or ( - relation.from_ in media_ids and relation.content == "content" - ) + for relation in await uow.relations.get_outgoing_many(post_ids): + managed = _is_reference(relation.content) if not managed: continue identity = (relation.from_, relation.to_, relation.content) diff --git a/tests/extensions/twitter/integration/test_archive_graph.py b/tests/extensions/twitter/integration/test_archive_graph.py index fd7b221..f07cc41 100644 --- a/tests/extensions/twitter/integration/test_archive_graph.py +++ b/tests/extensions/twitter/integration/test_archive_graph.py @@ -19,6 +19,7 @@ from app.business.info_base.resolver import ResolverManager from app.business.info_base.storage import HTTPStorage +from app.business.lexical_retrieval import LexicalRetrievalManager from app.business.source import SourceManager from app.persistence.info_base.repository import BlockRepository, RelationRepository from app.persistence.info_base.uow import graph_uow @@ -26,6 +27,10 @@ from app.schemas.info_base.relation import RelationModel from app.schemas.info_base.storage import StorageBlobModel from app.schemas.job import JobModel +from app.schemas.lexical_retrieval import ( + BlockLexicalRecordModel, + LexicalMaintenanceOptions, +) from app.schemas.source import SourceModel from extensions.twitter.api import ( TwitterAPI, @@ -83,16 +88,6 @@ def tracked() -> typing.Iterator[set[int]]: yield ids if ids: with TestSession() as db: - content_ids = db.exec( - sqlmodel.select(RelationModel.to_) - .join(BlockModel, sqlmodel.col(BlockModel.id) == RelationModel.from_) - .where( - sqlmodel.col(BlockModel.id).in_(tuple(ids)), - BlockModel.resolver == MEDIA_RESOLVER, - RelationModel.content == "content", - ) - ).all() - ids.update(content_ids) db.connection().execute( sqlalchemy.delete(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tuple(ids))) ) @@ -133,6 +128,9 @@ def test_native_roundtrip_projection_and_media_are_lazy(async_runner, native, tr } ) with TestSession() as db: + block_count = db.exec( + sqlmodel.select(sqlmodel.func.count()).select_from(BlockModel) + ).one() blob_count = db.exec( sqlmodel.select(sqlmodel.func.count()).select_from(StorageBlobModel) ).one() @@ -155,6 +153,26 @@ def test_native_roundtrip_projection_and_media_are_lazy(async_runner, native, tr assert native["posts"][0]["article"]["title"] in text assert native["users"][0]["handle"] in text assert native["posts"][0]["id"] in text + report = async_runner.run( + LexicalRetrievalManager.maintain( + LexicalMaintenanceOptions(max_records=1000, scan_page_size=50) + ) + ) + assert report.failed == 0, report.diagnostics + for query_text in ( + batch.posts[0].id, + native["users"][0]["handle"], + native["posts"][0]["text"]["text"], + ): + matches = async_runner.run(LexicalRetrievalManager.retrieve_local(query_text)).matches + assert post.id in {match.block.id for match in matches} + with TestSession() as db: + indexed = db.exec( + sqlmodel.select(BlockLexicalRecordModel.block).where( + sqlmodel.col(BlockLexicalRecordModel.block).in_(tuple(tracked)) + ) + ).all() + assert set(indexed) == tracked assert fetch.await_count == 0 replay = async_runner.run(_write(batch, tracked)) assert replay.response.summary.unchanged == 3 @@ -185,16 +203,20 @@ def test_native_roundtrip_projection_and_media_are_lazy(async_runner, native, tr == job_count ) media_id = result.block_ids["media"][batch.media[0].id] - relation = db.exec( + relations = db.exec( sqlmodel.select(RelationModel).where( RelationModel.from_ == media_id, RelationModel.content == "content" ) - ).one() - content = db.get(BlockModel, relation.to_) - assert content is not None - assert content.storage == -1 - assert content.resolver == "core.video.v1" - assert content.content.startswith("https://video.twimg.com/") + ).all() + assert relations == [] + stored = db.exec( + sqlmodel.select(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tuple(tracked))) + ).all() + assert len(stored) == len(tracked) + assert all(block.storage is None for block in stored) + assert db.exec( + sqlmodel.select(sqlmodel.func.count()).select_from(BlockModel) + ).one() == block_count + len(tracked) def test_sparse_counter_history_and_managed_edges(async_runner, native, tracked): @@ -416,7 +438,7 @@ def test_batch_lookup_tolerates_non_json_and_reports_locatable_corruption( assert [item.status for item in response.posts] == ["found", "failed"] -def test_media_pointers_use_only_captured_https_cdn_mp4(async_runner, native, tracked): +def test_media_metadata_roundtrips_without_content_blocks(async_runner, native, tracked): base = int(native["media"][0]["id"]) + 1000 records: list[dict[str, typing.Any]] = [ {"id": str(base), "type": "photo", "url": "https://example.org/image.jpg"}, @@ -469,11 +491,7 @@ def test_media_pointers_use_only_captured_https_cdn_mp4(async_runner, native, tr RelationModel.content == "content", ) ).all() - assert len(content) == 1 - pointer = db.get(BlockModel, content[0].to_) - assert ( - pointer is not None and pointer.content == f"https://video.twimg.com/{base}-high.mp4" - ) + assert content == [] def test_representative_batch_keeps_database_round_trips_bounded( @@ -516,7 +534,7 @@ def count_round_trip(_connection, _cursor, statement, _parameters, _context, _ma finally: event.remove(ASYNC_DB_ENGINE.sync_engine, "before_cursor_execute", count_round_trip) assert result.response.summary.accepted == 600 - # Hundreds of per-media queries exceed the client's 15 s budget on remote DBs. + # Keep the synchronous native batch bounded when Core and DB are remote. assert len(calls) < 20, {kind: calls.count(kind) for kind in set(calls)} assert len(result.block_ids["posts"]) == 400 with TestSession() as db: @@ -526,50 +544,7 @@ def count_round_trip(_connection, _cursor, statement, _parameters, _context, _ma RelationModel.content == "content", ) ).all() - assert len(contents) == 200 - assert len({relation.to_ for relation in contents}) == 200 - - -def test_media_pointer_reuse_keeps_storage_identity_and_shared_urls( - async_runner, native, tracked -): - base = int(native["media"][0]["id"]) + 1000 - existing_url = f"https://pbs.twimg.com/existing-{base}.jpg" - new_url = f"https://pbs.twimg.com/new-{base}.jpg" - inline = seed_block(BlockForm(resolver="core.image.v1", content=existing_url)) - existing = seed_block( - BlockForm(resolver="core.image.v1", content=existing_url, storage=-1) - ) - duplicate = seed_block( - BlockForm(resolver="core.image.v1", content=existing_url, storage=-1) - ) - assert inline.id is not None and existing.id is not None and duplicate.id is not None - tracked.update((inline.id, existing.id, duplicate.id)) - records = [ - {"id": str(base + index), "type": "photo", "url": url} - for index, url in enumerate((existing_url, existing_url, new_url, new_url)) - ] - result = async_runner.run( - _write(ArchiveBatch.model_validate({"media": records}), tracked) - ) - with TestSession() as db: - links = db.exec( - sqlmodel.select(RelationModel).where( - sqlmodel.col(RelationModel.from_).in_(tuple(result.block_ids["media"].values())), - RelationModel.content == "content", - ) - ).all() - content_for = {relation.from_: relation.to_ for relation in links} - media_ids = result.block_ids["media"] - assert content_for[media_ids[str(base)]] == existing.id - assert content_for[media_ids[str(base + 1)]] == existing.id - assert content_for[media_ids[str(base + 2)]] == content_for[media_ids[str(base + 3)]] - created = db.exec( - sqlmodel.select(BlockModel).where( - BlockModel.resolver == "core.image.v1", BlockModel.content == new_url - ) - ).all() - assert len(created) == 1 and created[0].storage == -1 + assert contents == [] def test_official_provider_keeps_source_text_and_exact_id_semantics( @@ -602,6 +577,7 @@ def test_twikit_bookmark_first_then_native_enrichment_keeps_one_root( async_runner, native, tracked ): post = native["posts"][0] + media_id = native["media"][0]["id"] author = SimpleNamespace(id=post["authorId"], screen_name="source_handle", name="source") raw = { "rest_id": post["id"], @@ -611,7 +587,18 @@ def test_twikit_bookmark_first_then_native_enrichment_keeps_one_root( "lang": "zh", "conversation_id_str": str(int(post["id"]) - 1), "in_reply_to_status_id_str": str(int(post["id"]) - 2), - "entities": {"media": [], "urls": []}, + "entities": { + "media": [ + { + "id_str": media_id, + "type": "photo", + "media_url_https": native["media"][0]["url"], + "url": "https://t.co/source", + "original_info": {"width": 1920, "height": 1080}, + } + ], + "urls": [], + }, }, "note_tweet": { "note_tweet_results": { @@ -628,6 +615,7 @@ def test_twikit_bookmark_first_then_native_enrichment_keeps_one_root( assert batch.posts[0].replyTo is not None and batch.posts[0].text is not None assert batch.posts[0].conversationId != batch.posts[0].replyTo.postId assert batch.posts[0].text.text == "@mention 完整🌏 & https://t.co/source" + assert [media.id for media in batch.media] == [media_id] source_type = f"{Source.__module__}.{Source.__qualname__}" async_runner.run(SourceManager.sync_source_types_async({source_type: Source})) source = async_runner.run(SourceManager.create(source_type, nickname="Twikit first")) @@ -636,23 +624,40 @@ def test_twikit_bookmark_first_then_native_enrichment_keeps_one_root( return_value=TwitterAPIResult(batch=batch, primary_post_ids=(post["id"],)) ) try: - with mock.patch.object( - TwitterAPI, "new", new_callable=mock.AsyncMock, return_value=provider + with ( + mock.patch.object( + TwitterAPI, "new", new_callable=mock.AsyncMock, return_value=provider + ), + mock.patch.object( + HTTPStorage, + "get_raw_content", + new_callable=mock.AsyncMock, + side_effect=AssertionError("Bookmark indexing downloaded external media"), + ) as fetch, ): async_runner.run( Source(source.id).collect( JobModel(type="core.source.collect.v1", parameters={}), CollectConfig() ) ) - root = _record(post["id"]) + root = _record(post["id"]) + report = async_runner.run( + LexicalRetrievalManager.maintain( + LexicalMaintenanceOptions(max_records=1000, scan_page_size=50) + ) + ) + assert report.failed == 0, report.diagnostics + matches = async_runner.run(LexicalRetrievalManager.retrieve_local(post["id"])).matches + assert root.id in {match.block.id for match in matches} + assert fetch.await_count == 0 finally: # Track native identities as well as the root created inside Source's UoW. async def remember(): async with graph_uow() as uow: rows = await uow.blocks.find_json_field_many( - (POST_RESOLVER, USER_RESOLVER), + (POST_RESOLVER, USER_RESOLVER, MEDIA_RESOLVER), "id", - (post["id"], author.id, raw["legacy"]["in_reply_to_status_id_str"]), + (post["id"], author.id, raw["legacy"]["in_reply_to_status_id_str"], media_id), ) tracked.update(row.id for row in rows) From 5b55f094a43055bf3e375b8c2bd45466c0146b6e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 17:52:46 +0800 Subject: [PATCH 06/15] =?UTF-8?q?test(twitter):=20=E4=BB=85=E6=B8=85?= =?UTF-8?q?=E7=90=86=20HTTP=20=E5=9B=9E=E5=BD=92=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=9A=84=E5=8E=9F=E7=94=9F=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按输入身份和缺失引用占位追踪本次创建的根 - 验证共享作者目标不会被关联清理误删 --- .../test_archive_http_roundtrip.py | 104 +++++++++++++++--- 1 file changed, 90 insertions(+), 14 deletions(-) diff --git a/tests/extensions/twitter/integration/test_archive_http_roundtrip.py b/tests/extensions/twitter/integration/test_archive_http_roundtrip.py index dbcd3a8..ea6cb89 100644 --- a/tests/extensions/twitter/integration/test_archive_http_roundtrip.py +++ b/tests/extensions/twitter/integration/test_archive_http_roundtrip.py @@ -49,20 +49,48 @@ def distinct(item): return distinct(value) -def _all_archive_ids(native: dict) -> tuple[str, ...]: - return tuple( - record["id"] for kind in ("users", "posts", "media") for record in native[kind] - ) +def _archive_owned_ids( + native: dict, + *, + preexisting_ids: dict[str, set[str]] | None = None, +) -> dict[str, tuple[str, ...]]: + """Return submitted identities and only the absent references this batch creates.""" + resolvers = {"users": USER_RESOLVER, "posts": POST_RESOLVER, "media": MEDIA_RESOLVER} + defined = { + kind: {record["id"] for record in native[kind]} for kind in ("users", "posts", "media") + } + referenced = {kind: set() for kind in ("users", "posts", "media")} + for post in native["posts"]: + if "authorId" in post: + referenced["users"].add(post["authorId"]) + article = post.get("article") or {} + media_ids = list(post.get("mediaIds", ())) + if "coverMediaId" in article: + media_ids.append(article["coverMediaId"]) + media_ids.extend(article.get("mediaIds", ())) + referenced["media"].update(media_ids) + for field in ("replyTo", "quote"): + if field in post: + referenced["posts"].add(post[field]["postId"]) + if "repostOf" in post: + referenced["posts"].add(post["repostOf"]) + + existing = preexisting_ids or {} + return { + resolvers[kind]: tuple( + sorted((defined[kind] | referenced[kind]) - existing.get(resolvers[kind], set())) + ) + for kind in ("users", "posts", "media") + } -async def _cleanup_archive(native_ids: tuple[str, ...]) -> None: +async def _cleanup_archive(owned_ids: dict[str, tuple[str, ...]]) -> None: async with graph_uow() as uow: - roots = await uow.blocks.find_json_field_many( - (POST_RESOLVER, USER_RESOLVER, MEDIA_RESOLVER), "id", native_ids - ) + roots = [] + for resolver, native_ids in owned_ids.items(): + if native_ids: + roots.extend(await uow.blocks.find_json_field_many((resolver,), "id", native_ids)) tracked_ids = {block.id for block in roots if block.id is not None} - relations = await uow.relations.get_outgoing_many(tracked_ids) - tracked_ids.update(relation.to_ for relation in relations) if not tracked_ids: return @@ -81,6 +109,15 @@ async def _cleanup_archive(native_ids: tuple[str, ...]) -> None: ).all(): db.delete(block) db.commit() + async with graph_uow() as uow: + remaining = [] + for resolver, native_ids in owned_ids.items(): + if native_ids: + remaining.extend( + await uow.blocks.find_json_field_many((resolver,), "id", native_ids) + ) + if remaining: + raise AssertionError("cleanup left test-owned archive identities in PostgreSQL") def test_real_http_submit_query_preserves_native_wire_payload(monkeypatch): @@ -88,8 +125,21 @@ def test_real_http_submit_query_preserves_native_wire_payload(monkeypatch): native = _distinct_fixture() simple_post_id = str(int(uuid.uuid4().hex[:15], 16) + 10**30) - simple_author_id = str(int(simple_post_id) + 1) - native_ids = (*_all_archive_ids(native), simple_post_id) + shared_author_id = str(int(simple_post_id) + 1) + owned_ids = _archive_owned_ids( + native, + preexisting_ids={USER_RESOLVER: {shared_author_id}}, + ) + owned_ids[POST_RESOLVER] = tuple(sorted((*owned_ids[POST_RESOLVER], simple_post_id))) + with TestSession() as db: + shared_author = BlockModel( + resolver=USER_RESOLVER, + content=json.dumps({"id": shared_author_id}, separators=(",", ":")), + ) + db.add(shared_author) + db.commit() + db.refresh(shared_author) + shared_author_block_id = shared_author.id async def sync_source_types(): return None @@ -101,13 +151,14 @@ async def current_config(): monkeypatch.setattr(PeerManager, "get_current_config_async", current_config) token, _ = _issue_session_token() published = publish_extension(Extension) + shared_author_survived_cleanup = False try: with published.client as client: client.cookies.set(SESSION_COOKIE_NAME, token) headers = {"Origin": "https://x.com", "X-Tweet-DB-Client": "x-monkey"} simple_post = { "id": simple_post_id, - "authorId": simple_author_id, + "authorId": shared_author_id, "createdAt": "2026-10-19T12:00:00Z", "text": {"text": "HTTP to PostgreSQL round trip"}, } @@ -117,6 +168,24 @@ async def current_config(): headers=headers, ) assert simple_submit.status_code == 200, simple_submit.text + with TestSession() as db: + simple_root = next( + block + for block in db.exec( + sqlmodel.select(BlockModel).where(BlockModel.resolver == POST_RESOLVER) + ).all() + if json.loads(block.content).get("id") == simple_post_id + ) + assert simple_root.id is not None + assert ( + db.exec( + sqlmodel.select(RelationModel).where( + sqlmodel.col(RelationModel.from_) == simple_root.id, + sqlmodel.col(RelationModel.to_) == shared_author_block_id, + ) + ).first() + is not None + ) simple_query = client.post( "/twitter/x-monkey/api/v1/archive/query", json={"posts": [simple_post_id]}, @@ -153,4 +222,11 @@ async def current_config(): assert [item["data"] for item in response[kind]] == native[kind] finally: published.unpublish() - asyncio.run(_cleanup_archive(native_ids)) + asyncio.run(_cleanup_archive(owned_ids)) + with TestSession() as db: + shared_author_after_cleanup = db.get(BlockModel, shared_author_block_id) + shared_author_survived_cleanup = shared_author_after_cleanup is not None + if shared_author_after_cleanup is not None: + db.delete(shared_author_after_cleanup) + db.commit() + assert shared_author_survived_cleanup, "cleanup removed a preexisting author identity" From 089be6e76560024fd05f260bd61266e11ced5393 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 22:46:47 +0800 Subject: [PATCH 07/15] =?UTF-8?q?docs(twitter):=20=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=8E=A5=E5=85=A5=E6=8C=87=E5=8D=97=E4=B8=8E?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E6=88=AA=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 说明 x-monkey 连接、续签、同步和按需阅读流程 - 保存真实脚本同步及 Web 阅读截图供扩展文档使用 - 区分媒体元数据保存与媒体本体持久化 --- .../docs/global/images/x-monkey-read.jpg | Bin 0 -> 61669 bytes .../docs/global/images/x-monkey-sync.jpg | Bin 0 -> 174471 bytes extensions/twitter/docs/global/index.md | 71 ++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 extensions/twitter/docs/global/images/x-monkey-read.jpg create mode 100644 extensions/twitter/docs/global/images/x-monkey-sync.jpg create mode 100644 extensions/twitter/docs/global/index.md diff --git a/extensions/twitter/docs/global/images/x-monkey-read.jpg b/extensions/twitter/docs/global/images/x-monkey-read.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9aff8b2989eb7116e6f9f750df0404c119633383 GIT binary patch literal 61669 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 _{p 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 v>-pI{fRYk^ot+qm5 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 literal 0 HcmV?d00001 diff --git a/extensions/twitter/docs/global/images/x-monkey-sync.jpg b/extensions/twitter/docs/global/images/x-monkey-sync.jpg new file mode 100644 index 0000000000000000000000000000000000000000..be3fb7f476fbebffe901ab664c0a6c8d4c959fab GIT binary patch literal 174471 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±BUo z*+A=4;SDrNE=Z00&D1_F?YEOkPgpLaEJ}LWj=faGSlW86ZMpQ^dQygj{^#ploscc+ zg$FR`%IW~rP*@Nuvg&qKC_`JCTPR;WDUR*E(?u(WaeDd1vDoO|<>{D56k9KS=+3OS0pJNcb4F&fdd;wQ@=$QA)Y5Sm;(4 zk{6x5)FE6^lasGfHgOw$P}2y{7jPBRkAL|d+yC5Ktn;8#8}jrhX_bpNcNl#hO^7#| zz`wz5FuYZ*km7A3vF(D4z0<8-4K)B)LFpe98)7pY8eY)9<*wlv(0H&U6Kep=c4j^0 zm&v=9zQ5zgr{O>YdJzkRfAXT-BJN?x6`Cva_N_51SS$|j9@)Xc+vJbA$+`8Y)A!FE z?f$BTN36xI;Kk4+OWGTgnA4x9B|}E>rGbkJ2XGDJ_23->_QNr1H)R5{1G;UQ>ftP& zZN8C&D%l{^3SUBv_9nrR>kpLvvYJtxF?_yxL%$Ig^!QNmFIa9Y0n>{$kVJdm^2gw) z*OhbG{~adpuig(kgo(mW5zbvHDj(M1r-}Nm3x2qpQ{cY`Lo_$|vt^ti){xTuT^|hz z&nBUt*KdSS&+nxw$k8v>nzVHp8rGEe>lK9TN;Pc^A)31eJh`^4=R)4ya{lv>+su z#bkSs(V9IbFdsZg@2W;r)JOZ~ou!|%6)n2!6O*V-aSb8oc=A{&{luusl!+c;@_M>o zE1tnsok_!s{FL-+GhFWDevSN|>Ha6lk4KWEqNjWM@u6Zbg{adUzaH^x-R){F^1h+# znBNaxi*6fPS-5myb&R=mC1fSLCSs#JN1fzMbZS!Hb|@;?@Y9}`5BK9PVR&N>tR&O( znWo^-%y1)KC zS0PCyq83@QAs^662D)k%git7&$vCAe@a~jtn|6jX4|K} zcTe-u5)PD4ai)xXF{u>XT{$2UT!~71dK^IVW~)ND*NC<~Pd#a6aB=bEOqqeeK+&O? zTm{^ms9bv?&iCF)@sP7HVs_w?z;+tqsx0)mRyUuFSYY60MOWr*ZFj<{&tqDFlwqem zuXmHwe6hjz5laFRK>BHZAls3+0nZgLie#C)fc%cTFs-a@rOHz(9}{#1dwPLK})@-RcI%zab??&6d!=~v)nH(X4%V; zh(}*tYj896yF?o5R88vk#b+44U<5^eb?4yNFX zBcxt7LH?!@u)$#;G~aIAgV2cO6CMJUEIW<#CvGvv3SIvKQcgupZ%^aGP1dChkz~E{SwtRc^>Vcds`d&pT?5d!;MDFL8?Xvuw-HlK z=d@d_Y&o!6gcayrtTTaG%ey|6orb$Qz`}}OAccCN@5&P2k%)?phPb$@T<5PPYW+FS zCTc@jQ_L<{{H>O}-9XzIYYPdswi6kgNTX3Pt==Tv?*{~@L-+^OtrVTN2b6WrjdU8Y zG*;F}mugWHHD%apyk#O8*c7hP#Ss~-y-auPZ%$8E!XvQ0TkE-(zSo;Y%QR2&E!m2~ zMI5&9(2?w=OEQ!AdQcZYYeg+MFCtsAe&n(8rN%#M#OMYyRM|a$E+Eop!bUReS3M-HVkUQ!M1z9^kxs6b2muVHi z<^zVBr8!kaKLKk!Ylj1Af!0i4%4Zalsct6fn%#JuO|NL#II6b_`SW&;b~hBEVXX!_~H{#l5bGaFwZgRU~J5y$m1f* zEKk5CB=9&~z+oH`sH)8~;)t0%(q?93*jVDx8 z`4vB{IggCU4T&)@n140TeQ$0IM#hiSKD&jfw?;B1LibZrK43Z;j`y&yv#+Q>Ym3mYsJaXi@v{$YIiNOdMLa|+kT?r zwVdVYU$|lCx;!gn-M3DCy+y6@U3c$M1m5R1ZBORhY?=+a3>Q%`22<}2%vmd9XB79Y zxJWtRRoaVumT+W1e`m)|yg~XCFC)?9^L48vby2h*^Ul3{$44!YuMR=g5!jl<;z5^= zufpLD3u|0Tpx`=!L>)Oc#<%eYrK_@Xv~q4%5n$CO)4#FEZ1wzNJI*WX2fV9)6o_lH zZ*jC@-Wge-bF`Sjp`$NtOMKDxQX)Ff4}G?n_ODJCfB*d%PZtCf4SNCaktNp^-+Ke^ zv^HRZx7mI#n1$;=df6c)u+P?JzY!7&yPll#;AxJis&8kr5R$KdStaNCOAOjyu;!~m zJB6WskZ&Fo)UCe=GJD(< zK;pnR&{j9|@q}{b4dNf_IIcMEa4zLdUcbVZ;i)^|uKxMs;wsr;OTYo4_nZ`?0R-4} z9JoY=YDeE%Kb7Pu=k+%|xY|$?fi2R1V2Be)6Y%F?$nFnO@=q6IF(?pz!Y8qg-FtYi z-xV{o%b@i^=Lm5-_{jL6-P;njW^XpSYi5O$cwFgvzSM+@ci?ePx`ONV{dvU{6209yokOr$fGrgpCo%x5L*k6M0-Ju4eV082!Yw z+nsc>FG%A)YfTmvwN1aq_+*w)#Nc{9?W3){>j&%wlp)4L9+De9cwk7@%yYj=Rz=HV?={5}tJyrTLlpxYGx zN?UDZ>!M@On7g}3ft`Yh`o$3C*6WAcQ}s4%eHx5_jXY8_P+Bl6jPzLja~D|ZerX(s zgSH@K*tvV zYrBNUO8V5JY`m+H6P<*p?ayzjNqmAuY_SiJ44a`0 z`c>sHdq&@=FuP_p-_@44 zg`|EcB@qcks|ts)jFdwF`mLb7gmY!)$n*Rhje7##w)8!#NJQW`XYRVtL6LSz_SR0N z_hbXmq4vUg|9fmrzu2v)YRe_yqaW8(x?Sfhh;3g0PqMbHGDczg315%PnMuYM)J@t~ zdkCC@tMg_{eFPIcvl-?lXFkbBBimIg1Vf#u5P#n-EbJY^x>l~UjQRRonAdYj0%Qcs z+^<_n@&f%@p>h=}!Qrk!kck~u;eHxC-Vz={1-EGZ5X&NY+?7ac>&IfL-Nt^DO@_NF z@tS$8u5<=fo{^B2^s+Zp9Nwg&kO@nC419sxbF~IXRoPL)#Es*X`bAg9gFIn*78#)t zvQ;{~NiRWC(Ffn9wLBC!=erBn%h(u>*y=y!-Z3O?4R6`nl=}LDvT2O27!e_%FPmc` zz=63|XoP~uT@ZX5&Vv6ZQR&2?K!p?X`7&H8u%fD{AVvVzy zHZ}zDE3<_5yn7o`9dgZw;!$;Qy}fW5Zw#(Xu?Ni_T;bqttjk-Q*)dohBw1(Ke0?iF zstT99oC3HZz%gM}0-(J@ zgQk)Rk4@|IZ0sAQS7CW8f$j`&9k0sT6K8Ybk)n1m(Jm$ za3+BwmIc@9yosdJaYw@S?(QL_2NM=lmJ4@=PYE$aUP75|S_gwl)^uz?aD_0abET=( z8-Lkn09c@`xdPsK3E$Rco{m(5*VDB>5pMp(E)p2)o3|um#DOBWh*SAtwa)O2`A%q! z?#kz0ECY%%y=~=L_uF3HC-BY6sWOKIqB0!{h=}vl7Btq%y4#VyN##RwzYq#JlXWT_ zbDKnM>1oR=%fuSB$(%_0xdwxIE#vi2MD*MqB6p~6`LA^fJ{3BM(HR(e^N}0=N~ZWp z;W5Flg5?l_cEsdD)YU$l?ZP|>rQa4lW;MobJu8{UojIqk;E zJg`Yn0i%WphGlI%@qch^QnqJq+gw}bv_7;}WMxnj=FuwYbzlT}nt79+Zt!``a||v9!h{#ELB^*3;#6#8{>CwTD=zZ7Jp65^5ef~Y!8 zlsyH85Wt(6$0G1x6q0oHDzLy~ao3EKYFS|k{p%;Z-l#%M3r0LMBFdcn*r6&1*Eq!i zA7Sdu5TGz080jN5=JbXGxdGLdo{t z;lNZxK|3dLawkZ_X75z%(f6neL3pa;e}w%)bpY=0;1-)*el!G@(cH|!eaAGoJbxf-4k09)J$natgKi1MqIx1z6e4EG0?D{DN}qZ=t4Yu`zQLwzu@HnB2H)} zs~rO+I``%;18;`lcP3Uq`wg^gOtAvojA7i%@_{-WCb3^0Pni<&r-Xt2gM(^)JADO`cG0vU5Jg? z{zecng%dAc{9%x|4-Z)vPQSFBZ&R?xV{ z^FJo>&+!GN|96T1<4>{ZZC$kt+#cw8o8v$48dU}3hy#HIoJU%mefifc6eq)H+kCe( zz3UpTHzJu~e^j}aK7B}rNu|QGo4vp%^tYBST3S*kaECbfS1w%JJrn(5e&wGESe`+X zQ<|$G*o!A-;>L#fd-y%~9F`I-LG$}BOc|;I#YFuGAZ=TKXdq~TrgpzF&2xujcE1h` z482^j`VOXQfBB9bH6oOMru1Cu?CZIa{(+(x90{K3L7*)zX7SY0Zi=Dut?6tZYyipR zO#oue*CL3t zLTxLt-ct{3dY*{ltVY&BbM|KerDLWP1yXzxm*5nQ-K)#?{IEfRp$_fE`Uxh(S-AYw zWtK{6^n{-Bw(e`{8^YfR-l_obVFhj)OXU!w8<0<%m|%G={c<`Cm_Z?^^>Mlc1<7A{ zezb9U4`=*I)b>(5o~AbJ+t#V?Caxc6(9INf&ehcSVb2Zgt?L85CbpxN9s@_jV&h@x zZ-lw+=H?U=q)*x0uAvXV5yJIutrubzX8P2VeYs6uv+5XY5Uf_%S$C4_w+;9AnR$<4 zLuf`D>_kL{%NWbbCw+6p*%s!Hg$x#>Stq&~R|CVp2SFCsPKMa)GE)x*8q@8kNmSVo zgyLe)aP|!=awAzDWPD=lBYEeSuwFD~kx~1t(pxg%OPoC4iaQNfeqCv_smJ%)l)^Ha zqHz6AQ(nB#m+9;n^c@gI&676xvH;dQ`=uQ0zT`sv8Vb#Uxg8rm}Q4 zJn99E=&=u|hG~89!k?v}^6ayjvgoLMX`DgtF{sSP-JIm9-R{Q?j`2w;@Ie|)z0(3>p zDj~?Dk~H%`1gDbUK_s31cd(>)#p=k1imXO>!*ZPI5$M~zdYYRx-VTi8;umuvAMSyT zRKaN~kR@=}PFTxsmN=qUsXyaBajOxJ@1o8b>s3PFJCTdWy*mruCwwR>#CkhE1c(I{ z1zk%v$%ZS`DeULbh)&Ew?+EJL$LomwJdkjeA*-v}PCSz9e=p=?Ep#OO1svWKsWCXI`ShWOXALb3Iwr|!4u zuFs(b@i$SX?hN(d#vG*s*YVGGVN246?+>#;Tn01B&r{>_C99cFzx;h45qmPJ zlR=j%BtmAyx6^vEgh5)&_AU6FOnK_^4B44|c-l#H&UR3E87Vm7G`X*zQ=CUXFYQzGWCkVEL%hb}bss>#B4yK^#1tjUmv? znM&?A=UU}KlhG0!Vac_&FMfsDIo-6wELbv3Zr}Cl6_zb8gygbLVRgfI3NruVn2hH$C*MBc_)lyx`3~;EUr< zJkXlftuLF{wBHE3F>Pczc!e;+oT%t(&UfsWhM* zaId|L2QeDf6|G8&iKj^&mB;*Qiga@xb#K1XQ+0X%>#@1&dtF=ZOhM#b5CdzNbX-hP zFqYZPe5-5g8@4pU(?;(Lb16Y;S2^*^^jq{6>+jZDQ|b2kS|^mTQ}>N}Rcy$(?C+sF zMHCt+m)Y*!CA?CD^lCk%RL`-w&!al52CC?WXxcyCkP%5^b4a424GQ}#l;LV!Q56X$ za-l3$zyZ&$aKJOse*}2`7ggqetG3j=ilph425vHm&wg`39WIO4eW%i?83aGaqEVjT zif#OQ_WobI(l#@98GRJ?FX& zJCBx-VfiYw(Qq}O;>r-U4iJh+O^Eqz8#H1zACGNv-XRb$GfL2$EU}5C~DSk zgy{T7zRkxw8VDZ$Zv+y+)Fzal=3M?af^-#3y?zQNoe!gkzs-l|RN;MaZ}cqT0PP}#FQn7_s&{OysvX{Nte8&DhwS_3xFJYjd;E6s`DW5+`r#8={B;Q(l% zbsjWa{?`2c>BBwB|Bm+Od*Xku+y6&*Nb4pNY_qE#IizZJ)2mLS3Cx#D$~q7`kWx{3 zG(r*MveHLT7L-$PVQ}+tHhJvrJrF>zWW>5xml^X-jcK9yr_YMY(?}jXfd2_UWp6ga zZBZh-Ag$tc_HeUiXE;QJ00?%^$%o`AF8MK=?urV@`LnCdZO?BdRFbZ$;%1OmnS2O| z^7F`SSYaKQfHvA~(a^2ay$!r&WpN=kUW>uJtwgT)_Ta`uCac7DoYwWnaovCxt8lB_ zU3jSS>Nmp9Nztw5-4il9@G=?zlh1#A`v?mm>AD-J`i($8drznjn`nYP4Z)C9ej^Ob z{P7_#&HJgn`HK{e$mwJS-c!e)m0dny8o^gX+y#x7AI1Rw7fbv2r++i^l;ZyzK{9&t zaR9 zbw_`k{(|bC=lG|E@4e{y!{$lzr^%Q6%LHMgoxc&j4qQHg;faRfUw(m2f4ne%+Wj>D zb&7w!XbHsdukb&7=>NfoGjsKP^AA_%k6XXSzfADY7fnp!-~Lb&j#mn#{m9F&n)ev{ zok&-g@15l~BOE8=6G;AiuRhn8cPy3O&%i!eEd_rEby;Ul|PXGO6~g>jzQm}An+S{2o95Hg;KR=DT3K!?W=BVU9?n$ zW##7&<*77R5T^V4T1V*eY^?dKvy@%W(3!7_U^Fit*`LbJ;0_xByjR!p;*UZ@V-LHJ z(gEUIf>ni`1h5U7+RZ`cVYfUiuf>%%}ea}5RlT%N8oYDE~B<-RN~oVP!=8K(_~ zAD>6PE_wa8@c0jm?+^bl+9|1){J&t#zmakgx5XnaNCthT1qEAOgN|XoOW3nGUxKE7 ziFP)uKo!Kn`nJN}=B)uZl5<5Zl_b9Sq=9n&&M+0Y)_AjIM&+@%vB&g9wWP?EYg5x~ z)|=5!xvGBG@fC86FVKf#9(EhJL==LelC)_LL#E#2To;@3m>AF>NC)#XTb?e7xUO^w zi;2RfQd=)7_@n%&Ypmf$avP3W#7pXCNemKQ^mxOGUJN;X%}*?;FPK~!xB94>Z>sRX zyb-M=e22yNX{QtRO7Dg81sMX~mO;{F78qw8xB0Xi5A7DYOQf6!HLggh$BSt6&UntH zKg@5l`jY%q(|<0q*FrPfISz>h?Mi?rihjDMk87viKZ>47uz;HCJOP@5C@Y=ZV8)^d z7{&TiUcX20)}DXJhgiwREJukgP@GVTsQ(b}COSAZX3(j1y2kdqE%jUQi|}9(PPCSJ zcSSXH+Tjo0jPd4b*Da!i+Glxv0K{V&jP{qt+bWpOHW|4_*9PKT!y9c`MPCs-s;2Vb zq@vAURa6_H#kSqyf<~}b;Tt?7S^TF&)^uVwS-=g84(81ig4^L`2g%M(d9FBff7$JO=3Tte9Pdk8XR2Q)qb;!^%yC@)u!gghrGYUl z=H8tL6qz>8_9T&Tyj2$4mOa-aWQFq{rXT>HthbH!6v3dWLjZ^}fENTjPXjli)(^vT zvsy=F?DV=f-e3Jn2iiFG!#-x$7|!CTe7&*R3S}}<MG#He{(crelBbMhEp#7l~lxdb}W>xgdSn1 z>wPD#)e1XYk^0PP19{d02RY5IrhX04ncKV|JtpraPPYx@oE){&vzyC=U8cm>T-q~m zm1lkIIc$akB&(8XY7hOk-9zg(;n!ljt3vax-gZLiLVz~Ld@r&0ZP3YklvAS@V4B-4 z5PQ(M8EhnVJt`!t?59X`*A`mBg+I#J&ABwUMq$eX3R^LF4KF+H2KigXQL2#p#9)gE z-g3F>VaBp|fEtZ}{o1EEoF*e7py(!n_*P*j#{xsrQcs=1`|0=aGB0;ghtki&XFIR?|?2@ z!*POCBfY4SBWu_mH}IAR0M_QQDsVf;WBtq>11-QjJMd`BZ&cpk)1m0YB63NQYQDa# z1-RPUl_BXy5!EE$6q`(rGeKearsRvUD1-1(z`Y+CIIVXxEDwWU#bZ`9G<#{u^D5y` zt?iRpt{UB;TN*wkt;k;S6dC1Z4={KMfX@SuQ=({aUlN?aI9vVU)pfX2Vh^)DrV13D z9?=T0wSps^*RP7>Rb@2uYpg?z;xy<-;|TTGI2}qlhtxFov>~IE2hB&}fVEaY zxL+)Nzvbf_jJHp@9_*jGov)PdzQNCNSo!ly3`dg`Xg|;VgNk9Jz@HD9>qq2iw|?w& z^>7L8OEU3+Ce-`m>taWrCt`^A*1C9aD^)`qjo7j2bHyx*^xfm`TX-S!5WXM!nSt~R zd_JoUxJ4h(O(u>II7!Mgl)V|ke1_yv+14+P3!{c|f$EzwbK7iEGtFx>KMIX;M||9; z#MfQM^ldPc4!DO%p?CBD#tM%X9 zljK4ucawnV*HuB8`%hCts_2S^qh_*;_DO5@=slD{ubk(Jn3AD_6Yjo@BPAciy~zk) zJ7yi{wp$b!9Gb>BtVoL7P}R(ql_g)*ZdIbtj76nHrIJxN=lrBeIA}IHl+tFpYI4f) z5;&cza_}!*_+rQQ?NGd33Mf=sO4vk;Aga24eyPQxE?&R-WfG|*IhQ+630{U%go};C z;9}$5^uMRA|I^q1Mr@44`ec)xbFvH6D`N6N7-$V6-p^O}@2(73d7a$fswSf;=GGpF z0&N>JWF1mE{0cwfCM&_KUsrHDCy{y4z+bKZRoP5}=J{`g9wqU;&?CStpDz4wu$}_w zw9hx8g!wQ9qfoez$2<>~u(`Cu=z9+YsU85}kMy1|JE$MN%5!nSzoqj3_9IpbLD|AV z_NwY#N%@Ts;r43rh6Ie9RiI0kmHIl3PcL>p%cQBbVWWSAodu#fQY8gR0flsvYlram zS#Hly^V;gTwPoqAhP5|E(zVYM&*Xewx~pmT6RmeCfZBr=J3x6KKy%g!*5ujMy2p9} z$%VS3t>z6Wjo)4sT#F7Y+>Bo-vdk&)**%jJ970S|E17a{n7M_BXzA2mnyYi(W+yF< z-Yu@I0&^BqwHAD}QXU1Nl=Yy_s#4p`gN$PRSm8eP9(VhyXq2!EuLI><0%db&yr$fJ z^uBb1YAM7wC}IH6^(_!|+X2SWox8jd)~}$DTP**z`HCX7Sw8+_{X82Tm(gJ3o@hy( zW)^O-1#53mNQpc6x_gD8b+XN73C6Q{V=-6QR6ozN%oB_8&Ys!1MxkP-wKY}8UKuyx zB9a0z;4+Ew$ilXlYC#v&pkik3s zEoEBj7wp}ecfu6^4|`u3)mFQ18>CPiiWe$>u-2Q`*7H7dK6A#p+5OUPJvDav zaF*aAKCy=Lm6C+vu8?%qV62ZW+^d~-(%F3l&s2I{g-Sl`)g&jE31F2z$R1!^&)lRG zVeUGa{y7)S5GzW3RjF_)cPP3#79|0Qh!%cWo2rWuF5_S#wyi2PmMBuGZso9P(jp9T zcq|i_U&IvG^MhGe$+XzpXCnrZE!85`H@hrSw^(O1Q#<*lqQc(~9*0VRgUC6T3SH5z zyM5a0&EFzr8AMfs!JvmWP+Ot9HDE-`XO!aW+{aC7+s==ODe+G3{!3_Et_bq6*LF8KG#vVciuB#cl7BO8**a(egEfNL(gnX^7m*dSKSS-LY-BdeA6ac&|KdtpXmQ}xQk4Pg7i`a*hrya zll~@Ct-^&%BOA6FhTbu1Gqh~zUKX>46HYJg7M$W=@v>#oH+Aes>=>YJ97jTFBv|&_ zc}}M6;O}QRo}NBUE3o%8FXQ6FD12~{m`b!W@BZURAXZ~W7SjP&D-e8N63AEh;n1B9 z2jO7+g9`#Hwzf^np(bC4ODA_vsy1Ac{aBIWGitkY9S(UKd_z=vWR06};2JoBk;+Ezd=?z3i$}Gu7%#lWmadhAbTT+9H#c>?-5(Ws`N>NGc#BZQifz6CKavZ-`LkCpG8?uXC%~Bj+Y`h$q?yp?09Kb|6LMws)!z{wC3c=ON@fG3VbXry zV}yd$Ek=#=d1i6M(r2|jRL694vppspfG?&qeBbdk z1b^C^utuIBLGK_TcG}pzLSt;(yns*@f2wvEhcOr+F=cGe_9EHttXq6~?ffuFD4(O| zoaFTAm2=zf>@rcZ%E<#*XgkG-&FU`0#U=En;~<8Ym`O}0y`T^>GUp58p{nNPWC&eWWtybgnj2Y2R<)Eq837>%uYzK2o`yRu4DJ5?AUO zPBRYQUeM1*BsMoZC}jRyHlXEl-6+9;@ZpGgnB+RSvNPC&!i4X7W>|PC=@!1lK?KM# ze-r1{nyhc*oizAyWYJm$4P`GiOxF{8EyWud|H{0Zy%?RS69XE)hl~WTCeqPWw(76O zd94aYRT%hHJ`A6#(@Bg0Ul|V_W)LN3H=Ng`To6B?W+e@`Wp`sVaN8N1Q)K=6eI&{e z3ZLh`CjWz{)p+M8gIxa&dX|xi4%GS6|2_U9MMQmRbu0_UIIaiNEadJR;nqCtI|zpf zGotMt8W;9tdM3j}7URTP9t=S^B&I1~%5KLqt$P6?(~DS)`T}Qyk9rcvDn#R_VH$qJ zyWiS+p6>}1KY1;}20N)@nM$ya<1czyD?`&eN=09$ED1gq77apNPYA_J`QZ0ooVYj?B3j%)Ns53ea{FSnZ8 zLLQ`JYZ|M69UJ403VK+thLJYyFJLwG!aV8J^_r~o_#OefZjQg~+4XIVZZ-D$x+5Yj zJ~C1)Fz>F;P?k6P@jJ>V3^30C)2V6h?8o#m)rfZG)7W{n?#d4$x>5emUMtO*SwUZA zzucYWs*sX3llriTN`OKoF10_WL^!``2LE~e8#Hfm+Xwi{JFgkjo~x%kZP!uy8n2zt zL(fD!6$|NuO6c4aIu5%UCL?B>0)Lp&aw7%znk^UY;_`Y64C{hy?EGi>hC&ZiK`;=P zsM~V;ySSXEC{U5Tu7RRu!<+zLa@o;5+MU*EBtG;g2by9bPoOc;xbDJ0YCc=pRL1a4 zID>45U`te2=}4B$2-;%hvYCzo6MhP$*E>yV{jL9C`U+$mzfW53wXCk6JbJ4MzHqn* zbKw@*=6v$z%_AG%GABS#`0--mUFpKqFI{3U5B{D$1%>TkOc`3pb{l`iYW<_V zE5p)nsT;?h%mWgIXeYY}-JP+6$Xf}KGBD|#`CH1U(KtKaz z9CY=;agX2Z>9_ArLu*UCl3(>9Cl7%Ii7lGc$1;|6npjsr$PW4lSMSQnzhv?whlgv;Q5c05goa6wDj$>tc zFNYf;{B2GRo7Y=cBco&F@!i0KvzwVimg?kD^(Cy1ebcHH!Y>n-4oc-z+VP^LN<}7E zYc3y8o9M$XG51ZA1L>zc66S9EmRU}Yuimws%tpK4#`sx(R)TS0a-%G|-46NGl(!_I zh>^Y4b93wL`jw`(l*V(`cRNR^qPXt)%3|q9o&YevIdkLkqb+exj;VE){S+WaWLB8*3XEZs z7ePHapp&r}vj_sB-I-sXo;68}SWnR%33Y!9nqz3t%ambX@;YMcHsmk9!TXAs>_yUr zl-J=oYfOHz9QJI~?15YlHn1zIQ6u^;!%Q6n8#%L%b6)d>9gH<46fv}4%>bbzH)zlG zv%AugwBj4#vuoxlMv0KFe(-Uvz9+aEFRvdrht-@CdH4|kdh)8kk0A{S8xYNjkmGPW%vy)aLCv9|&Dmp7Kz)3aqg9yNBs|64g>?ZaVARgD`$YXl=wlP+ z7gwGvYTx5-(>b&?wFoE1mT9x5*`rUP63c_Y4IZk4R^&L?kEm?R57wgx=QLT@;>X(#IW{Zm_$X%%QS~WJl@laefBL|Gn#qegdg#>yZxpr4O1Fs$ zJxrx05dD@G0Ow0D*M%DO|6xuo18^5?{=??f|2gi$L&IM)RuyM_OhymZvd+hHX+})G zTO{(Bwv7jHUey?y4tu_ zyIgt){x(GKxD2yq^|F*cy-BGgOPS4{1Vf+*^D%%?@V@e{WEXPxS?=!n_8%OEm%2!8 z_c5e$v9r9fCmcH;72?Dg}DY1wNs5Mlv)YCyVETO!>B<>x95=cz#v5LWh(@ z1_Cmrkzjl9uSd|c(r-a$)^sP`5P-sfWqP9xP#AcjxoU^(K}SYGt70p6GIaAgzd?&l z05I$WMMQ(^_B>5zhr$2$&$O}n^i7)cS~f?OL(G26f?;`e zsNlo(`V3tgMp3Ex?|a?lf%|`2QGnb0KdvZEpKMb=%T ze}L5a&$s;ZN>4~9{ioZ|%{Q)GU;;r~`(e-xL;3%$voy|83$Mzr?@^9}j$2HT@_-!p zEi(8Tdc6ctFPOBUf4Lb*m}&iE0|7PiKmYQ^KmWRa=q|ZoY{)+}FyYYf_!rtTK-%zx z-X#J%P94zX?*IWM_&n&8BZ9P!? zhZf5Hxx-3>{%Mc=bKCtp{%KIz@bgL(1*8hph;z^u6+m$TV`&_5hzEBi+ke??n!>pl zoL3D?Z6P*ZyUhv|Ee)d9=^6jv5~OjThj{{fr&gHAdasD08Y*m+wXE8LWgPWBRNE~{ zRcJtHx1%E${m%}Z#r1RuAlcAR1sIU^7TJaTg>m`6VaHjy>5RlqtXy&cH^@c1??-Ub zh8OP4`vZ9kTxUzXHME6*A-dwqy(DNl9fF-8wBkwJtBpKl$;rG zRrB`kAm^vVo$i^2yi>~QpLZjbt#^hV9{E9?qGBk8c5Vs|j+Oo|XO29CzFHJ}ahA7g zbWH>YI!2d(#H^+IB>7LQ0}~O%Yug`y=?(rFql^eT$)NB^AionjP5n}AFM>M!AxPqL zaVJIm5xS@PPR$nY_fai4LfT#FORVbTYC@MY(H%+kNkL(^BaOe*t(3pw_}Wa}(0&o@ zN-kJTu!uc;$|i9-k9?x1Vj(MVEzmeTN+|*BJ~xmX!Pjkq3b}fN5H4!`a8Wpe-vU>o zS`NJ0@1>KY(Yg$5ciKN?6#G1&uG zeciqt=F$tl{NdjziiEmxU>g()xT6wh`y5gNC$J6$*QaUMgqE=;_MGzTEKIM$=5M_PCN-u>tqn_|wlVtZaD z#I>gh?^`vTtxFML<%d_i{WFcOo%W;t8=o9y%_=~#?_7pZB`ZF>xUlWzeCwxESQwUs zX@KayiarUf7i;$Va4|&Q-cTL?sx|&eTNjxomTJLsLnbTy%Y8bOz~>I8j~tKP)ren) z{st+r1nHm zgFmHb<5gl)tbW{Ub=)#ZaFL!?l$#uKbgk0-)%X+HP-z zb6-)omZXqO;_Gc~JM{PNF~SKr776reTcCBR5Gsjk^L zgu#i|q_de% zrkF7YqX@-Eh3_0johq1GzbCD~zFq(y#dn`jYkfD)?4>4MCpwBYX0s<;0v(mA5$>+jUSSYHm-(Q0Jf zMwNlpz9}B=lL27cu%`Wtj}|7&nX`|$i3pl7orB8#Ob@P4So9IwaPNyK9i;%W{`~p! zUllqOZr3D`PeV^1%1FFl4gR^>{@9=Yl)R^sWEY8Zl3ks;w;Rj^P%uJCZ_oOdQ0ez? zJUYPRa$N-PkyHrqs-GH`x2ITC#C}XFU0ZXL`S~zx5pIhl1$heh)f;lD^IZgBN}i;? z&!PTtC{vO7t$Sm>1JV^GH0C<&s&~n>G0SGTjx$MYVEE-c-=aE~ntlv@wmFWLT;ZYd z=qh~T`>XM{b+@oIY%6Lc%P-xBbP{j&u5}!Q^Xyo7KM6ant^z9Lx_x-yPFj)g&Jowm zb!5%)nQkPb$k$4bzEju^hseu5dnYMO$vEHg8x%)J2uB#=P41Ra^~?|TH@oZMI<7NC zHko;^o&;Z-=SVWNx~-~pFT`oyU*4P5bwY-Hn3w_kzKv>@$y$_f)N&_9-5Y~-kTdO9={VeLsABXnDAuXHkJaGESL zIP=Y?1ZlfTT}>m~X|ms6bt&n(Wici-fe+dpM6^Oq`NPoPRZy;*8q9#yHX)@#$ zwRsSQ9ugZMS1wM(pI=>SOmNVT>KAfn{`vezFK=*(#)WN+Mh_-S1f*$AdyAsqLvf|FCe*Y%m`?qgiEvdnz@Pmve&c| ztlpVm(mix$txwWkdaSB`^LD6DO8|?eH_$V%gRXA2hhUz;F>ql$bPiUDFw~DHiyvKF zKo8xT*m%}V=A5pCo||>6yUPreE(hHwUkJBT0?AK#37$K_@W-N@I#dZnUAY$G$u&l@ zeybPq*ea#W$T?=!YVDL z>k2`qrhQndLw-GPykQXW*^R$o3a_1Q)iYXeVcJ;UT4Htf0(@%=bQ#ZE308aG$X>PJ zrwa5&N#{~5ON~u#L+lo86h}`577b6-*2%k|QGj0&f7%~8<^;Sw z6B_7C$#~?_x<1dHhA-)93JClN@hsaBc54K$kYmci<6 zM8P|OSM6m7C88PSL@ekiENDp%ztVw_?JaWH3t&bAxA-h3vwo{qgMh*~@xKJ%BrNOy z>?=I*9P88WYEfXg)JZm{R6Hg4*@DE1F+YL6o2C)nmo!{6&(Uz-8Yem>x3ul~mi zvja*@O+d9DHgnjCnZ3fwSn2G0w!b88X7)0cW^PmtI@6L6~qKnBL1W1HU3ruy?tv`p-)`AG`2O-8l92w;pLd=`EptR7@LhX z3Z>6YZ5T4(SzR?lpDcwXH9QePw>_U*8#|S|R(tJhx!+&;^c3&C;Eyo}H|#Nz(i-_i zYhyL_xk{VqPkph)2}6`4K9|!)KS4$tXAeikPn8Kqk11_43cJTh@#G|v{7B`_%b7lm zBF^VbD^-Q}SNJakyI1C^WKLBwykD`WA@=u}Cqc2T;_&h(9xC{|Z*W4MSw4CNM_45ku~hH)y{UE2uitsK6tQf# zcGK&|yUXH3?_Q~zgmx!g)Zf_;RvgKg^NsG)sG1bwZ;*LS9Yv{U7D0(ln;Z6Xb_AH0 zs4);hy^}{Au2A0HVM$q*R`e{kBlRp_%Yp!FH+x0G63z(olL!Yr zr_IwhIbV6m=hhmVuy2RXnckyHc67Ynzo?*ECpZ{+X9>|idBSU!>L?+7etV;KC;h{4 z3+C!@D70xlusu#L4;e43lxK4tI>}k`)miHT-*T1ard;EEwah$f0`<4$mW?<84ikr~D zq$}}sD#cKk$1r(Q)D?mTNt?{;DgnVjXPIe>ABHlW60 zZ#cH~2q?@v=(-f=0bHsopCB^fhd66#ksg3wRQ7L>$JDN3%tT@eY73?vSf^}7+%f0Z zO6MCW4x^VzTuiWBr=PjqiN3D2?h~AjW6zo{KE>=wAYJm_@8g&--s6qX8^b{dA&;0$ z21|q6P6sxU0^ujr4Za?qbjHK=rnSs)Edhpbo?W$Meo7}$3ZFa+K~`6sD??L%hV`pI z$Alk4wlz3aeAG%V7Gj6r)8hM3j1m}Jh`Mdbwxkoomi7+^c1`&aK|SnkI9XO+8?#Nj zkKelmFI2gT0M<6BHu_9OFIhX5?pTC3d};kOU%V0^r|8ENhH2z{`Wb73529q+rgU~b zW6i`Z>1a4`>Zit~Qw2Xm*X0;A&B0edL#K9IAw>ak$@k$)?a7rJ*r8}pzo$-)rM|tT z)>hfijs+8UFeN9HKVahX+&l*XL5)Ewx!H!NlK`aPm8`}vq=WaluT7yu7bs8z1W3*b|A{)gg7!{A?Pj6HnO5KeY z0Q42^`E)LPjO?%I?>Z~YufhJ>-RrN$)@*j}0gOZLe+c8i_A=dr-Mc3MiF{Jr>^+A+ z2Q?QRgH5|8J~nG~*ixs@y8EhZo81GSG%0YrnNFHvh9*r(Gd`r<;LiAgAtQ?8I2acH zz?bI#-8MeHSIml34Fqm5~7~+^Uc%Ko!KIVD)wWQ!9 zlB;kzLzmcQ++BD0F+mSE-JXbC4D-a6vFsV_RUNb{K_i+Dkao)pzDG# zlJi_^ljvr6qxI6V&DN_u+Rb6FtEqv=wKhhxzFpd!BONKzMX^dbVM_ELCexCE2m5|z zy=eYd>geAn4F4VPtbeVgf$|rbu)na6D_b&@o#5XmpX6Vla+Do4g*tm-s}ToibhF*b z*l9@8vIA+cxm%Hd(!<}NlTtC#tE{7Q=o;6?S*ZfE_ghKh=|SYT{Aycl!un zZ4^iPk+FbsTCZSGlHiZ1kKRGIGlrqf0cD?ssBeiA-cFI3Eki%EsRwFpT!_ZG&GKw2 z&|sQ?Q{J}V>nrpHxJ;A`!iP8z6k_MF7bpWpdN*YhJRFN*la16fD$6O0dQ54`Q}m8ss?Yi{wao%`r5CBA?{!oAg}~gHS;QG)a zxY-~D+DELlNL-0l-4274v{>Y0?+;EKujT^|YaLxtcn0W9pf2OvEHrXv4y3B0;yv|X zz&WBYUPkXj!$&-y`m|m;oPp8>ZztrcfjzPQf>Gef+xM-zEkn92RBiF>KP-hiHSw&4 zgi?sh#y{Ky41kL(ZN>*gRlFO*`(K;Dz6cLaEo#bD?TR*3utRGoq%JBYer&F(vX392 zcuz+;i5 zUqYq>&Evl#P3lQB;_^{la=Zfdi+gP1nuMr5i>x0Dco7@+7tpzeT!Iy^f^ZPQDX#)s zX$fAkcdG~MZtc#}cwbi3%C$ZpE+u3UuMwg19oCVkoN%^EeV$D79Tb~`Nx8>oQ2s={ z*Eje%%4*o5iQQVujJqbq$GoE)AhEe?*ETC8hU@Zk{C%R*n4P3f?2~Pj1eV=kxwQLN$O$rl46&|os|z{7U-W7gL>WOD#Rv)wT1W)@nMef*=ve~EK?F`9 zaJlT0hyKH-SvEA0(5Gb|Br)I3tEsoeiMl4aq`+8KPQ@QQ=N)A7i%C-0`H2?R2S(AU z3yZTjLHbR;<{fJ#H*iqhH9hv8};Kr1X;X{H$bVAwclh4xY%k;sS8 z|0NB0{AvvTXU%5*_Wl0`k6&@jyVwlfd#|+LAcd`p2tfCsDyV;;AZXSn=px777<4gs zx?B#KZI@B@k0)sdyKbvX zx@t4-5JJNj&F+&*cy$?;0ZBYOH=*Gzh7r$j(D~G!8_#vpTkc`Ao%6Wnmf>-Cr;4an zm+k9}v8z?6J?QQFt{FWpnYzb_A_dAf%SzoAZ=$-;iv6#oS^mBM$twbM;v4VlBY%Tr zIi+9Q|5mzd;bi(`)>f!Jg-nxzxTvaeg*^ zO}J3v`aA#c&3%3Wy_K}jvwNOhBA*@`O(H%69yqdE(G0kwE=O|b{2`v&G3FnHV3-t3 zi|2-uNl_Y-)LGkf2|^XF9{Ln0p&)eH-=Np90;xA{N1U*JknrVLkh0bzt5x_DRkzsf z_-9*v`1#w^gmP+qnPQYT39?MvOz!)Vlw-rzmK2u&G{9?L>r*CH^bv9mjoH=d&=%YL z&Js^hFWWhZUP|}eKVRUv)P6Lm%s^N4GtcafcqD3Roy_PJJPTf_W>C$$`N^pdwDV4M z(wqK~>(Xf_ZR;7^H%#VAgqP~{!SV!>qcp#EhL4&R7Vpbw{> z)gL_fYue9SP)W!!b%3u;^cHvKA(#0>!Q@!@#a3PH!4c*bG^Cvwk+*i04;1>B^_Tr@ znmlsb7-e8JTdBVCR9+GQ!!hnXQ@60K}=6NT7Hx~6% z5|Z?<6%@IweZHK~M6%hRjG|`NiCCi~L`}HvjEoaipT1kH zck!t#6d(991Q%bB?l)s~l5YMol6*$=6D6U*Hz)Kn!}pMuy#&1rFO5|HZv4EC`) z@2kTRF9$Kt)C3pyGP}oVZ`|s&wI1o^1mn6oyo&w24J8kJY753`1>98bJ)mB1L(HQ< zCVn6AA2DH4axBS%pKT_fB@6~&)vr)`1IO2Oxi}`f`fTIjDC#86T(Bz>dQwqo9qU2f zRK^~VZIo(4Y@f2tkLqT2wm<&ZU6>2tn5&Oy;o^GWp+=@PR6td%wxD3XB(aZgOw1jr zou~M0NyQ?*R#lSlOm#WS?DB5EEb^Vok^p3UMgT&^g&iLTig$rjny?4*Ajg!$Alv!? z1&Z3t?A{wDt@iS{e#jB8IG}H6P}bugyiHA=!z*W>B71FtWfY&?-rio<@mUo+NwRa{ z$n7V*VjQjVwQ6@N{1064;!S@m9wK zVnfmiU0o*8mfKcTYl(ZX`p1On*zN)x23rB&RA&F@Xw;Tj(N~$w0b#Cp?Q=#1`9Q@wTXL}#&A$(CLIe)8L`4tT^#Cg*U(aF##@Mn5XXD|m4707s7AH!&PXAt^wg|*gRap&l+ zVeR9w;M2XMR>o7D?sncJ2V()9_^Y27sWavcl7$rJb7?ub8(wIP!hLm22cKQ@gi7nY zUc3*9&9aF$xZ}Goth;$pNx7fHaw-`8ZHmKDOg7E4A&}&BsfSmN(MVn_f3o5d=lho_ z>6(%OboB*8n!zWmf_O>1Hb>T^H{?%)`N8kOxG(4^92zou+e_h@15WP^;P%&KWy@sK z%<)tUZHl+rwdCqUj|Ij9duVjjm@qslA??%yYrFnRUJky=hWoz=dI)g3zoBsj|A=8+ zcjTQreVSc2s1CWmbg;SJ$$^c0IZI)5T#kwbxGLHPchDpIW~ZtE$h zG#B%&Uc4j4L(b@$Oz)V;!t`>s)#E``SlDBH5dID8CzM)%-ddh*Wr~;vTt&IPv`mdD zi8EngOHa1kr2vN4mVKAuZil^Krp6~GtFOG!kgC@Kid9;7CfO%T_0lEx!YiU->}nU) z;biQl*|*cxA?jv=*w{uTo3S2JzN8+gwS93TGaR5I<82dl7X-(_;vZ3yOHg{O8d9pa zb0+0kllgslXZ|={zUCRdhC8aPMhx6@?IZpYG@)MDY_h=Hv9tB`&6nJMP$1_aQ>&KC zc~BX{lER*_5Lzrd{&ty$sd!<+opuo-aPsxk#Gp91x0&W1nciTb)m$^@comguM>%KB zNi-zFVaS9c`F7)J41A1i=P_-14ojK>dy%(=ia{TouVi81O+%4ov;W}av7r2`#IIy# zT2B>|ZuE?PkwxEJD*Mbv__-VxN|*Kctd+CPFl7TeLQLE7N%}WxkZ{ocCXdP zNYN^G>~*RHG6Zo~8bPuB?s^choUmfiCG<=4T5 zX5TLkN!O8lC-?O$(HAP*3hgtTmnyQm+)g=omg@3mlXdHHd;y=G9zc&Xhr(!02r|Y7 z&}Yj`?RkERe^nqk%Nz=E`u5O7J$7)U`DN~n%i25Rs@#u!{rP+^qu0g!Xw#cnr}&p- z&$^YXlE=Qs_t}|c96$R319?-vVb1rMPpq<|es+fSdTZV>GODt|pJo+GZfY;EFOXci z=f#e9QMJ^XdOlxJMroPEkixzjTW?Xhhe1hCKcbcZx zZO*rXRgXYXgh}WN#0sCVao@x|7_<4>+PD^NIzO=9#`_zj**2{b-+lrUzi5Bk@_2Am z69e@t*Y%r+(M8`ywXiRrQFQ$YOZgk7@^8IAppiq0Ll4Wqzj*8)XZ{&?3;xT(s=qK# z_!`anhtSI3UjHHg#}P=()s|`0uUnMgAnpLSov*DCM?|zogL2}(JnkKqB-p=5fM=hJ zEI~Ge@4yD9nTV6}MkZm3C721Iv9N#7{;wF%s`Db-o6dPzp&iLG(-deczS^=FiZRUw z`_s#~hss0|pe$b(*rpyYYN0Utb_mQqba*?iwa@7}Pu=C)K=;bW0KjCn=*pv`maYox zXY2DlMYtD@EnARWZ;KHG`GSVMJ(H_&KN1+35h%W2pajnnb~g11l{3&(m0x?~swrQM z`F?m!!wZfCSrMpajbpqvgby#oQfIlZx|cXO;h6h8sgAv@2$k=f7MPNqGiJ3R9|O=sS@dUyER|^)2p3j$iSm+$lB=RAYP%FY`6xhI`@%bCUwv_BS0I z(R#p-@6>$CU^oUlWX_w0gR3ai^!L4VFus5-adNR75Lne;SCIT<2f!ukY-}sVUh^2H zsaEX*&>a5|_i6N8LzE8wj19w zc(CDx!+?5QQ`jEQuE}n$8R@8SW%onNS$Cx$oRIl4=OIDqD!dr2 z?#uP!fZ5)u_|x6K44nLkIAlVooP71nm^mC4`BoA9KIzHzdhq4hm|Qa2J1X5!y*m{T zK;p%8{Y5cLG(U~v70!##EDdg~57s&PN(@Kkz$k4JoQH*{!{^4MsiNsiV%`a{m zF!2bN9?VlQ1iCgthWBh92S?-VWiG~1j|bDQoWgovmGI_Z5=(lEnzBRClW9o`I9<5= zjLsQHx|ysms>oKg(^jOEGa(N?@&8&VVujydeyN_ zt&ZunR!0a*a=ZyNy2OOoUuFuZ7%>)ELE~Zc3`P_3MbWczg_>i|YcM@6r9d zqQN5Z8^n^sEm7t<7>!LFn$i(0&pNK?;{nA)U6P|=$5_EZw;g=Yb@4K(`FF|?%WDD) z(RditTzQD$3nR0)kkIyQJ&3=Y5m);sH7X1}N9rgjMa_|HXx~%Wk++cE)rZ>8Z+uhi zXD5e}wU(>vJH3l5(l^5db-i_)sju*Yedt3Vg_ZV2{_>yiIBwtkkFiMji%}H!j|i@T zB{v=}d&xh351eO!BK3z_PHZ7*A}0>3K^VyC$MN;I`f?|3#*+*`%J6EJ?HaBd(sbrW zl~I%yF_zvkFb(V4Evi4ctZc3KY>rWDPVFP8H_GRI@tjf&A|RPx;*%~aNR9VX)Gg!A zsOtW6px`{>-d};dvk6@DifEbQvXVy5qm1s6j60LD{$-Kl@?~%X>dHZGl;UPTm#RIR z_&{<1!Q$QxCS3cfy2N{HQvQ>2vr2MT?kh>%&!1HF$8e*=)ltiq%`IYsxz@+#8sWzu z0-3x;bz4tWKYefm0PjckNSeg*Z_5)wZ`3wR@7AQ4AEIx#VjcJ4SFQsV8(N64vPEwJ zeJ&?4HkbSC(oQkgZsv-@O)e5X!4w7x%D^ox0aaI3;cBAfxWsx&X7<{cQ<7h0kIQR! z`v@yQp9`5#C`LUU(bE=nHyP1=A18EWEH|z#9Eu>fwy=(0+ zK81Aw#7qrvI1<+mNp?z>?cjWs*;!g}=bJyC`YK6^Cs@w;yh^=z%O&hUXf3>$o3VJ^ zta0j|yD}R|A2$GIDC8%$CYjtLz;HoafJR?k~ zND;Zz_XH7h``Vukp@0GLIHh&CCtK0&R{G>V5@2))0sfVndUpxNI$VOa>n zE68{tx7<1&%jy`wKdvvR^LaRxDVvg8tv}@>-^e@xAYY3~Bs9xZAHKlhhHT~Y9{z2W?p&+&I~RZDPQT5an-;$>&BZ~1v#kPtv-TA*@rlw z=B5$8PQ?X#GmoztNt#WHzwJfGU?gBG|FT{btnOuNn56&IH_?$9>@qoDRRMJ#3tJ|O zH2!htR&FroT`2cGXVEUfCdkU@3&WEdTa$G)zOd$p1t#wzlrbb`SGhmTk6hM&s}A6t zsm`qi}-7S@)(bSD0C_mi00t&lw}c>2i{e?s)K zU<3}Qwa17^-<&&hLsj#K>@P&lKXP>BrBkFhk{&fJ>z$j#ow%(TH+EK{cdm+ynoi$I z70$_T4O}MMTGxdH_tu$O*4Im+TRn`71xiKb%WX+I2ybBsT9ekrI|5_af=O3*y)`O!51Br{;FSsF{foTwAj7{_qJ? zE=gLM1aDm}$v~qDDU0l`ZG{qlzKP>{HvWe!M6ee%qgS=_3nr3Di(GRX@70J~IiLRq z=@B&uGev#8B+PtoP}bvoCl|i;a%Q; zGx%%ZZ9x+3mW`p#&@XCTKc%8|z$)pGbXfEUR0&L*E*97vkIhY- zCg#~qER39y$>}^vr{_#kV@lr80LV)K1{q&RJmxxIvQi6UdOg>Nm#B_9z;1WODAJ%x zHpk0z{?2U-;noYLV3^{h>J_Zy{0eipg47u4AMqAD*VW|N*&7H@q1$aS5@hmHhvL2R z+e`rDcI;}^#0z2)ByeKPsTLO%a0!j6M`-HQ80Fs95DGrj<_p>3QJq`^2bYx{xg+X! zX_vRg_1eZfMW6lC9rOeQq8v6AlsI?>^vFN8N9ghNMS*?AR*V!iQtjvZ5`w!m9kFo)sQ|%7D zIR&b2+RXuDwYs3L-ihC!?Y>*&4UhtX4Gfw$W}@b84VTB}IjayT_vimIXQFR9Z@~20Zbqfhz>(t-jt;A`3oJXD;L~S% z8*GesJVx7h9LC&8e`n}%dJy7}?w?vv0kbSmke#%cVVO;$ySUBtF1Jdr>4L$Yf*8)+%Z8)3@*s zhEa;WDO$P%pDbOaSr-`N+z_k!Fg1L4RJ~Y{wO(YRd&_rUREqITt6z2z*IWx5@=q=O zr@oqO!J!d{MqU^y0d)?e6QX{8O4622PcQ@{7E6lb1vP0fNCWrLQZS1!s>Rq=3Glty z$hZlDY-Lbia>5&%rnxIQ*W={rXsE8)vNq z|MRrVLy~a;2$F>0%Jne#&NEmXHToP7|JQ3ay+f=7tpGd7={)$Krtbgjg@AN^c--Gs z205@YatWlqwqD%HO+*^916{<86xC?#8KJ~57)1niF#Iv2QFf+u6PNDnPO`&Fj1Bj& zTA*{?tozsL!q`H4@`@%ZS|F@y<}0J?n%=z9Z@ zZ4xVR=lzpgWYB9szLyhx!w|3FNnl62UH!x41<;OGj1mLkH%*KPAZ7$stbZ?ib2~;+r`sN2hZJ&29baMzkh)C^P|FP(0fJJWyUw@kJs|WNe zPtfVS3{7g@ZxHRu)ePM|MLnMDE*wW(N51SyUCW1pR57uy&%+!rw%^A3`RZy&QZ)Zv z>7=~e5~&B2u0jhe#xeiO3gutU<)un~HaHJ5yz^LcZ9vFxg>nicBG0YOKT7p8{v2m! zx}pJim(PK3{)bNPW6)^FP49g}4-Sf@kJx8EXt{8R@0rq1b(W*iJL+mi%-!SYOF;II zNOoClhO%np>YLMV5Gw^Wd<9bAkL zai}pWFsRP9KXz*6xw~(GjiE(|LOHrNiU+bW;qAoA5=LhoycDCpE~9x$S68dL*%YvU z?nuWimN<&Ck)YU8R^9QQ3-|ptX1yzf5GbESyz)mWvL`dkEQ~owjfo|8aXCX3R}3{p zqP_C)B(|eg>)wp)U6wdi>v>0WcxYrRqOpG)ceU*3`>&c}19Xv`xY8;|?zw9HX z3+ytuu+`rnOuGzpd;RjmZbdbn9JL%^1_vZ3n6a0%XFHZ>ypx?a@kqS9fn6)W-kreTM=K#X^x5hXREbcem1_#ogWA-6<3(8j2QcaW4?uiv)KM#ezd{ zE0XV%@Aurl`@a7;=gc|J%$aBA$t08PC&_MhXR~|lb-l0Gn=%j*8+V_}u}OomzuXJi zS>H`>(#7Wu%az;u;RH)&#SLG)qHR)|;&i-B! zsX9D+4#Ci|WIf?(GAxYkgw=@iYVb6)0FI76-w_Fs&W=1^08U!wnW<5|m?n507C-cB zw>aci%B<`&%r+Nnv;&|RzQgyXMoSU7^^k1su50sw-EF4gmGGy*4Mlig-Ai?h+IJK3 zifCE)uS*xZhe(;kyK?;T;xmoP5xEg!ovGt?jo?@3P;}}H(lLMb%qKt5CEhZ{Sz!3& z;}Gl{8-81P0+StSb0xcT1=TcoYG+Ah7PzGvK0wKs5;t~f<{_+ABuVg6S{uMtt~;jJ z3-8$W@`pZ3OVm+wJ|DNjh_q3Kk{U=Aq~x-^uO>q}@C=<^Omf1MXBD#1fZetTCly4V ziQ1f23`UWt#u8X20Jezo4#xmmQJ8ToDZTh|yUB>4N4e=@Id8aXYWo{`plaN>8*p zbQrrRsVJ8pUja*5XbdWS(kA?i03avfc?AFmUCc~poyi;(g_^qd=JY1#Asb9Mv=jiYCjOheXuB>F|GD0L%Q3TmY+6e}$+&$(gvT zMLwVm+lrgZ*nxw zbG`$h%&G0|R6m3ZPSVw&X#xP(Pg=X^ms5`5_YkA7Vg3~7@j706J)VYWjJP}MIi_oO ze;t);x5s7iafvqNBYd^XFMimNf#ReRFBrD$^ofgW^MI(xNFq{3>wLB{h#jO6Zd-xL@6Q0akW- z6Lqy0_`dk{rG-UAohI@yq4`=&w!vi~;z4L$eND3klKP43H>-zC{OLrf<++($hIp~~5wNUyhs_2I3JON@woLU{|sr0M11 zOoxRb{VUcI+^6`y-Fz^Ufhon)Xrmle0hM&%!q)Lp1M(C+%hH%4>dUrrWd&&z8mvo)wR!`W7%YUCU`0wkj3e4+ai!=$bTTn$?f0+& zCjtHRu_OY^i#W7HV~5Qx719UUsX&_e`Bs4kWe!=JRO~;`e_pQrOkNSXtTAek<5i-& z=*vnzN94yJGg;h((=z0FKvfKN@&bvbsv8XOG&cDw$iAqZi4A254(OTI#mDx=`n&pA z-k_-z39_(T%a-lCf0!Vz)*Wnb`mQc)LG~bDtzq)i`is&hg6ZcevkR!|_11?mT1q@9 zIulB3pw0dJz7Ypz(cx|CYk(A^ocqtwZ zA@jy01Za6gLKSZ&45x^>%C{N;qMO8J|?>23v;9);)m>S?+o)fP93i zh^vG$r`?97#j8ak2S-xDUr8YP7RH7ubLsdm)fdB81AfF)b@T=_3?yo4>Y4ywdRRnH z`O6n-KjO<5HHOB0{Jr*o{s(%Z$q^Cf>u^y|A$Y{yjP}~-@&V6E5FV#s$j{>xdci}) zM3?_0WX5E>8{YVmO4*~|>ui}_swsGJ%Nl~XT#vbs&CUf0(Ws~lgmO9YZps+iSOYgM zt9zB|FkWf)z#=9nnkRPE^%s+RT}e&8!b`>Tb{jm?y#IK?ZgfO%F#I3ipsT1fy_Q7> zic(`iDcv3po!|7)wmkv9pEOlpf(@(_YRwY76AcYIbTbq2=Ay(UTFcEV0;n}!=%$)Q zN_btQ(lvOqFr_8lfT*IGQWoaHZ(`+|2L_xYXyLV&hl@xO+xH^`Z6Cb=UW}o1G5Vq$ zKvI{1BCn*DG|7y&;Q-3)+=wRo9I#;32)~Z$-ct7jP1WjzV-FmxefWJGKN-k(uNN#jsl zYVqcLypNnsic_iRZbD29Z?E8gE=wG>z2(1A-FYJ7?ouX1HTkEbe{pXQNLdJp-GqDO z4X1rDM>;=K_xC2dyO6|X8dA(I3;Gr%4<&s7cKPd~KejM<4{^T{LYnAX41$NBf8)j+ zP*Ka3-B)gkt30*(5_*^oFLn7>o#B5b%=+7$vc{c<^xp~#8iQl6q5mu5D#I$qwAK$x zE9`7*m^s>=1YE=1tt zBkreMv$6JU2T9rB-?`C)cv5C#wqJ`$4`i$}#LA*T_KAJu^Qjv__^1X-|09$I%?xD3 zkG;Bh@!Zd~uPCQ6-^LN3OiUPn{pI7?)A%YAdwu6jcczx9odlf4U)&j%X$+afX{r=l zt&N(=;~*ho#H@}^t;Evayu_JG;I_-#+Di0yr z9-8>9!A}_Dy%QQ?uEju%-y{)5ZXM!*&$@ zz$opx$G4tHl`@1X3-U6oMk^Ina6D$KUAt+C3T(RI-}WjR+5EbT?<}z#pZYUDPNn_f zesFQyD4+r$>t=v7X}~j(;XNtmMvlL^7p!>svhhXwi$)(E&R($#h<2?!UB&obJUy$u z9Q$zes8qOAnHXBRK4VLvZ^G*4p-dj)xxTTH-332Xsfk}5LR3tpnFFS0m(hgNCPzr; zZniF_A8jVqqdH%vQ}hyhC%i~!iyFXJZ1RtAgAe-@gTNcD-&fBVfjb=z)<-%TH4!iXR+D?1R#mXFU&T9OKw*1yw5e+vDJ4se z%UpVdsf*g1P;1?81FwOGe5R?d39~*!?XPW1Y?Wy$sI0sJ(>K2$PpwSTrntJOi=xRE{;o;WgH|n-r4k=sK%+mhRNp#rVWZR#sHi(lJTA617chY z7mkU@UxNU&$QG$l{y|k9oS}Q`;K{!L02E%G117McbM5lkxiEnQ1=aXyY&Xfn-|vvo zzVz#u>9u0*NTdZNp(UE}*q2P2ViOyZk=t**Cd=NNcl}cK-uA}J%HP^DvTx*tDK7=h z2CEZ$v#w({5kQqQ*{B-2SkZ+38VOL$gkNl!GORu(9=EYq40C>_tNGwQF zB)6<;cEQ*=Yq7_Ps~8_WwPhTN#gQ^uP06^YgWT0a!!UO{QyY z(f>W(@88L+W~~q1#$1!we?p8-qhcy6%Oix|W*0b(1 z!s7fFk{(oQTVHg+UOdlO{`Jh#;gg^hgPE(C*3Y$x*oKpU(u;bm2CO;#^EO!P5kKg2 zEAZN3(O9x6*FIxpw7i=g(QE&cSqN%>CJ~(`PH|e$n{V5A+un=`k9v*ue9#f{iG6OU zhZZ1b{38)R&~uiLMa_bN;IWAGu|`#0(U!y&lpg28fEIQ>-Mw&E-Ne>kiNjj=Q%!V@ zH&=Dw2HIupZ{FtdhD;C@T-A4)v;g~ zu5xz^^*R#8cBim@C#tfit8W$@-flD>Le>2j-WkpKh*si(EL4(EF=+(kzv7{EhSQ%% zXgL|;oET$_(VkJ2+u&h7^;GaftCBd(SqM5_m{>}3vaS15%aM$Np-I$&>@d{hF!rJC zvFR$HgW`JPZ2r5&tJnai_eyC3CSNP6;>&>axh$9OHxlF6I%$1z6wky8Xzez&ayijp z+<|owdmiJ$=VHt(9}Y@R=^OvtU|@4TBXe$d6?QZ1xGpG>_9H1wPx4y;QM$d`0rBM; zmOCjPn?pkZLc7xtQCY?Yr;=e_7R@}>ucRBvOWK0+lnVE)<<#D#3~-Y1-u1p&i$*8! z9kLTPc$_=~!Rv~j@fqJz#1J81rB*kcBMRIQf+IAxdDOP~MB_%ZY3WY$ zXRk*suWCy4UpGok*Q}7SOD+T7M@&**X$dnZC32kUCes=S)@Ej)t*pmU2HAh4A{1d0 zqtzOXbN3h=qP{Ayu)XUny*Un*%4SsdV?uSS&WxE;9&3bqKpoE#M35wTZ>9z3zfklN zC46FkDu~iC)M#3Urr}y*-wIAh2@d#sLaV$y7CB=KONN&K*9;*F<&KJ(3@8;IJK)bd zl(y$Y_6@H^P-@XOT2O&lom&cYqu)53+k4BxZ=7pJ{6`6=^ov26r!Rlp;pc757&B;H zV3`BoIWt5*N>$Ux{eYW5QN)aZGLlk0_(J}A^Qy}6-0?3 z)DT&)hA*ZonJkkj54DGqggv&?fs5wtn3jO>AQk1OepLYmhNm(gDgDn*)b#|i$y9S{ z&jr`d?zXhID}#;60NBIsSTy|;>$XoCs=|4|5hAYK&GfnLi6i0-9S-(Csd}(*RxIen zf+1<8Bj@W0%D_KJtqu$>VjxDM22^|&U{F5t%GTlynJZLCT^5il#^TS=l>Vys2~U#` z<()+hRXOC1TCl1N*N}JzGBwEckKndEMnVfI{4^h^?6(p6Dr!=Po{VY$GAIK_1gTIH;$vFq^2cgy5a7Le!6? zXhR(XbB?%RcTDAP+ES#Yq~r1j`IMf={viKoIq#Mv{wKQ$Tu{=h2YZrKl%AI@^qC`d zj8o5fw(;(cFGLn>+-a(DRRZRBvqtc?WsZiPRg z;K{5t#L2LH`#Ps2!4+_rV2d`*JfOangnzWYenk^qN^9GH_oJ3+)Nt$W`9eN$QFEq~ zhvF@^A8vlAK(H9F+CwqST!2f?LJ2kt`np@XMRZA1r76y!h+k%)dpBfd zNY~#Ac>5^$BPv_-k8o$UTZ#qFL3$vZkgx_ZE-PFavkI^)*{|zyb4C!?%4I2}T6(=o z4cj7cXE{1x{w}WlVPiBgRGcO6xi3ebcIWfib^|Y~BIrg82kVuQPM^=6eDl0z^VgN( zx@DNMb8%!~0ZC?`ef+1meuW93%YNsY>k#ZLeL|l&HQk-HZS7n_Jg;KAe*ND!KQ~2zTgs-`yHidXfzq z>1AYMl=sUgjl5!{_^`_+cJtTt24XuNd}ZBd{`QBBG(#CEPB^zq62UtkC!A`EMUU}3 z>0Rk{n_X@2NRW>>p15fH9Hg{xo{!=q2v;ILxOWpodTV4k!`5J6HFNWx$B-eOL196P z#UWp>u*%GGMrPEL1?g? z#c6#%_PrD*YFcOznE?1Q!rsra6*ts%q|3bsrkmN0eL1t6`@6u(!L|v)MTpVPKi>^J zjxWwq0+aWr!Bsz$9PtyXN?K04BAt=1Z|m-Rpt^HjzBLF@kYDu|%-oM$WhVN@ zKXm!F?BZhRE$uWyw-Aoi@3xT?zB7GxMXR^rAErJyqjB%RpJ-AUbem|V*ow0Vp2z`+hTv5?O-7MNG#+pi`nYf?*(}{R>NVPP3IxR}S6{(B?RnMOsl(Wr1huR?oWN)Ok^nn}M`orc7dbc+r2}78Cy~~Yvx>pS8k#i9h*}P1+`9<@D_f54E z?@*Gwi$|sXTZ|}q`ru|eO|ZDS6CEinc&!4a6l~`EY9h32y>wOd^g5k2UE749{kcu$ znp6afO@P%_FPTSkP7$5QIOhTYA28Zm=DrvF4RKxVFdz;>5vdZ}St@*4`n}!O_Q~f8 z8+Fpo0a}}x3fVRV_~pH*Bg7W=!F5*8nM6#x#CjLVo|+Za@ZwTrd#4HV@bh!u6#O3Z z^dpx<5;{XR;2EtZaj+wbDJAUOFadA~Aq}w#vOnLsp4o7T9-PcQkYF3nEFE#}4uhD= zO~stud{kB-Y+u;FUbBA~L@}v6?Utd5S1w;rszc`rE5k6!fdFe28Bes?f(}q=JGxGS7k0LO=ITr2gO*4C$ycUO%sp2DBY6`8}a=qX450&pV!=VeE(tl!@mQIMO|m2 zkvgll(9@S5u=E7#t*a0#HYqmo*#A^&iH*S?iWsDL2BmtsHmK7vWuR8QFH+=zed{}? zoxUFUr#MjX=d>u(W3oeuLLEaXqXh+T9R}q_Z5p6+s9fI z956Thrc&w2e-*Hi2D*Logd4fAMzA&5Yowdd486mw@@G1dKcoIAW_+`8{o}}DR?|rT zE$yal_iBOQz}BDU%W71v0;6=Tp{9>MCAGPh!9wDE!^wm zy+hBmD=oo|WJ%WHD-bRVe1i;SjFUef3_jSd!^h4m5h|ZQb9xoE2&dh;0RKu+nq~7E zBZq0v6oN2k-Bx6}ZQSMBLMNzrB6PfYV`HeNM+E@BhLuaYV=K{5@Lcdf&XM@_qgLj8e1qW9llcnk5UlI|) zME3+2=D9;l!6yW#MHaQcL^egU*fH|$k6kW-i&J{JQb4-DS<2Ntgzbd zFWW3RsbigD-H-Z`KD1(|IRtWKy;iSkE-#y4fr|Q6{RQ9*+wO{VdG*npTaWT(d-zH&C%dwRLL>3_oc~8?eOP z>{bKd>BPIHnTO5LSB)_mWRiWQ*jVEpYj>9?`Z$is!=t5S^)12bZ5eS?i<`yaD^R+L zlvZ|`OZ* zNzBBzQ=dMEw}g!Pt0(BV8dAnDQTptZ^TBwi3XFRGM~GMA*@M^__<`r_a~xk6k^L%4u1i57=AzYT#IM-}c8H)H!YdR>M+guq3)JGU zxS$JFL>S3|gFwJjiT=bmZ&dJPR)4w{f2G8tab|+xN8;saCA}{*>?vGZ#+&-kbhaW> zovO zw_FHk6b5C2u$wXT)Z$vG^9pHn zp(PB+2EOtlc%!l^OHvp(HcjewNL^0 z$xZYZ)?CNvs(WZq^DhLYP8y1@L!1kGEJw1gIxP3ix8ld2Hht{WwVuVysnh}axQ9*4 zCnjUm@y`N;q7*D+07Dx7^>+ z0)c#Elwg;jvqZ5GyMNz{-1FWZD&9t(nxL}v-$GvB%c4PA1LDosj9WfNKhs9J6D|;_ z@blMm1B7aZ5IEN!j}<6Gw`-2)xAdFR(0C-&y~`PL294aDMlQmT<0#*2Z@a*vVHXn~ z57YIy*vBB08Q$9STb*@ixF-i049A3Uc~98gA*S^$>4Ad_X{!yGaB6Tz6_%qHLH&+C z%cB88%{6JkIT zdaDAx_R*+37X4^GpYrSW&pa0~DB1u(LS*KO&Ac6NT5|O6{zG8MJfuVRdf~Q+x+&i- zBTE9uS1TCmz#3YCCR*X*Tq5{t@x%0bYJw&y-(8cmxHn~K|LN{$PYJ9&a-Ya{9xMEB zh5P(ddQ19}ZRX)vnsjuDlKJ>sH#?1`)lB{5_v|Mi+hYUu7NhF@K}gJJZlOIInuCQh zQMfJRSM#kSLU3$L^7_Wkdms1qqW;Iry&!dmOe2-eN|wn@O6=|_-bGK?8%M+>gX@nU zZ}v!J0H{VnTTbS31w@_Qz?6Jy1L*c@;*sy4s99S5fD)A~hRKCon$K!we*X^`bN>#i z7ZpJNt8e&U&;KPA@^%3do5se=>R!+H-0s~w-)o-!l=wl`e8t3DLe!4hr)u~$`L#yM z+Kl+);Y|P%`}@7%%3-Tf4FYYWKW?Z1hQsDeco28}{epNX`>E~HBIkC7z&$NZ_wSx; zQh9*a(9LG6#c2+2UYo`B!nMTNrtidtp)kqF=Z@3MA9E?e6i0}tm=3o&@gJQX=bP*X zLVH3&s;EEL0L^0W=JV68eikS4jqGLLNW7Ht-I&fxrCUxY+@#L?>}%$h`C@7&(xQ#E zF9mAq5Y4IUV7qbf%I+TpXu?5TS@I-q23KjMl5OauiDD$Lp%01?|v>DKiOZ-+M zv5|GMJa0>cU`8)Sl>Y`ediLy|mAl|oJ+?^)klLg(s~C0ogOHtX(8rLk0(mFJU*Bp6 zao_!BH~U6d+#B;M(_LNH5En|9`^i7amw_f#^x2s}zXZUm@06*pF-7gPGKC^YLihaF zh%@x7yiOAnrtGla`5#Qs!i|1#*+yCLTEQsw3Zecpkh0wRBTMQT|K|;fSR{38LmLEh ziOe!qYtSsQ)u0${i>4h!kD=9bgTph&wjG6BW;vAC-kjg$Vjz<7c}B;Qb1*VMnj|YG zk`+G}v#2p@$U^O%?E$6U?Bu%T+060Sc-e=3%F-u*SGrIB)%xarE33YoCYYQNUBWY* zTN0zxS%5F7meLQ8i_GR#nD{4L(68+8bNB-q%Vu$t*4z{nekeUHnJ6JpITsR6^6bLu zo0#s_#UO^iN52V|pE7Yh4<;@?;J)wjPz^w10cu}lmBYv;ItiO8sj@L6(W74_i9&O< zptb1`7tBckM80;{DWjark2(LjS3;ZnyY*^c8y*&=9IU=Lcu?-|btp+JRq5uJp^i2& zj%F-u6OpX{;+~YZGM3SrtY-pUOrGo#p+rl^3l61M?G@97lR=A>jAKv~o9v39$)`L> zX@EFZbg;p*h}4PT#mz=D!5FO-cS*HuE~ZK9cI`m=v3SFJ%wQ4p?7O)6LV1etx@uQh zp$C;BfL{2^Y_mG-Y|q0#apn!Q@w25NU(w@&g4(nKr1coJnCt>EC&XIa4CLuD_(%D3 z1#m6HJytY+F4tNEm$&_BAtmSTzP~(mk;>D>gqv~>B2DG7@ zhtE}pe$)()Lu%o^=wyTALE{c@RG3S$$A-fN0T)nFcrq*ttvE7Cn4Le;8%@^~PK|7k zt7ZSvvn)6sKxrWcot^t5>Gi%N9DzZjcB1*h_e1&9qt=TAAo|Q%@eIE9M~b%`1jD;b z=(G1hc8WZLta0enL+eenMx1xbp0m!9H{a5WPz$Vm1A2RI%b8uJ*sjfcVeGFa%IgG>-fD zhjRP>3tF#;N+lFx2+$lr?@_XbJ*nbSXNkGQe`_{Y`sdC6Y@YwE_ulQGYWVzQBnr0Z z;aG?I7}gmwcE!M4V_$PJzLy>KU;~kynEOLNawfC1Zl@A_v5euGit72e+ooNG)P*Y_w#2T{sL4v?+fFl zrPHL^5e{(cuK(=?am8@Ml(O3Til9siO@6EcNN#5@OKuTc2i%tf7Kwk)BKG>fZHeri w{tttLPkjYj(&8XlG*^2n+Zmafa%~rc=cD$kT-@{-A5&W1Z<=0.3.1 <0.4.0`,Web 分发也需满足其宿主兼容范围。 + +1. 在 InKCre Web 的 **Extensions** 中打开 Twitter 的 **Setup**,选择连接同一 info-base 的在线 Core + Peer。如果尚未启用,先在 Core 和 **This browser** 启用相应分发。 +2. 找到 **Connect x-monkey storage**,复制其中的 **x-monkey service address**,再点击 **Connect + x-monkey**。 +3. 浏览器进入该 Core 的账户页,确认已连接的 Peer 和会话到期时间。这里使用现有 InKCre 连接建立会话,不需要把 + `JWT_SECRET` 交给脚本或 X。 +4. 返回 x-monkey,打开 **设置 → 远端数据库**,启用远端数据库,将刚才的服务地址填入 + **服务地址**,保存并点击 + **检测**。确认显示已连接,再开始同步。地址必须保留 Setup 显示的完整路径前缀。 + +如果脚本先显示“需要登录”,可点击“打开账户页”查看连接指引,再从已连接的 InKCre +Web 完成上述操作,最后回到脚本重新检测。账户页不自动查找你的 InKCre +Web 地址。Setup 没有连接区时,检查所选 Core 及两端分发是否支持本功能。 + +此会话具有现有 Peer +JWT 的完整信息库访问权限,默认有效期为 7 天。有效的会话检测、查询和提交请求进入配置寿命的后一半时自动续签;部署者可以调整这个签发期限。过期后需重新从 Web 连接,不需要额外的 refresh +token。账户页的断开操作只清除此浏览器的会话,不删除已保存信息,也不撤销已经复制的 JWT。 + +## 同步已捕获的帖子 + +在 X 打开帖子,让 x-monkey 捕获它,再在脚本中打开该帖详情。找到 **远端归档**,点击 +**同步此帖**,检查帖子、作者及媒体的逐项结果。**同步全部**也可能包含已经捕获的上下文;它不会让 InKCre 补抓尚未捕获的完整线程或历史。 + +![原版 x-monkey 显示帖子、作者和媒体元数据同步成功](images/x-monkey-sync.jpg) + +图中是预览环境的真实同步结果,记录数量取决于脚本捕获的内容。“媒体已同步”表示媒体元数据已保存,不表示图片或视频文件已经转存。 + +再次同步同一原生帖子会补充同一个信息根,保留已知字段,并按计数观察时间合并更新。Bookmark 采集到同一帖子时也补充这份信息,而不是另建供脚本使用的归档副本。 + +## 在 InKCre 中找回和阅读 + +按既有的[索引与搜索流程](https://inkcre.dev/guide/search)维护词法索引,再用原帖 ID、作者或正文线索查找。在 Info +Base 打开结果,即可阅读已捕获的正文、长文、原帖链接和上下文;这时可以关闭 x-monkey。 + +需要查看图片或视频时,点击对应媒体的 **Load +media**。只有这个显式操作才从已捕获的 CDN 地址读取媒体字节,保存、查询、列表、正文投影和索引不会预先下载它们。 + +![同一帖子在 InKCre 中打开,显示原帖链接、正文与显式加载的照片](images/x-monkey-read.jpg) + +图中照片是在 InKCre 里点击加载后显示的。本版本保存原生媒体元数据,不持久化媒体字节;**Load media** +也不执行转存。如果源站 URL 失效、读取超时或文件超限,界面会反馈失败,文字和元数据仍保留。不要把“已同步”当作媒体文件已长期保全。 + +## 与 Bookmark 授权分别管理 + +x-monkey 使用现有 Peer JWT 连接自己的 InKCre。Bookmark 采集仍使用 Twitter Setup 中单独配置的 X +OAuth 应用与账号授权,随后通过 Source 和 Job 执行;两种连接不互为前提。连接 x-monkey 不需要创建 Bookmark +Source,也不会替你启动书签采集。 + +需要自动采集自己的书签时,继续使用现有的 +[Bookmark 操作指南](https://inkcre.dev/guide/sources/twitter)。断开脚本会话或关闭脚本不会删除两种入口已经保存的信息。 From db070bf3805583a4ff85a8938f2f1182ddeb2d5a Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 23:38:01 +0800 Subject: [PATCH 08/15] =?UTF-8?q?docs(twitter):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=89=A9=E5=B1=95=E7=94=A8=E6=88=B7=E6=8C=87=E5=8D=97?= =?UTF-8?q?=E4=B8=8E=20Python=20=E8=BF=90=E8=A1=8C=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 x-monkey 从首页移至独立功能页,补充 Bookmark 和检索媒体工作流 - 复用共享文档模板,为 global 与 python 接入静态构建和打包约定 --- .../twitter/docs/global/.vitepress/config.mts | 17 +++ .../docs/global/.vitepress/theme/index.ts | 1 + extensions/twitter/docs/global/bookmarks.md | 105 ++++++++++++++++++ extensions/twitter/docs/global/index.md | 82 ++++---------- extensions/twitter/docs/global/reading.md | 40 +++++++ extensions/twitter/docs/global/x-monkey.md | 60 ++++++++++ .../twitter/docs/python/.vitepress/config.mts | 12 ++ .../docs/python/.vitepress/theme/index.ts | 1 + extensions/twitter/docs/python/index.md | 93 ++++++++++++++++ 9 files changed, 352 insertions(+), 59 deletions(-) create mode 100644 extensions/twitter/docs/global/.vitepress/config.mts create mode 100644 extensions/twitter/docs/global/.vitepress/theme/index.ts create mode 100644 extensions/twitter/docs/global/bookmarks.md create mode 100644 extensions/twitter/docs/global/reading.md create mode 100644 extensions/twitter/docs/global/x-monkey.md create mode 100644 extensions/twitter/docs/python/.vitepress/config.mts create mode 100644 extensions/twitter/docs/python/.vitepress/theme/index.ts create mode 100644 extensions/twitter/docs/python/index.md diff --git a/extensions/twitter/docs/global/.vitepress/config.mts b/extensions/twitter/docs/global/.vitepress/config.mts new file mode 100644 index 0000000..fe74a53 --- /dev/null +++ b/extensions/twitter/docs/global/.vitepress/config.mts @@ -0,0 +1,17 @@ +import { extensionDocs } from '../../../../../docs/_shared/website/extension-docs/preset.mts' + +export default { + ...extensionDocs({ + name: 'inkcre/twitter', + title: 'Twitter / X 扩展', + scope: 'global', + description: '通过 x-monkey 保存帖子,或采集自己的 X 书签,并在 InKCre 中检索和阅读。', + sidebar: [ + { text: '扩展概览', link: '/' }, + { text: '用 x-monkey 保存帖子', link: '/x-monkey' }, + { text: '授权与采集 Bookmark', link: '/bookmarks' }, + { text: '检索、阅读与媒体', link: '/reading' }, + ], + }), + lang: 'zh-CN', +} diff --git a/extensions/twitter/docs/global/.vitepress/theme/index.ts b/extensions/twitter/docs/global/.vitepress/theme/index.ts new file mode 100644 index 0000000..e23b068 --- /dev/null +++ b/extensions/twitter/docs/global/.vitepress/theme/index.ts @@ -0,0 +1 @@ +export { default } from '../../../../../../docs/_shared/website/.vitepress/theme/index' diff --git a/extensions/twitter/docs/global/bookmarks.md b/extensions/twitter/docs/global/bookmarks.md new file mode 100644 index 0000000..72666a1 --- /dev/null +++ b/extensions/twitter/docs/global/bookmarks.md @@ -0,0 +1,105 @@ +--- +description: 授权自己的 X 账号,创建 Bookmark Source 并观察一次采集。 +outline: false +--- + +# 授权与采集 Bookmark + +Bookmark 采集使用 X 官方 API 读取自己账号的书签,与 +[x-monkey 连接](./x-monkey.md)独立。你需要可访问相关 API 的 X 开发者应用,以及一条能辨认正文的近期书签。开始前检查应用的访问权限和计费;具体条件以 +[X Bookmarks 文档](https://docs.x.com/x-api/posts/bookmarks/introduction)为准。 + +这条路径使用 OAuth 2.0 用户授权。应用级 app-only bearer token 不能代替账号授权;不要把 InKCre 的 +`JWT_SECRET` 填入 X。 + + + + + + +## 采集范围与停止方式 + +一次普通采集只读取一页,`result_limit` +为 5–100,默认 40;命中已记录的上一条主书签时,只保存它之前的新主书签,未命中则保存返回的本页。书签顺序变化或两次采集之间新增过多内容时,不能据此保证全部覆盖。 + +`full=true` 只取消本页的旧书签截断,并在 Job +state 记录下一页位置;当前实现不会自动提交下一页 Job 或排空历史,也没有独立的 Source +backfill 实现。不要把它当作完整回填、文件夹同步、取消书签同步或全部回复采集。 + +停止自动采集时先禁用对应 Cron,再在向导账号步骤选择 +**Disconnect**。也可以在 X 撤销应用。它们都不删除已经保存的信息;断开 x-monkey 会话则不会代替停止 Bookmark 计划。 + +## 授权或采集失败时 + +- **setup 404**:检查 Core 地址及 Twitter 在该 Core 上的运行状态。 +- **setup 401**:检查当前 InKCre Peer 连接;它与 X 的账号授权不同。 +- **X 授权失效 / 要求重新连接**:重新授权,核对账号和应用是否被替换或撤销。 +- **X 返回 402/403**:检查应用访问权限、额度和账号授权;**429** 表示应等待供应商限流恢复。 +- **Job 完成但没有新信息**:核对连接的 handle,新增一条可辨认的书签后再运行,不重置已有 Source 游标来试错。 + +另有 `twikit` 账号登录后端;它不是上述 OAuth 向导流程。运行人员可从 Python +scope 查看实际配置和本地会话文件要求。 + +返回[扩展概览](./index.md)。 diff --git a/extensions/twitter/docs/global/index.md b/extensions/twitter/docs/global/index.md index b5c7880..8073045 100644 --- a/extensions/twitter/docs/global/index.md +++ b/extensions/twitter/docs/global/index.md @@ -1,71 +1,35 @@ -# 用 x-monkey 将 X 帖子保存到 InKCre +--- +description: 通过 x-monkey 保存帖子,或采集自己的 X 书签,并在 InKCre 中检索和阅读。 +--- -`inkcre/twitter` 可以接收原版 [x-monkey](https://github.com/steven-vvv/x-monkey) -捕获的帖子、作者和媒体元数据。保存后,关闭脚本仍可在 InKCre 中检索和阅读。它与 Bookmark 采集共用原生帖子身份,不需要为 x-monkey 配置 X -OAuth 应用。 +# Twitter / X 扩展 -本文是 Twitter -Extension 源码中的用户指南,尚未构建或发布为 Registry 托管文档站。截图来自隔离预览,展示实际操作界面;请使用自己部署中显示的连接地址。已验证的候选源码版本为 Python -`0.4.1` 与 Web Module Federation `0.4.2`,这些数字不表示相同版本的公开产物已经包含本功能。 +`inkcre/twitter` +把 X 上的信息保存到共享 info-base,提供两种独立入口。保存后的帖子、作者和媒体元数据属于信息库,不依赖采集脚本或某个 Peer 持续运行。 -## 准备连接 +| 你要做什么 | 使用路径 | 需要的授权 | +| -------------------------- | ------------------------------------- | ------------------------------------------------------------------ | +| 保存浏览时已捕获的帖子 | [连接 x-monkey](./x-monkey.md) | 现有 InKCre Peer JWT 会话,不需要 X OAuth 应用。 | +| 定期采集自己的书签 | [授权与采集 Bookmark](./bookmarks.md) | X OAuth 应用、自己的 X 账号授权,以及 Source 和采集 Job。 | +| 保存后找回、阅读和查看媒体 | [检索、阅读与媒体](./reading.md) | 已连接同一 info-base 的 InKCre 界面,以及兼容的 Twitter 阅读分发。 | -你需要已连接自己 info-base 的 InKCre -Web、可从浏览器访问的 Core 公共 HTTPS 地址,以及正在使用的原版 x-monkey 脚本。本次接入以 x-monkey -`v0.2.0` 为兼容基线。 +两种入口按原生帖子 ID 补充同一份信息。已捕获的正文、长文、作者和上下文可以继续使用;缺少的内容不会被编造,也不会在阅读时补抓完整线程。 -在 Core 与 Web Peer 分别安装、启用兼容的 `inkcre/twitter` -Distribution。Core 运行 Python 分发,Web 运行 Module -Federation 分发;两者独立选择版本,不要求版本数字相同。此功能的 Python 分发要求 Core Host SDK -`>=0.3.1 <0.4.0`,Web 分发也需满足其宿主兼容范围。 +## 先准备扩展 -1. 在 InKCre Web 的 **Extensions** 中打开 Twitter 的 **Setup**,选择连接同一 info-base 的在线 Core - Peer。如果尚未启用,先在 Core 和 **This browser** 启用相应分发。 -2. 找到 **Connect x-monkey storage**,复制其中的 **x-monkey service address**,再点击 **Connect - x-monkey**。 -3. 浏览器进入该 Core 的账户页,确认已连接的 Peer 和会话到期时间。这里使用现有 InKCre 连接建立会话,不需要把 - `JWT_SECRET` 交给脚本或 X。 -4. 返回 x-monkey,打开 **设置 → 远端数据库**,启用远端数据库,将刚才的服务地址填入 - **服务地址**,保存并点击 - **检测**。确认显示已连接,再开始同步。地址必须保留 Setup 显示的完整路径前缀。 +先[连接 InKCre](https://inkcre.dev/guide/connect),再在实际执行能力的 Peer 上安装、启用兼容的 Distribution。Core 运行 Python 分发,负责协议接入和 Bookmark 采集;Web 的 Module +Federation 分发提供 Setup 和内容视图。它们独立选择版本,不要求版本数字相同。 -如果脚本先显示“需要登录”,可点击“打开账户页”查看连接指引,再从已连接的 InKCre -Web 完成上述操作,最后回到脚本重新检测。账户页不自动查找你的 InKCre -Web 地址。Setup 没有连接区时,检查所选 Core 及两端分发是否支持本功能。 +Python 分发要求 Core Host SDK +`>=0.3.1 <0.4.0`。Web 分发也须满足其宿主兼容范围;仅安装扩展并不表示它已经在所选 Peer 上运行。x-monkey 和 OAuth 回调需要浏览器可访问的 Core 公共 HTTPS 地址,完整地址由所选 Core 的 Setup 提供。 -此会话具有现有 Peer -JWT 的完整信息库访问权限,默认有效期为 7 天。有效的会话检测、查询和提交请求进入配置寿命的后一半时自动续签;部署者可以调整这个签发期限。过期后需重新从 Web 连接,不需要额外的 refresh -token。账户页的断开操作只清除此浏览器的会话,不删除已保存信息,也不撤销已经复制的 JWT。 +运行人员可从同一 Release 的 Registry 文档入口选择 **Python** scope,查看运行配置、采集参数和 setup +API;本组 **global** 文档说明用户工作流。不同 scope 是独立文档站,不通过拼接 URL 切换。 -## 同步已捕获的帖子 +## 已保存的内容与限制 -在 X 打开帖子,让 x-monkey 捕获它,再在脚本中打开该帖详情。找到 **远端归档**,点击 -**同步此帖**,检查帖子、作者及媒体的逐项结果。**同步全部**也可能包含已经捕获的上下文;它不会让 InKCre 补抓尚未捕获的完整线程或历史。 +本版本保存原生帖子、作者、媒体元数据和已捕获引用,不自动转存图片或视频文件。只有用户明确加载媒体时才读取源站字节;源站失效后,文字和元数据仍保留。具体行为见[媒体边界](./reading.md#媒体按需读取)。 -![原版 x-monkey 显示帖子、作者和媒体元数据同步成功](images/x-monkey-sync.jpg) +x-monkey 接入不提供远端历史浏览、搜索恢复或未捕获线程补抓。Bookmark 的一次 Job 读取一页,不保证全量回填或同步删除。采集完成与检索索引维护是两个动作,新增内容需要按既有流程维护索引后再搜索。 -图中是预览环境的真实同步结果,记录数量取决于脚本捕获的内容。“媒体已同步”表示媒体元数据已保存,不表示图片或视频文件已经转存。 - -再次同步同一原生帖子会补充同一个信息根,保留已知字段,并按计数观察时间合并更新。Bookmark 采集到同一帖子时也补充这份信息,而不是另建供脚本使用的归档副本。 - -## 在 InKCre 中找回和阅读 - -按既有的[索引与搜索流程](https://inkcre.dev/guide/search)维护词法索引,再用原帖 ID、作者或正文线索查找。在 Info -Base 打开结果,即可阅读已捕获的正文、长文、原帖链接和上下文;这时可以关闭 x-monkey。 - -需要查看图片或视频时,点击对应媒体的 **Load -media**。只有这个显式操作才从已捕获的 CDN 地址读取媒体字节,保存、查询、列表、正文投影和索引不会预先下载它们。 - -![同一帖子在 InKCre 中打开,显示原帖链接、正文与显式加载的照片](images/x-monkey-read.jpg) - -图中照片是在 InKCre 里点击加载后显示的。本版本保存原生媒体元数据,不持久化媒体字节;**Load media** -也不执行转存。如果源站 URL 失效、读取超时或文件超限,界面会反馈失败,文字和元数据仍保留。不要把“已同步”当作媒体文件已长期保全。 - -## 与 Bookmark 授权分别管理 - -x-monkey 使用现有 Peer JWT 连接自己的 InKCre。Bookmark 采集仍使用 Twitter Setup 中单独配置的 X -OAuth 应用与账号授权,随后通过 Source 和 Job 执行;两种连接不互为前提。连接 x-monkey 不需要创建 Bookmark -Source,也不会替你启动书签采集。 - -需要自动采集自己的书签时,继续使用现有的 -[Bookmark 操作指南](https://inkcre.dev/guide/sources/twitter)。断开脚本会话或关闭脚本不会删除两种入口已经保存的信息。 +操作截图来自隔离预览,连接时使用自己 Setup 显示的服务地址。 diff --git a/extensions/twitter/docs/global/reading.md b/extensions/twitter/docs/global/reading.md new file mode 100644 index 0000000..f68fbcf --- /dev/null +++ b/extensions/twitter/docs/global/reading.md @@ -0,0 +1,40 @@ +--- +description: 找回已保存的帖子,阅读正文和上下文,并显式加载外部媒体。 +--- + +# 检索、阅读与媒体 + +[x-monkey](./x-monkey.md) 与 [Bookmark](./bookmarks.md) +保存的信息都可从 InKCre 独立使用。关闭脚本、断开提交会话或停止采集计划,不会删除已经写入 info-base 的记录。Web 需要能够解释当前 Twitter 记录的兼容分发。 + +## 保存后找回同一帖子 + +1. 先确认脚本的逐项同步结果,或 Bookmark 采集 Job 的实际完成状态。 +2. 按既有[索引与搜索流程](https://inkcre.dev/guide/search)维护词法索引,等待维护完成。提交和采集不会隐式维护索引,不需要为了这条检索路径配置 AI 凭据。 +3. 用原帖 ID、原始链接、作者或正文线索搜索,在 Info Base 打开结果。检查正文、长文和 **Original + post** 出处;已捕获的作者及引用、回复、转帖上下文可以继续导航。 + +只知道原生 ID 的引用会显示为未捕获的身份,不伪造正文或作者资料。纯转帖也不补出一段假正文。旧 v1 帖子仍保留读取能力;重新采集到足够原生事实后可沿用原 Block +ID 升级,既有用户引用继续有效。历史上已经存在的重复不会在本版本全量清理。 + +## 媒体按需读取 + +图片、视频或 GIF 初始只显示已经捕获的元数据。需要查看时点击 **Load +media**;Twitter 阅读分发才从已捕获的 CDN URL 经 HTTP +Storage 读取字节。保存、query、列表、正文投影与索引不会提前下载这些原生媒体。 + +![同一帖子在 InKCre 中打开,显示原帖链接、正文与显式加载的照片](images/x-monkey-read.jpg) + +这是隔离预览中真实保存的同一帖子,照片在用户明确加载后显示。本版本不预建持久媒体内容 Block,不转存媒体文件,也不创建媒体下载 Job;**Load +media** 不会将字节保存在信息库。 + +读取只接受允许 CDN 主机的 HTTPS 地址。视频和 GIF 从已捕获的可用 MP4 +variants 中选择最高码率,不把 HLS 播放清单当作视频本体。原生 URL 和其他元数据仍保留,即使它们不能用于当前读取。 + +## 来源失效或内容不完整时 + +源站 URL 失效、超时、体积超限或没有可用地址时,界面会反馈媒体不可用;文字和元数据仍可读取。重新打开内容不代表媒体已经长期保全,不应把“已同步”理解为文件备份成功。 + +阅读只解释已经保存的内容,不额外抓取未捕获的完整线程、引用正文或历史。x-monkey 与 Bookmark 的源信息密度不同,后来的稀疏信息不会抹掉未提交的已知内容;计数按各项实际观察时间合并,较旧观察不会覆盖较新值。 + +返回[扩展概览](./index.md)。 diff --git a/extensions/twitter/docs/global/x-monkey.md b/extensions/twitter/docs/global/x-monkey.md new file mode 100644 index 0000000..5bb9e4a --- /dev/null +++ b/extensions/twitter/docs/global/x-monkey.md @@ -0,0 +1,60 @@ +--- +description: 将原版 x-monkey 连接到自己的 InKCre,保存已捕获的帖子。 +--- + +# 用 x-monkey 保存帖子 + +此入口接收原版 [x-monkey](https://github.com/steven-vvv/x-monkey) +捕获的帖子、作者和媒体元数据,不需要 X OAuth 应用、Bookmark Source 或采集计划。兼容基线为 x-monkey +`v0.2.0`。 + +## 连接自己的 InKCre + +准备已连接自己 info-base 的 InKCre +Web、可从浏览器访问的 Core 公共 HTTPS 地址,以及正在使用的原版 x-monkey 脚本。在 Core 和 **This +browser** 分别安装、启用兼容的 Twitter 分发。 + +1. 在 InKCre Web 的 **Extensions** 中打开 Twitter 的 **Setup**,选择连接同一 info-base 的在线 Core + Peer。 +2. 找到 **Connect x-monkey storage**,复制其中的 **x-monkey service address**,再点击 **Connect + x-monkey**。 +3. 浏览器进入该 Core 的账户页,确认已连接的 Peer 和会话到期时间。这一步使用现有 InKCre 连接建立会话,不需要把 + `JWT_SECRET` 交给脚本或 X。 +4. 返回 x-monkey,打开 **设置 → 远端数据库**,启用远端数据库,将刚才的服务地址填入 + **服务地址**,保存并点击 + **检测**。确认已连接,再开始同步。地址必须保留 Setup 显示的完整路径前缀。 + +如果脚本先显示“需要登录”,可点击“打开账户页”查看连接指引,再从已连接的 InKCre +Web 完成上述操作,最后回到脚本重新检测。账户页不自动查找你的 InKCre +Web 地址。Setup 没有连接区时,检查所选 Core 及两端分发是否支持本功能。 + +## 同步已捕获的帖子 + +在 X 打开帖子,让 x-monkey 捕获它,再在脚本中打开该帖详情。找到 **远端归档**,点击 +**同步此帖**,检查帖子、作者及媒体的逐项结果。**同步全部**也可能包含已经捕获的上下文;它不会让 InKCre 补抓尚未捕获的完整线程或历史。 + +![原版 x-monkey 显示帖子、作者和媒体元数据同步成功](images/x-monkey-sync.jpg) + +图中是预览环境的真实同步结果,记录数量取决于脚本捕获的内容。“媒体已同步”表示媒体元数据已保存,不表示图片或视频文件已经转存。 + +再次同步同一原生帖子会补充同一个信息根,保留未提交的已知字段,并按计数观察时间合并更新。Bookmark 采集到同一帖子时也补充这份信息。较旧捕获可能继续显示计数差异;不能仅凭差异判断保存失败。 + +保存后,按[检索与阅读](./reading.md)找回同一帖子。关闭脚本或结束提交会话后,已保存的信息仍在。 + +## 续签、过期与断开 + +此会话具有现有 Peer +JWT 的完整信息库访问权限,默认有效期为 7 天。有效的会话检测、查询和提交请求在剩余期限不超过当前配置寿命的一半时自动续签。部署者可以调整签发期限;普通 Core 请求不会续签该会话。 + +过期后需重新从 InKCre Web 连接,不需要额外的 refresh +token。活跃会话没有从首次连接开始计算的绝对续签上限。账户页的断开操作只清除此浏览器的会话,不删除已保存信息,也不撤销已经复制的 JWT。 + +## 检测或同步失败时 + +- **需要登录 / 401**:从 InKCre + Web 重新连接,然后返回脚本检测。不要把 Core 的 JWT_SECRET 填入脚本来代替连接流程。 +- **账户页或协议返回 404**:检查服务地址、部署路径前缀,以及 Twitter 是否正在所选 Core 上运行。 +- **结果失败或请求超时**:保留捕获内容,检查实际请求结果再重试。成功保存后重复提交仍补充同一帖子,不必创建第二个存储后端。 +- **媒体读取失败**:按[媒体边界](./reading.md#媒体按需读取)处理;元数据同步成功不保证外部媒体 URL 长期可用。 + +返回[扩展概览](./index.md),或继续配置独立的 [Bookmark 采集](./bookmarks.md)。 diff --git a/extensions/twitter/docs/python/.vitepress/config.mts b/extensions/twitter/docs/python/.vitepress/config.mts new file mode 100644 index 0000000..5aebf78 --- /dev/null +++ b/extensions/twitter/docs/python/.vitepress/config.mts @@ -0,0 +1,12 @@ +import { extensionDocs } from '../../../../../docs/_shared/website/extension-docs/preset.mts' + +export default { + ...extensionDocs({ + name: 'inkcre/twitter', + title: 'Twitter Core 运行与配置', + scope: 'python', + description: '在 Core 上运行 Twitter 的协议接入和 Bookmark 采集,配置授权及运行参数。', + sidebar: [{ text: '运行与配置', link: '/' }], + }), + lang: 'zh-CN', +} diff --git a/extensions/twitter/docs/python/.vitepress/theme/index.ts b/extensions/twitter/docs/python/.vitepress/theme/index.ts new file mode 100644 index 0000000..e23b068 --- /dev/null +++ b/extensions/twitter/docs/python/.vitepress/theme/index.ts @@ -0,0 +1 @@ +export { default } from '../../../../../../docs/_shared/website/.vitepress/theme/index' diff --git a/extensions/twitter/docs/python/index.md b/extensions/twitter/docs/python/index.md new file mode 100644 index 0000000..c97362a --- /dev/null +++ b/extensions/twitter/docs/python/index.md @@ -0,0 +1,93 @@ +--- +description: Twitter Python 分发的宿主要求、公共地址、授权配置与采集参数。 +--- + +# Core 运行与配置 + +Python 分发在所选 Core 上提供 x-monkey 归档协议、Bookmark Source 及 Twitter Resolver。它不包含 Web +Setup 或阅读界面;用户工作流由本扩展的 **global** 文档说明,Web 另选兼容的 Module Federation 分发。 + +Python 分发要求 Python `>=3.12,<3.13` 和 Core Host SDK +`>=0.3.1 <0.4.0`。选择精确分发、启用意图和实际运行属于相应 Peer;扩展配置与账号状态由部署共享。 + +## 公共地址与生命周期 + +在执行该能力的 Core Peer 配置 +`http_public_base_url`,使用浏览器或脚本实际可访问的 HTTPS 基址。Setup 由此产生 +`/twitter/auth/callback` 和 `/twitter/x-monkey` +地址,保留部署路径前缀。不要使用 PostgREST 地址代替 Core 地址,也不要把预览环境地址作为其他部署的默认值。 + +启用 Python 分发后才会注册 Source、Resolver 和 HTTP 路由;停用撤回入口,不删除已保存的图信息。Web 独立启用不会让离线 Core 执行采集,安装状态也不等于实际运行状态。多 Peer 可以具备采集能力,但同一 Source 不应配置重复的采集计划。 + +## 扩展配置 + +下表是 `TwitterExtensionConfig` +的实际字段,通过既有扩展配置管理接口修改。OAuth 向导会同时维护相应账号状态,优先使用向导配置官方后端。 + +| 字段 | 默认值 | 用途 | +| ------------------------------- | ---------- | --------------------------------------------------------------- | +| `backend` | `official` | `official` 使用 X OAuth 用户授权;`twikit` 使用账号登录。 | +| `client_id`、`client_secret` | 空字符串 | 官方 OAuth 应用的 Client ID/Secret,不是 API Key/Secret。 | +| `email`、`username`、`password` | 空字符串 | `twikit` 登录所需账号信息。 | +| `totp_secret` | `null` | `twikit` 可选 TOTP 秘密。 | +| `api_language` | `en-US` | 传给 `twikit` 客户端的请求语言。 | +| `proxy` | `null` | 传给 `twikit` 客户端的代理;当前官方 OAuth 客户端不读取此字段。 | + +保存 OAuth 应用会把 `backend` 设为 `official`。如果应用变更会影响已有账号或待完成授权,setup +API 要求显式 +`confirm_account_reset`;配置指纹改变会清除不再匹配的账号和授权事务。不要盲目确认重置或直接编辑持久 state 来绕过它。 + +官方后端把账号 token、user +ID、handle 和续期状态保存在扩展 state;账号授权属于整个扩展,不属于某个 Source。官方客户端使用当前授权刷新 X +token,供应商拒绝授权时标记需要重新连接。凭据及授权链接不应进入日志或截图。 + +`twikit` 在实际采集时登录,使用 `data/extensions/twitter/twikit_cookies.json` +作为运行实例的 cookie 文件。该路径需要可写并保护其中的会话数据;它不是共享 info-base 的账号状态,也不会自动在多个 Core 之间同步。OAuth 向导不能代替这套账号登录配置。改变后端或账号后应在正常生命周期中停用并重新启用,以结束旧的本地客户端。 + +## setup API 与认证边界 + +运行人员或 Agent 可通过既有 Peer HTTP 方式调用下列 Core 路由。setup API 要求 Peer JWT +Bearer;CLI 当前没有专用 OAuth 设置命令。 + +| 方法与路径 | 作用 | +| ---------------------------------------- | ----------------------------------------------------------------------- | +| `GET /twitter/setup` | 读取 callback URL、可用 x-monkey 基址和账号连接状态。 | +| `PUT /twitter/setup/oauth-app` | 提交 `client_id`、`client_secret`,必要时明确 `confirm_account_reset`。 | +| `POST /twitter/setup/oauth-transactions` | 创建十分钟有效的授权事务,返回供用户打开的私有授权链接。 | +| `POST /twitter/setup/oauth-transaction` | 以 `transaction_id` 读取该事务结果。 | +| `DELETE /twitter/setup/account` | 断开扩展当前连接的 X 账号,不删除已采集信息。 | + +用户仍需在 X 页面批准正确账号,浏览器回调为 +`/twitter/auth/callback`。新建授权会替代此前未完成的尝试;替换应用或账号会影响所有 Bookmark +Sources。 + +x-monkey 的 +`/twitter/x-monkey/api/v1/session`、`/twitter/x-monkey/api/v1/archive/query`、`/twitter/x-monkey/api/v1/archive/submit` +使用协议专用 Cookie,与以上 Bearer 路由分开。首次连接由 Web 向 `/twitter/x-monkey/session` +顶层 POST 新鲜 Peer JWT;Core 为相同完整 Peer 权限签发带 `session_use=twitter_archive` +的会话 JWT,Cookie 路径不构成降权。Cookie 为 Secure、HttpOnly、SameSite=None,要求正式链路使用 HTTPS;普通 Core 保护路由不读取它。 + +## x-monkey 寿命配置 + +`TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS` 是 Core 环境配置,不是扩展 JSON 配置字段。默认 `604800` +秒,即 7 天,只接受正整数。首次连接与续签使用同一配置;有效协议请求在剩余期限不超过当前配置寿命的一半时续签。 + +此值是以后签发 token 的默认期限,不是 JWT 准入上限;降低它不会撤销仍有效的既有 token。它与普通 Core 签发使用的 +`JWT_DEFAULT_LIFETIME_SECONDS` 分开。没有额外 PAT、refresh +token、会话表或从首次授权计算的绝对上限。断开只清理当前浏览器 Cookie,不能撤销复制的 JWT;部署更换原有 JWT_SECRET 后按既有机制失效。 + +## Bookmark Source 与采集参数 + +Source 类型为 `extensions.twitter.bookmark.Source`,Source +config 当前为空对象。普通采集通过既有 Source Job 执行,参数如下: + +| 参数 | 默认值 | 实际行为 | +| -------------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `result_limit` | `40` | 整数,5–100,作为本次一页请求的结果上限。 | +| `full` | `false` | `false` 时按 `latest_tweet_id` 截断本页旧书签并更新主书签游标;`true` 时不截断,并将下一页位置记到 Job state。 | + +每次 Job 只请求一页,不自动提交后续 Job;`full` +不等于完整回填。此 Source 没有独立 backfill 实现。分页位置和 Source 游标是运行状态,不应当成账号配置手工重置来排查授权故障。外部请求失败与数据库写入失败不能被报告为成功采集。 + +媒体只保存原生元数据,Python 写入不创建持久图片/视频内容 Block、媒体 blob 或下载 Job。只有消费端明确展开时才通过现有 HTTP +Storage 读取源站字节。词法索引维护独立执行,不在 submit 或 collect 中隐式完成。 From e345d5e55964eec9660435a7e407bbf30baaa9a1 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 19 Oct 2026 23:45:27 +0800 Subject: [PATCH 09/15] =?UTF-8?q?docs(twitter):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20CLI=20=E4=B9=A6=E7=AD=BE=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留不安装 Web 分发时的现有 OAuth 请求示例与成功检查 - 补充依赖、授权期限与应用替换冲突说明 --- extensions/twitter/docs/global/bookmarks.md | 88 +++++++++++++++++++-- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/extensions/twitter/docs/global/bookmarks.md b/extensions/twitter/docs/global/bookmarks.md index 72666a1..6ed5b3e 100644 --- a/extensions/twitter/docs/global/bookmarks.md +++ b/extensions/twitter/docs/global/bookmarks.md @@ -47,14 +47,90 @@ Sources 共用扩展当前连接的一个 X 账号。不要通过切换账号来