linkgnome 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkgnome/__init__.py +3 -0
- linkgnome/cache.py +57 -0
- linkgnome/cli.py +124 -0
- linkgnome/config.py +169 -0
- linkgnome/db.py +278 -0
- linkgnome/fetchers/__init__.py +0 -0
- linkgnome/fetchers/base.py +91 -0
- linkgnome/fetchers/bluesky.py +179 -0
- linkgnome/fetchers/mastodon.py +265 -0
- linkgnome/link_meta.py +87 -0
- linkgnome/scorer.py +238 -0
- linkgnome/setup.py +194 -0
- linkgnome/tui.py +288 -0
- linkgnome-0.0.3.dist-info/METADATA +34 -0
- linkgnome-0.0.3.dist-info/RECORD +18 -0
- linkgnome-0.0.3.dist-info/WHEEL +4 -0
- linkgnome-0.0.3.dist-info/entry_points.txt +2 -0
- linkgnome-0.0.3.dist-info/licenses/LICENSE +28 -0
linkgnome/__init__.py
ADDED
linkgnome/cache.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Caching layer for fetched feed data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from diskcache import Cache
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FeedCache:
|
|
13
|
+
"""Cache for feed data."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, cache_dir: Path | None = None, ttl_seconds: int = 600):
|
|
16
|
+
self.cache_dir = cache_dir or Path.home() / ".cache" / "linkgnome"
|
|
17
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
self.ttl_seconds = ttl_seconds
|
|
19
|
+
self._cache = Cache(str(self.cache_dir))
|
|
20
|
+
|
|
21
|
+
def get_feed(
|
|
22
|
+
self, platform: str, timeline_type: str
|
|
23
|
+
) -> list[dict[str, Any]] | None:
|
|
24
|
+
"""Get cached feed data if it hasn't expired."""
|
|
25
|
+
cache_key = f"{platform}:{timeline_type}"
|
|
26
|
+
cached = self._cache.get(cache_key)
|
|
27
|
+
if cached is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
data, timestamp = cached
|
|
31
|
+
if datetime.now().timestamp() - timestamp > self.ttl_seconds:
|
|
32
|
+
self._cache.delete(cache_key)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
def set_feed(
|
|
38
|
+
self,
|
|
39
|
+
platform: str,
|
|
40
|
+
timeline_type: str,
|
|
41
|
+
posts: list[dict[str, Any]],
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Cache feed data."""
|
|
44
|
+
cache_key = f"{platform}:{timeline_type}"
|
|
45
|
+
self._cache.set(
|
|
46
|
+
cache_key,
|
|
47
|
+
(posts, datetime.now().timestamp()),
|
|
48
|
+
expire=self.ttl_seconds,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def clear(self) -> None:
|
|
52
|
+
"""Clear all cached data."""
|
|
53
|
+
self._cache.clear()
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close the cache."""
|
|
57
|
+
self._cache.close()
|
linkgnome/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""CLI entry point for LinkGnome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from linkgnome.cache import FeedCache
|
|
9
|
+
from linkgnome.config import ConfigManager
|
|
10
|
+
from linkgnome.db import LinkgnomeDB
|
|
11
|
+
from linkgnome.setup import run_setup
|
|
12
|
+
from linkgnome.tui import run_tui
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def main(ctx: click.Context) -> None:
|
|
20
|
+
"""LinkGnome - Terminal-based link aggregator for social feeds."""
|
|
21
|
+
ctx.ensure_object(dict)
|
|
22
|
+
ctx.obj["config_manager"] = ConfigManager()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@main.command()
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def setup(ctx: click.Context) -> None:
|
|
28
|
+
"""Walk through interactive configuration."""
|
|
29
|
+
config_manager = ctx.obj["config_manager"]
|
|
30
|
+
run_setup(config_manager)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@main.command()
|
|
34
|
+
@click.option("--period", type=str, default=None, help="Time period (e.g., 24h, 7d)")
|
|
35
|
+
@click.option("--page", type=int, default=1, help="Page number (10 items per page)")
|
|
36
|
+
@click.option(
|
|
37
|
+
"--limit", type=int, default=None, help="Number of links per page (default: 10)"
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--platform",
|
|
41
|
+
type=click.Choice(["mastodon", "bluesky"]),
|
|
42
|
+
default=None,
|
|
43
|
+
help="Filter by platform",
|
|
44
|
+
)
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def fetch(
|
|
47
|
+
ctx: click.Context, period: str | None, page: int, limit: int | None, platform: str | None
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Fetch and display ranked links from your feeds."""
|
|
50
|
+
config_manager = ctx.obj["config_manager"]
|
|
51
|
+
settings = config_manager.get()
|
|
52
|
+
|
|
53
|
+
if not settings.mastodon.enabled and not settings.bluesky.enabled:
|
|
54
|
+
console.print(
|
|
55
|
+
"[bold yellow]No platforms configured. Run 'linkgnome setup' first.[/bold yellow]"
|
|
56
|
+
)
|
|
57
|
+
raise click.Abort()
|
|
58
|
+
|
|
59
|
+
hours = _parse_period(period) if period else settings.period_hours
|
|
60
|
+
page_size = limit or 10
|
|
61
|
+
run_tui(settings, hours=hours, page=page, page_size=page_size, platform_filter=platform)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@main.command()
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def config(ctx: click.Context) -> None:
|
|
67
|
+
"""Show current configuration."""
|
|
68
|
+
config_manager = ctx.obj["config_manager"]
|
|
69
|
+
settings = config_manager.get()
|
|
70
|
+
|
|
71
|
+
console.print("[bold]LinkGnome Configuration[/bold]\n")
|
|
72
|
+
|
|
73
|
+
console.print("[bold]Mastodon:[/bold]")
|
|
74
|
+
if settings.mastodon.enabled:
|
|
75
|
+
console.print(f" Instance: {settings.mastodon.instance_url}")
|
|
76
|
+
console.print(" Status: [green]Configured[/green]")
|
|
77
|
+
else:
|
|
78
|
+
console.print(" Status: [red]Not configured[/red]")
|
|
79
|
+
|
|
80
|
+
console.print("\n[bold]Bluesky:[/bold]")
|
|
81
|
+
if settings.bluesky.enabled:
|
|
82
|
+
console.print(f" Handle: {settings.bluesky.handle}")
|
|
83
|
+
console.print(" Status: [green]Configured[/green]")
|
|
84
|
+
else:
|
|
85
|
+
console.print(" Status: [red]Not configured[/red]")
|
|
86
|
+
|
|
87
|
+
console.print("\n[bold]Settings:[/bold]")
|
|
88
|
+
console.print(f" Period: {settings.period_hours} hours")
|
|
89
|
+
console.print(f" Page size: {settings.page_size} items")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@main.command()
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def clear_cache(ctx: click.Context) -> None:
|
|
95
|
+
"""Clear all cached data."""
|
|
96
|
+
console = Console()
|
|
97
|
+
|
|
98
|
+
# Clear FeedCache
|
|
99
|
+
feed_cache = FeedCache()
|
|
100
|
+
feed_cache.clear()
|
|
101
|
+
feed_cache.close()
|
|
102
|
+
|
|
103
|
+
# Clear database old posts
|
|
104
|
+
db = LinkgnomeDB()
|
|
105
|
+
deleted_count = db.clear_old_posts(0) # Clear all posts
|
|
106
|
+
db.close()
|
|
107
|
+
|
|
108
|
+
console.print("[green]Cache cleared successfully![/green]")
|
|
109
|
+
console.print(f"[yellow]Removed {deleted_count} old posts from database[/yellow]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_period(period_str: str) -> int:
|
|
113
|
+
"""Parse period string like '24h' or '7d' into hours."""
|
|
114
|
+
period_str = period_str.strip().lower()
|
|
115
|
+
if period_str.endswith("h"):
|
|
116
|
+
return int(period_str[:-1])
|
|
117
|
+
elif period_str.endswith("d"):
|
|
118
|
+
return int(period_str[:-1]) * 24
|
|
119
|
+
else:
|
|
120
|
+
return int(period_str)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
linkgnome/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Configuration management for LinkGnome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from pydantic_settings import (
|
|
10
|
+
BaseSettings,
|
|
11
|
+
PydanticBaseSettingsSource,
|
|
12
|
+
SettingsConfigDict,
|
|
13
|
+
TomlConfigSettingsSource,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "linkgnome"
|
|
17
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "config.toml"
|
|
18
|
+
DEFAULT_PERIOD_HOURS = 24
|
|
19
|
+
DEFAULT_PAGE_SIZE = 42
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MastodonConfig(BaseModel):
|
|
23
|
+
"""Mastodon configuration."""
|
|
24
|
+
|
|
25
|
+
enabled: bool = False
|
|
26
|
+
instance_url: str = ""
|
|
27
|
+
client_id: str = ""
|
|
28
|
+
client_secret: str = ""
|
|
29
|
+
access_token: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BlueskyConfig(BaseModel):
|
|
33
|
+
"""Bluesky configuration."""
|
|
34
|
+
|
|
35
|
+
enabled: bool = False
|
|
36
|
+
handle: str = ""
|
|
37
|
+
app_password: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LinkgnomeSettings(BaseSettings):
|
|
41
|
+
"""Main settings for LinkGnome."""
|
|
42
|
+
|
|
43
|
+
model_config = SettingsConfigDict(
|
|
44
|
+
env_prefix="LINKGNOME_",
|
|
45
|
+
env_file=".env",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
mastodon: MastodonConfig = Field(default_factory=MastodonConfig)
|
|
49
|
+
bluesky: BlueskyConfig = Field(default_factory=BlueskyConfig)
|
|
50
|
+
period_hours: int = DEFAULT_PERIOD_HOURS
|
|
51
|
+
page_size: int = DEFAULT_PAGE_SIZE
|
|
52
|
+
cache_ttl_seconds: int = 600
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def settings_customise_sources(
|
|
56
|
+
cls,
|
|
57
|
+
settings_cls: type[BaseSettings],
|
|
58
|
+
init_settings: PydanticBaseSettingsSource,
|
|
59
|
+
env_settings: PydanticBaseSettingsSource,
|
|
60
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
61
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
62
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
63
|
+
return (
|
|
64
|
+
init_settings,
|
|
65
|
+
file_secret_settings,
|
|
66
|
+
env_settings,
|
|
67
|
+
dotenv_settings,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ConfigManager:
|
|
72
|
+
"""Manages loading and saving configuration."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, config_path: Path | None = None):
|
|
75
|
+
self.config_path = config_path or DEFAULT_CONFIG_FILE
|
|
76
|
+
self._settings: LinkgnomeSettings | None = None
|
|
77
|
+
|
|
78
|
+
def load(self) -> LinkgnomeSettings:
|
|
79
|
+
"""Load settings from config file."""
|
|
80
|
+
if self._settings is not None:
|
|
81
|
+
return self._settings
|
|
82
|
+
|
|
83
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
if not self.config_path.exists():
|
|
86
|
+
self._settings = LinkgnomeSettings()
|
|
87
|
+
return self._settings
|
|
88
|
+
|
|
89
|
+
class TomlSettings(LinkgnomeSettings):
|
|
90
|
+
model_config = SettingsConfigDict(
|
|
91
|
+
env_prefix="LINKGNOME_",
|
|
92
|
+
env_file=".env",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def settings_customise_sources(
|
|
97
|
+
cls,
|
|
98
|
+
settings_cls: type[BaseSettings],
|
|
99
|
+
init_settings: PydanticBaseSettingsSource,
|
|
100
|
+
env_settings: PydanticBaseSettingsSource,
|
|
101
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
102
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
103
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
104
|
+
return (
|
|
105
|
+
TomlConfigSettingsSource(settings_cls, cls.config_path),
|
|
106
|
+
env_settings,
|
|
107
|
+
dotenv_settings,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def config_path(self) -> Path:
|
|
112
|
+
return Path(_get_toml_path())
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
toml_data = _parse_toml_file(self.config_path)
|
|
116
|
+
self._settings = LinkgnomeSettings(**toml_data)
|
|
117
|
+
except Exception:
|
|
118
|
+
self._settings = LinkgnomeSettings()
|
|
119
|
+
|
|
120
|
+
return self._settings
|
|
121
|
+
|
|
122
|
+
def save(self, settings: LinkgnomeSettings) -> None:
|
|
123
|
+
"""Save settings to config file."""
|
|
124
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
data = settings.model_dump(mode="json")
|
|
126
|
+
toml_content = _dict_to_toml(data)
|
|
127
|
+
self.config_path.write_text(toml_content)
|
|
128
|
+
self._settings = settings
|
|
129
|
+
|
|
130
|
+
def get(self) -> LinkgnomeSettings:
|
|
131
|
+
"""Get current settings, loading if necessary."""
|
|
132
|
+
if self._settings is None:
|
|
133
|
+
self.load()
|
|
134
|
+
return self._settings
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get_toml_path() -> str:
|
|
138
|
+
return str(DEFAULT_CONFIG_FILE)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_toml_file(path: Path) -> dict[str, Any]:
|
|
142
|
+
"""Parse a TOML file manually (minimal implementation)."""
|
|
143
|
+
import tomllib
|
|
144
|
+
|
|
145
|
+
with open(path, "rb") as f:
|
|
146
|
+
return tomllib.load(f)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _dict_to_toml(data: dict[str, Any], indent: int = 0) -> str:
|
|
150
|
+
"""Convert a dict to TOML string."""
|
|
151
|
+
lines = []
|
|
152
|
+
prefix = " " * indent
|
|
153
|
+
|
|
154
|
+
for key, value in data.items():
|
|
155
|
+
if isinstance(value, dict):
|
|
156
|
+
lines.append(f"{prefix}[{key}]")
|
|
157
|
+
lines.append(_dict_to_toml(value, indent))
|
|
158
|
+
lines.append("")
|
|
159
|
+
elif isinstance(value, bool):
|
|
160
|
+
lines.append(f"{prefix}{key} = {str(value).lower()}")
|
|
161
|
+
elif isinstance(value, int):
|
|
162
|
+
lines.append(f"{prefix}{key} = {value}")
|
|
163
|
+
elif isinstance(value, str):
|
|
164
|
+
escaped = value.replace('"', '\\"')
|
|
165
|
+
lines.append(f'{prefix}{key} = "{escaped}"')
|
|
166
|
+
else:
|
|
167
|
+
lines.append(f"{prefix}{key} = {value!r}")
|
|
168
|
+
|
|
169
|
+
return "\n".join(lines)
|
linkgnome/db.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""SQLite database layer for LinkGnome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from linkgnome.fetchers.base import Platform, Post
|
|
12
|
+
|
|
13
|
+
DB_DIR = Path.home() / ".local" / "share" / "linkgnome"
|
|
14
|
+
DB_PATH = DB_DIR / "linkgnome.db"
|
|
15
|
+
|
|
16
|
+
SCHEMA = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
platform TEXT NOT NULL,
|
|
20
|
+
author TEXT NOT NULL,
|
|
21
|
+
author_display_name TEXT,
|
|
22
|
+
content TEXT,
|
|
23
|
+
created_at TEXT NOT NULL,
|
|
24
|
+
is_boost INTEGER DEFAULT 0,
|
|
25
|
+
original_post_id TEXT,
|
|
26
|
+
boosted_by TEXT,
|
|
27
|
+
boost_count INTEGER DEFAULT 0,
|
|
28
|
+
like_count INTEGER DEFAULT 0,
|
|
29
|
+
raw_data TEXT
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS post_urls (
|
|
33
|
+
post_id TEXT NOT NULL,
|
|
34
|
+
url TEXT NOT NULL,
|
|
35
|
+
PRIMARY KEY (post_id, url),
|
|
36
|
+
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS url_metadata (
|
|
40
|
+
url TEXT PRIMARY KEY,
|
|
41
|
+
title TEXT,
|
|
42
|
+
status_code INTEGER,
|
|
43
|
+
fetched_at TEXT,
|
|
44
|
+
final_url TEXT
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
48
|
+
platform TEXT NOT NULL,
|
|
49
|
+
handle TEXT NOT NULL,
|
|
50
|
+
display_name TEXT,
|
|
51
|
+
bio TEXT,
|
|
52
|
+
avatar_url TEXT,
|
|
53
|
+
last_updated TEXT,
|
|
54
|
+
PRIMARY KEY (platform, handle)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_post_urls_post ON post_urls(post_id);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_post_urls_url ON post_urls(url);
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LinkgnomeDB:
|
|
65
|
+
"""SQLite-backed storage for posts, URLs, metadata, and profiles."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, db_path: Path | None = None):
|
|
68
|
+
self.db_path = db_path or DB_PATH
|
|
69
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self._conn: sqlite3.Connection | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def conn(self) -> sqlite3.Connection:
|
|
74
|
+
if self._conn is None:
|
|
75
|
+
self._conn = sqlite3.connect(str(self.db_path))
|
|
76
|
+
self._conn.row_factory = sqlite3.Row
|
|
77
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
78
|
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
79
|
+
self._conn.executescript(SCHEMA)
|
|
80
|
+
self._conn.commit()
|
|
81
|
+
return self._conn
|
|
82
|
+
|
|
83
|
+
def save_posts(self, posts: list[Post]) -> int:
|
|
84
|
+
"""Batch insert posts. Returns count of new posts added."""
|
|
85
|
+
conn = self.conn
|
|
86
|
+
rows_added = 0
|
|
87
|
+
for post in posts:
|
|
88
|
+
existing = conn.execute(
|
|
89
|
+
"SELECT id FROM posts WHERE id = ?", (post.id,)
|
|
90
|
+
).fetchone()
|
|
91
|
+
if existing:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
conn.execute(
|
|
95
|
+
"""INSERT INTO posts
|
|
96
|
+
(id, platform, author, author_display_name, content,
|
|
97
|
+
created_at, is_boost, original_post_id, boosted_by,
|
|
98
|
+
boost_count, like_count, raw_data)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
100
|
+
(
|
|
101
|
+
post.id,
|
|
102
|
+
post.platform.value,
|
|
103
|
+
post.author,
|
|
104
|
+
post.author_display_name,
|
|
105
|
+
post.content,
|
|
106
|
+
post.created_at.isoformat(),
|
|
107
|
+
int(post.is_boost),
|
|
108
|
+
post.original_post_id,
|
|
109
|
+
post.boosted_by,
|
|
110
|
+
post.boost_count,
|
|
111
|
+
post.like_count,
|
|
112
|
+
json.dumps(post.raw_data) if post.raw_data else None,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
for url in post.urls:
|
|
117
|
+
# Normalize URLs to prevent truncation issues
|
|
118
|
+
# Remove trailing ellipsis that might be present in Bluesky truncated URLs
|
|
119
|
+
normalized_url = url.rstrip('...')
|
|
120
|
+
conn.execute(
|
|
121
|
+
"INSERT OR IGNORE INTO post_urls (post_id, url) VALUES (?, ?)",
|
|
122
|
+
(post.id, normalized_url),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
rows_added += 1
|
|
126
|
+
|
|
127
|
+
conn.commit()
|
|
128
|
+
return rows_added
|
|
129
|
+
|
|
130
|
+
def load_posts(
|
|
131
|
+
self,
|
|
132
|
+
platform: str | None = None,
|
|
133
|
+
since: datetime | None = None,
|
|
134
|
+
limit: int | None = None,
|
|
135
|
+
) -> list[Post]:
|
|
136
|
+
"""Load posts with their URLs."""
|
|
137
|
+
query = """
|
|
138
|
+
SELECT p.*, GROUP_CONCAT(DISTINCT pu.url) as urls
|
|
139
|
+
FROM posts p
|
|
140
|
+
LEFT JOIN post_urls pu ON p.id = pu.post_id
|
|
141
|
+
"""
|
|
142
|
+
params: list[Any] = []
|
|
143
|
+
where_clauses = []
|
|
144
|
+
|
|
145
|
+
if platform:
|
|
146
|
+
where_clauses.append("p.platform = ?")
|
|
147
|
+
params.append(platform)
|
|
148
|
+
if since:
|
|
149
|
+
where_clauses.append("p.created_at >= ?")
|
|
150
|
+
params.append(since.isoformat())
|
|
151
|
+
|
|
152
|
+
if where_clauses:
|
|
153
|
+
query += " WHERE " + " AND ".join(where_clauses)
|
|
154
|
+
|
|
155
|
+
query += " GROUP BY p.id"
|
|
156
|
+
|
|
157
|
+
if limit:
|
|
158
|
+
query += " LIMIT ?"
|
|
159
|
+
params.append(limit)
|
|
160
|
+
|
|
161
|
+
rows = self.conn.execute(query, params).fetchall()
|
|
162
|
+
posts = []
|
|
163
|
+
for row in rows:
|
|
164
|
+
urls_str = row["urls"]
|
|
165
|
+
# Handle case where URL might contain commas or be malformed
|
|
166
|
+
if urls_str:
|
|
167
|
+
# Split by comma but be careful about URLs that might contain commas
|
|
168
|
+
urls = [url.strip() for url in urls_str.split(',')]
|
|
169
|
+
else:
|
|
170
|
+
urls = []
|
|
171
|
+
# Clean up any truncated URLs that may have been saved
|
|
172
|
+
cleaned_urls = [url.rstrip('...') for url in urls]
|
|
173
|
+
posts.append(
|
|
174
|
+
Post(
|
|
175
|
+
id=row["id"],
|
|
176
|
+
platform=Platform(row["platform"]),
|
|
177
|
+
author=row["author"],
|
|
178
|
+
author_display_name=row["author_display_name"] or "",
|
|
179
|
+
content=row["content"] or "",
|
|
180
|
+
urls=cleaned_urls,
|
|
181
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
182
|
+
is_boost=bool(row["is_boost"]),
|
|
183
|
+
original_post_id=row["original_post_id"],
|
|
184
|
+
boosted_by=row["boosted_by"],
|
|
185
|
+
boost_count=row["boost_count"],
|
|
186
|
+
like_count=row["like_count"],
|
|
187
|
+
raw_data=json.loads(row["raw_data"]) if row["raw_data"] else None,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return posts
|
|
191
|
+
|
|
192
|
+
def count_posts(self, since: datetime | None = None) -> int:
|
|
193
|
+
"""Count posts matching criteria."""
|
|
194
|
+
query = "SELECT COUNT(*) as cnt FROM posts"
|
|
195
|
+
params: list[Any] = []
|
|
196
|
+
if since:
|
|
197
|
+
query += " WHERE created_at >= ?"
|
|
198
|
+
params.append(since.isoformat())
|
|
199
|
+
row = self.conn.execute(query, params).fetchone()
|
|
200
|
+
return row["cnt"] if row else 0
|
|
201
|
+
|
|
202
|
+
def clear_old_posts(self, keep_hours: int = 24) -> int:
|
|
203
|
+
"""Remove posts older than the specified window. Returns count deleted."""
|
|
204
|
+
cutoff = (
|
|
205
|
+
datetime.now(timezone.utc) - timedelta(hours=keep_hours)
|
|
206
|
+
).isoformat()
|
|
207
|
+
cursor = self.conn.execute(
|
|
208
|
+
"DELETE FROM posts WHERE created_at < ?", (cutoff,)
|
|
209
|
+
)
|
|
210
|
+
self.conn.execute(
|
|
211
|
+
"DELETE FROM post_urls WHERE post_id NOT IN (SELECT id FROM posts)"
|
|
212
|
+
)
|
|
213
|
+
self.conn.commit()
|
|
214
|
+
return cursor.rowcount
|
|
215
|
+
|
|
216
|
+
def save_url_metadata(
|
|
217
|
+
self,
|
|
218
|
+
url: str,
|
|
219
|
+
title: str | None,
|
|
220
|
+
status_code: int,
|
|
221
|
+
final_url: str | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Cache fetched metadata for a URL."""
|
|
224
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
225
|
+
self.conn.execute(
|
|
226
|
+
"""INSERT OR REPLACE INTO url_metadata
|
|
227
|
+
(url, title, status_code, fetched_at, final_url)
|
|
228
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
229
|
+
(url, title, status_code, now, final_url),
|
|
230
|
+
)
|
|
231
|
+
self.conn.commit()
|
|
232
|
+
|
|
233
|
+
def get_url_metadata(self, url: str) -> dict[str, Any] | None:
|
|
234
|
+
"""Get cached metadata for a URL."""
|
|
235
|
+
row = self.conn.execute(
|
|
236
|
+
"SELECT * FROM url_metadata WHERE url = ?", (url,)
|
|
237
|
+
).fetchone()
|
|
238
|
+
if row is None:
|
|
239
|
+
return None
|
|
240
|
+
return {
|
|
241
|
+
"title": row["title"],
|
|
242
|
+
"status_code": row["status_code"],
|
|
243
|
+
"fetched_at": row["fetched_at"],
|
|
244
|
+
"final_url": row["final_url"],
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def save_profile(
|
|
248
|
+
self,
|
|
249
|
+
platform: str,
|
|
250
|
+
handle: str,
|
|
251
|
+
display_name: str | None = None,
|
|
252
|
+
bio: str | None = None,
|
|
253
|
+
avatar_url: str | None = None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Save or update a profile."""
|
|
256
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
257
|
+
self.conn.execute(
|
|
258
|
+
"""INSERT OR REPLACE INTO profiles
|
|
259
|
+
(platform, handle, display_name, bio, avatar_url, last_updated)
|
|
260
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
261
|
+
(platform, handle, display_name, bio, avatar_url, now),
|
|
262
|
+
)
|
|
263
|
+
self.conn.commit()
|
|
264
|
+
|
|
265
|
+
def get_profile(self, platform: str, handle: str) -> dict[str, Any] | None:
|
|
266
|
+
"""Get profile data."""
|
|
267
|
+
row = self.conn.execute(
|
|
268
|
+
"SELECT * FROM profiles WHERE platform = ? AND handle = ?",
|
|
269
|
+
(platform, handle),
|
|
270
|
+
).fetchone()
|
|
271
|
+
if row is None:
|
|
272
|
+
return None
|
|
273
|
+
return dict(row)
|
|
274
|
+
|
|
275
|
+
def close(self) -> None:
|
|
276
|
+
if self._conn:
|
|
277
|
+
self._conn.close()
|
|
278
|
+
self._conn = None
|
|
File without changes
|