Add scheduled X post fetcher deployment
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Create .env from this file locally, then replace with the Bearer Token from X Developer Console.
|
||||||
|
X_BEARER_TOKEN=replace_me
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
*.db
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 每天抓取指定 X 账号推文
|
||||||
|
|
||||||
|
这个项目提供一个零第三方依赖的 Python 脚本,使用 **X 官方 API v2** 查询指定账号的近期原创推文,并去重存入本地 SQLite 数据库。不会抓取私密账号,也默认排除转推。
|
||||||
|
|
||||||
|
## 配置并手动运行
|
||||||
|
|
||||||
|
1. 在 [X Developer Console](https://developer.x.com/) 创建有 API 访问权限的项目,并取得 Bearer Token。
|
||||||
|
2. 复制环境变量示例并填入 token(不要把 `.env` 提交到 Git):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set -a; source .env; set +a
|
||||||
|
python3 fetch_x_posts.py @OpenAI @XDevelopers
|
||||||
|
```
|
||||||
|
|
||||||
|
首次运行默认取过去 24 小时;可用 `--initial-lookback-hours 168` 取最多 7 天。之后的运行会从上次成功时间向前回退 5 分钟查询,SQLite 的主键去重保证不会重复保存。
|
||||||
|
|
||||||
|
数据保存在 `x_posts.db`:`posts` 表包含正文、发布时间、公开互动指标、原始 API JSON 和可直接打开的链接。查看最近记录:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sqlite3 x_posts.db 'SELECT username, created_at, text, url FROM posts ORDER BY created_at DESC LIMIT 20;'
|
||||||
|
```
|
||||||
|
|
||||||
|
## macOS 每天定时(推荐)
|
||||||
|
|
||||||
|
将下面内容存为 `~/Library/LaunchAgents/com.local.fetch-x.plist`,把其中的项目路径和账号改成自己的;token 请直接填入 `EnvironmentVariables`,或改为调用一个仅自己可读的 wrapper 脚本。
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>Label</key><string>com.local.fetch-x</string>
|
||||||
|
<key>ProgramArguments</key><array>
|
||||||
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>/Users/YOU/Work/fetch_x/fetch_x_posts.py</string>
|
||||||
|
<string>@OpenAI</string><string>@XDevelopers</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key><string>/Users/YOU/Work/fetch_x</string>
|
||||||
|
<key>EnvironmentVariables</key><dict>
|
||||||
|
<key>X_BEARER_TOKEN</key><string>YOUR_BEARER_TOKEN</string>
|
||||||
|
</dict>
|
||||||
|
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
|
||||||
|
<key>StandardOutPath</key><string>/Users/YOU/Work/fetch_x/fetch-x.log</string>
|
||||||
|
<key>StandardErrorPath</key><string>/Users/YOU/Work/fetch_x/fetch-x-error.log</string>
|
||||||
|
</dict></plist>
|
||||||
|
```
|
||||||
|
|
||||||
|
加载并立即验证一次:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.local.fetch-x.plist
|
||||||
|
launchctl kickstart -k gui/$(id -u)/com.local.fetch-x
|
||||||
|
```
|
||||||
|
|
||||||
|
X 的“recent search”接口只能检索最近 7 天的推文,且实际 API 权限、额度和速率限制取决于你的 X 开发者套餐。官方接口文档见 [Search recent Posts](https://docs.x.com/x-api/posts/search-recent-posts)。
|
||||||
|
|
||||||
|
## 部署到 Linux 服务器(systemd)
|
||||||
|
|
||||||
|
以下步骤适用于 Ubuntu/Debian 等使用 systemd 的服务器。把整个项目上传到服务器后,在项目目录执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo bash deploy/install-linux.sh
|
||||||
|
sudoedit /etc/fetch-x/fetch-x.env
|
||||||
|
```
|
||||||
|
|
||||||
|
在环境文件中写入一行 `X_BEARER_TOKEN=你的token`,然后把 [deploy/fetch-x.service](deploy/fetch-x.service) 的账号参数 `@OpenAI @XDevelopers` 换成目标账号并重载:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start fetch-x.service
|
||||||
|
sudo journalctl -u fetch-x.service -n 100 --no-pager
|
||||||
|
systemctl list-timers fetch-x.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
默认每天服务器本地时间 09:00 运行,修改 [deploy/fetch-x.timer](deploy/fetch-x.timer) 的 `OnCalendar` 后执行 `sudo systemctl daemon-reload && sudo systemctl restart fetch-x.timer`。定时器设有 `Persistent=true`,服务器关机错过的执行会在下次开机后补跑。
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Fetch selected X posts into SQLite
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=fetchx
|
||||||
|
WorkingDirectory=/opt/fetch-x
|
||||||
|
EnvironmentFile=/etc/fetch-x/fetch-x.env
|
||||||
|
ExecStart=/usr/bin/python3 /opt/fetch-x/fetch_x_posts.py @OpenAI @XDevelopers
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# Avoid a hung API request blocking a subsequent scheduled run indefinitely.
|
||||||
|
TimeoutStartSec=10min
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/fetch-x
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run X post fetcher every day
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 09:00:00
|
||||||
|
Persistent=true
|
||||||
|
Unit=fetch-x.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run as root from the project directory on Debian/Ubuntu-like Linux hosts.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR=/opt/fetch-x
|
||||||
|
CONFIG_DIR=/etc/fetch-x
|
||||||
|
SERVICE_USER=fetchx
|
||||||
|
|
||||||
|
if [[ ${EUID} -ne 0 ]]; then
|
||||||
|
echo "Run with sudo: sudo bash deploy/install-linux.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f fetch_x_posts.py || ! -f deploy/fetch-x.service ]]; then
|
||||||
|
echo "Run this from the fetch_x project directory." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
id -u "$SERVICE_USER" >/dev/null 2>&1 || useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$APP_DIR"
|
||||||
|
install -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0640 fetch_x_posts.py "$APP_DIR/fetch_x_posts.py"
|
||||||
|
install -d -m 0750 "$CONFIG_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_DIR/fetch-x.env" ]]; then
|
||||||
|
install -m 0600 /dev/null "$CONFIG_DIR/fetch-x.env"
|
||||||
|
echo "Created $CONFIG_DIR/fetch-x.env. Add X_BEARER_TOKEN=... before starting the service."
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0644 deploy/fetch-x.service /etc/systemd/system/fetch-x.service
|
||||||
|
install -m 0644 deploy/fetch-x.timer /etc/systemd/system/fetch-x.timer
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now fetch-x.timer
|
||||||
|
systemctl list-timers fetch-x.timer --no-pager
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch recent posts from selected X accounts into a local SQLite database.
|
||||||
|
|
||||||
|
Requires an X API bearer token. See README.md for setup and scheduling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.x.com/2/tweets/search/recent"
|
||||||
|
DEFAULT_DB = Path(__file__).with_name("x_posts.db")
|
||||||
|
OVERLAP = timedelta(minutes=5) # avoids gaps around a scheduled run boundary
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def iso_time(value: datetime) -> str:
|
||||||
|
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
public_metrics TEXT,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_username_created
|
||||||
|
ON posts(username, created_at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_state(connection: sqlite3.Connection, key: str) -> str | None:
|
||||||
|
row = connection.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_state(connection: sqlite3.Connection, key: str, value: str) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO state(key, value) VALUES(?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(token: str, params: dict[str, str]) -> dict:
|
||||||
|
request = Request(
|
||||||
|
f"{API_URL}?{urlencode(params)}",
|
||||||
|
headers={"Authorization": f"Bearer {token}", "User-Agent": "x-post-fetcher/1.0"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
except HTTPError as error:
|
||||||
|
body = error.read().decode("utf-8", errors="replace")
|
||||||
|
raise RuntimeError(f"X API returned HTTP {error.code}: {body}") from error
|
||||||
|
except URLError as error:
|
||||||
|
raise RuntimeError(f"Unable to reach X API: {error.reason}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_account(token: str, username: str, start_time: str) -> list[dict]:
|
||||||
|
"""Fetch every matching post in the requested period, following pagination."""
|
||||||
|
posts: list[dict] = []
|
||||||
|
next_token: str | None = None
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"query": f"from:{username} -is:retweet",
|
||||||
|
"start_time": start_time,
|
||||||
|
"max_results": "100",
|
||||||
|
"sort_order": "recency",
|
||||||
|
"tweet.fields": "created_at,public_metrics,referenced_tweets,lang,entities",
|
||||||
|
}
|
||||||
|
if next_token:
|
||||||
|
params["next_token"] = next_token
|
||||||
|
payload = request_json(token, params)
|
||||||
|
posts.extend(payload.get("data", []))
|
||||||
|
next_token = payload.get("meta", {}).get("next_token")
|
||||||
|
if not next_token:
|
||||||
|
return posts
|
||||||
|
time.sleep(1) # be conservative with API rate limits
|
||||||
|
|
||||||
|
|
||||||
|
def save_posts(connection: sqlite3.Connection, username: str, posts: list[dict], fetched_at: str) -> int:
|
||||||
|
saved = 0
|
||||||
|
for post in posts:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO posts
|
||||||
|
(id, username, created_at, text, url, public_metrics, raw_json, fetched_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
post["id"], username,
|
||||||
|
post.get("created_at", ""), post.get("text", ""),
|
||||||
|
f"https://x.com/{username}/status/{post['id']}",
|
||||||
|
json.dumps(post.get("public_metrics"), ensure_ascii=False),
|
||||||
|
json.dumps(post, ensure_ascii=False), fetched_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
saved += cursor.rowcount
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Fetch selected X accounts into SQLite.")
|
||||||
|
parser.add_argument("usernames", nargs="+", help="X usernames, with or without @")
|
||||||
|
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help=f"SQLite database (default: {DEFAULT_DB})")
|
||||||
|
parser.add_argument("--initial-lookback-hours", type=int, default=24,
|
||||||
|
help="How much history to fetch on the first run (max 168; default: 24)")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
token = os.environ.get("X_BEARER_TOKEN")
|
||||||
|
if not token:
|
||||||
|
print("Missing X_BEARER_TOKEN environment variable.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not 1 <= args.initial_lookback_hours <= 168:
|
||||||
|
print("--initial-lookback-hours must be between 1 and 168.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
usernames = list(dict.fromkeys(name.lstrip("@").strip() for name in args.usernames))
|
||||||
|
if any(not name for name in usernames):
|
||||||
|
print("Usernames cannot be empty.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
args.db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
now = utc_now()
|
||||||
|
fetched_at = iso_time(now)
|
||||||
|
with sqlite3.connect(args.db) as connection:
|
||||||
|
init_db(connection)
|
||||||
|
last_success = get_state(connection, "last_success_at")
|
||||||
|
if last_success:
|
||||||
|
start = datetime.fromisoformat(last_success.replace("Z", "+00:00")) - OVERLAP
|
||||||
|
else:
|
||||||
|
start = now - timedelta(hours=args.initial_lookback_hours)
|
||||||
|
# The recent-search endpoint cannot look back further than seven days.
|
||||||
|
start = max(start, now - timedelta(days=7))
|
||||||
|
start_time = iso_time(start)
|
||||||
|
total = 0
|
||||||
|
for username in usernames:
|
||||||
|
posts = fetch_account(token, username, start_time)
|
||||||
|
saved = save_posts(connection, username, posts, fetched_at)
|
||||||
|
total += saved
|
||||||
|
print(f"@{username}: fetched {len(posts)}, saved {saved} new post(s)")
|
||||||
|
# Only advance after all accounts completed successfully.
|
||||||
|
set_state(connection, "last_success_at", fetched_at)
|
||||||
|
print(f"Done. {total} new post(s) saved to {args.db}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user