ForageFacebook 1.0.0__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.
- forage/__init__.py +3 -0
- forage/auth.py +134 -0
- forage/cli.py +310 -0
- forage/exporter.py +222 -0
- forage/models.py +74 -0
- forage/parser.py +562 -0
- forage/scraper.py +600 -0
- foragefacebook-1.0.0.dist-info/METADATA +337 -0
- foragefacebook-1.0.0.dist-info/RECORD +12 -0
- foragefacebook-1.0.0.dist-info/WHEEL +4 -0
- foragefacebook-1.0.0.dist-info/entry_points.txt +2 -0
- foragefacebook-1.0.0.dist-info/licenses/LICENSE +373 -0
forage/__init__.py
ADDED
forage/auth.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Authentication and session management for Facebook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console(stderr=True)
|
|
13
|
+
|
|
14
|
+
DEFAULT_SESSION_DIR = Path.home() / ".config" / "forage" / "session"
|
|
15
|
+
STORAGE_STATE_FILE = "storage_state.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_session_path(session_dir: Optional[Path] = None) -> Path:
|
|
19
|
+
"""Get the path to the session storage file."""
|
|
20
|
+
base_dir = session_dir or DEFAULT_SESSION_DIR
|
|
21
|
+
return base_dir / STORAGE_STATE_FILE
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def session_exists(session_dir: Optional[Path] = None) -> bool:
|
|
25
|
+
"""Check if a saved session exists."""
|
|
26
|
+
return get_session_path(session_dir).exists()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def login(
|
|
30
|
+
session_dir: Optional[Path] = None,
|
|
31
|
+
browser_type: str = "chromium",
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Open a browser for interactive Facebook login.
|
|
35
|
+
|
|
36
|
+
The user logs in manually, then presses Enter to save the session.
|
|
37
|
+
"""
|
|
38
|
+
session_path = get_session_path(session_dir)
|
|
39
|
+
session_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
console.print("[bold]Opening browser for Facebook login...[/bold]")
|
|
42
|
+
console.print("Please log into Facebook in the browser window.")
|
|
43
|
+
console.print("Once logged in, press [bold green]Enter[/bold green] here to save your session.")
|
|
44
|
+
|
|
45
|
+
with sync_playwright() as p:
|
|
46
|
+
browser = getattr(p, browser_type).launch(headless=False)
|
|
47
|
+
context = browser.new_context()
|
|
48
|
+
page = context.new_page()
|
|
49
|
+
|
|
50
|
+
page.goto("https://www.facebook.com/login")
|
|
51
|
+
|
|
52
|
+
input()
|
|
53
|
+
|
|
54
|
+
if is_logged_in_page(page):
|
|
55
|
+
context.storage_state(path=str(session_path))
|
|
56
|
+
console.print("[bold green]Session saved successfully![/bold green]")
|
|
57
|
+
else:
|
|
58
|
+
console.print("[bold red]Login not detected. Please try again.[/bold red]")
|
|
59
|
+
raise SystemExit(3)
|
|
60
|
+
|
|
61
|
+
browser.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_context(
|
|
65
|
+
browser: Browser,
|
|
66
|
+
session_dir: Optional[Path] = None,
|
|
67
|
+
) -> BrowserContext:
|
|
68
|
+
"""Load a browser context with saved session state."""
|
|
69
|
+
session_path = get_session_path(session_dir)
|
|
70
|
+
|
|
71
|
+
if session_path.exists():
|
|
72
|
+
return browser.new_context(storage_state=str(session_path))
|
|
73
|
+
else:
|
|
74
|
+
return browser.new_context()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_logged_in_page(page: Page) -> bool:
|
|
78
|
+
"""Check if the current page shows a logged-in state."""
|
|
79
|
+
try:
|
|
80
|
+
page.goto("https://www.facebook.com", timeout=10000, wait_until="domcontentloaded")
|
|
81
|
+
page.wait_for_timeout(2000)
|
|
82
|
+
|
|
83
|
+
logged_in_indicators = [
|
|
84
|
+
'[aria-label="Your profile"]',
|
|
85
|
+
'[aria-label="Account"]',
|
|
86
|
+
'[data-pagelet="ProfileTilesFeed"]',
|
|
87
|
+
'div[role="navigation"]',
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
for selector in logged_in_indicators:
|
|
91
|
+
if page.query_selector(selector):
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
login_indicators = [
|
|
95
|
+
'input[name="email"]',
|
|
96
|
+
'input[name="pass"]',
|
|
97
|
+
'button[name="login"]',
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
for selector in login_indicators:
|
|
101
|
+
if page.query_selector(selector):
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_logged_in(
|
|
111
|
+
session_dir: Optional[Path] = None,
|
|
112
|
+
browser_type: str = "chromium",
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Check if the saved session is still valid."""
|
|
115
|
+
if not session_exists(session_dir):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
with sync_playwright() as p:
|
|
119
|
+
browser = getattr(p, browser_type).launch(headless=True)
|
|
120
|
+
context = load_context(browser, session_dir)
|
|
121
|
+
page = context.new_page()
|
|
122
|
+
|
|
123
|
+
result = is_logged_in_page(page)
|
|
124
|
+
|
|
125
|
+
browser.close()
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def clear_session(session_dir: Optional[Path] = None) -> None:
|
|
130
|
+
"""Remove saved session data."""
|
|
131
|
+
session_path = get_session_path(session_dir)
|
|
132
|
+
if session_path.exists():
|
|
133
|
+
session_path.unlink()
|
|
134
|
+
console.print("Session cleared.")
|
forage/cli.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""CLI interface for forage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from forage import __version__
|
|
14
|
+
from forage.auth import (
|
|
15
|
+
DEFAULT_SESSION_DIR,
|
|
16
|
+
is_logged_in,
|
|
17
|
+
login as auth_login,
|
|
18
|
+
session_exists,
|
|
19
|
+
)
|
|
20
|
+
from forage.scraper import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
GroupNotFoundError,
|
|
23
|
+
ScrapeOptions,
|
|
24
|
+
scrape_group,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
console = Console(stderr=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Context:
|
|
31
|
+
"""Shared context for CLI commands."""
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.verbose = False
|
|
35
|
+
self.quiet = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
pass_context = click.make_pass_decorator(Context, ensure=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group()
|
|
42
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show progress and debug info")
|
|
43
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress non-error output")
|
|
44
|
+
@click.option("--no-color", is_flag=True, help="Disable colored output")
|
|
45
|
+
@click.version_option(version=__version__)
|
|
46
|
+
@pass_context
|
|
47
|
+
def main(ctx: Context, verbose: bool, quiet: bool, no_color: bool):
|
|
48
|
+
"""Scrape posts, comments, and reactions from private Facebook groups."""
|
|
49
|
+
ctx.verbose = verbose
|
|
50
|
+
ctx.quiet = quiet
|
|
51
|
+
|
|
52
|
+
if no_color:
|
|
53
|
+
console.no_color = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@main.command()
|
|
57
|
+
@click.option(
|
|
58
|
+
"--browser",
|
|
59
|
+
type=click.Choice(["chromium", "firefox", "webkit"]),
|
|
60
|
+
default="chromium",
|
|
61
|
+
help="Browser to use for login",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--session-dir",
|
|
65
|
+
type=click.Path(path_type=Path),
|
|
66
|
+
default=None,
|
|
67
|
+
help="Directory to store session data",
|
|
68
|
+
)
|
|
69
|
+
@pass_context
|
|
70
|
+
def login(ctx: Context, browser: str, session_dir: Optional[Path]):
|
|
71
|
+
"""
|
|
72
|
+
Open browser for interactive Facebook login.
|
|
73
|
+
|
|
74
|
+
Opens a browser window where you can log into Facebook.
|
|
75
|
+
Once logged in, press Enter in the terminal to save your session.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
auth_login(session_dir=session_dir, browser_type=browser)
|
|
79
|
+
except SystemExit:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as e:
|
|
82
|
+
if not ctx.quiet:
|
|
83
|
+
console.print(f"[red]Login failed: {e}[/red]")
|
|
84
|
+
raise SystemExit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@main.command()
|
|
88
|
+
@click.argument("group")
|
|
89
|
+
@click.option(
|
|
90
|
+
"--days",
|
|
91
|
+
type=int,
|
|
92
|
+
default=7,
|
|
93
|
+
help="Scrape posts from the last N days (default: 7)",
|
|
94
|
+
)
|
|
95
|
+
@click.option(
|
|
96
|
+
"--since",
|
|
97
|
+
type=str,
|
|
98
|
+
default=None,
|
|
99
|
+
help="Scrape posts since this date (ISO 8601: YYYY-MM-DD)",
|
|
100
|
+
)
|
|
101
|
+
@click.option(
|
|
102
|
+
"--until",
|
|
103
|
+
type=str,
|
|
104
|
+
default=None,
|
|
105
|
+
help="Scrape posts until this date (ISO 8601: YYYY-MM-DD)",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--limit",
|
|
109
|
+
type=int,
|
|
110
|
+
default=0,
|
|
111
|
+
help="Maximum number of posts to fetch (0 = no limit)",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--delay",
|
|
115
|
+
type=float,
|
|
116
|
+
default=2.0,
|
|
117
|
+
help="Seconds to wait between page loads (rate limiting)",
|
|
118
|
+
)
|
|
119
|
+
@click.option(
|
|
120
|
+
"--min-reactions",
|
|
121
|
+
type=int,
|
|
122
|
+
default=0,
|
|
123
|
+
help="Only include comments with at least N reactions",
|
|
124
|
+
)
|
|
125
|
+
@click.option(
|
|
126
|
+
"--top-comments",
|
|
127
|
+
type=int,
|
|
128
|
+
default=0,
|
|
129
|
+
help="Keep only the top N comments per post by reactions",
|
|
130
|
+
)
|
|
131
|
+
@click.option(
|
|
132
|
+
"--skip-comments",
|
|
133
|
+
is_flag=True,
|
|
134
|
+
help="Skip fetching comments entirely",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--skip-reactions",
|
|
138
|
+
is_flag=True,
|
|
139
|
+
help="Skip fetching reaction counts",
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"-o",
|
|
143
|
+
"--output",
|
|
144
|
+
type=click.Path(path_type=Path),
|
|
145
|
+
default=None,
|
|
146
|
+
help="Write output to file instead of stdout",
|
|
147
|
+
)
|
|
148
|
+
@click.option(
|
|
149
|
+
"-f",
|
|
150
|
+
"--format",
|
|
151
|
+
"output_format",
|
|
152
|
+
type=click.Choice(["json", "sqlite", "csv"]),
|
|
153
|
+
default="json",
|
|
154
|
+
help="Output format (default: json)",
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--session-dir",
|
|
158
|
+
type=click.Path(path_type=Path),
|
|
159
|
+
default=None,
|
|
160
|
+
help="Directory containing session data",
|
|
161
|
+
)
|
|
162
|
+
@click.option(
|
|
163
|
+
"--headless/--no-headless",
|
|
164
|
+
default=True,
|
|
165
|
+
help="Run browser headlessly (use --no-headless to watch)",
|
|
166
|
+
)
|
|
167
|
+
@click.option(
|
|
168
|
+
"--browser",
|
|
169
|
+
type=click.Choice(["chromium", "firefox", "webkit"]),
|
|
170
|
+
default="chromium",
|
|
171
|
+
help="Browser to use",
|
|
172
|
+
)
|
|
173
|
+
@click.option(
|
|
174
|
+
"--no-input",
|
|
175
|
+
is_flag=True,
|
|
176
|
+
help="Disable interactive prompts",
|
|
177
|
+
)
|
|
178
|
+
@pass_context
|
|
179
|
+
def scrape(
|
|
180
|
+
ctx: Context,
|
|
181
|
+
group: str,
|
|
182
|
+
days: int,
|
|
183
|
+
since: Optional[str],
|
|
184
|
+
until: Optional[str],
|
|
185
|
+
limit: int,
|
|
186
|
+
delay: float,
|
|
187
|
+
min_reactions: int,
|
|
188
|
+
top_comments: int,
|
|
189
|
+
skip_comments: bool,
|
|
190
|
+
skip_reactions: bool,
|
|
191
|
+
output: Optional[Path],
|
|
192
|
+
output_format: str,
|
|
193
|
+
session_dir: Optional[Path],
|
|
194
|
+
headless: bool,
|
|
195
|
+
browser: str,
|
|
196
|
+
no_input: bool,
|
|
197
|
+
):
|
|
198
|
+
"""
|
|
199
|
+
Scrape posts from a Facebook group.
|
|
200
|
+
|
|
201
|
+
GROUP can be a full URL, group ID, group slug, or '-' to read from stdin.
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
|
|
205
|
+
forage scrape https://www.facebook.com/groups/mycityfoodies
|
|
206
|
+
|
|
207
|
+
forage scrape mycityfoodies --days 14
|
|
208
|
+
|
|
209
|
+
forage scrape 123456789 --since 2024-01-01 --until 2024-01-15
|
|
210
|
+
|
|
211
|
+
echo "mycityfoodies" | forage scrape -
|
|
212
|
+
"""
|
|
213
|
+
# Handle stdin input
|
|
214
|
+
if group == "-":
|
|
215
|
+
if sys.stdin.isatty():
|
|
216
|
+
console.print("[red]No input provided on stdin[/red]")
|
|
217
|
+
raise SystemExit(2)
|
|
218
|
+
group = sys.stdin.read().strip()
|
|
219
|
+
if not group:
|
|
220
|
+
console.print("[red]Empty input from stdin[/red]")
|
|
221
|
+
raise SystemExit(2)
|
|
222
|
+
# Take first non-empty line if multiple lines provided
|
|
223
|
+
group = next((line.strip() for line in group.splitlines() if line.strip()), "")
|
|
224
|
+
if not group:
|
|
225
|
+
console.print("[red]No valid group identifier in stdin[/red]")
|
|
226
|
+
raise SystemExit(2)
|
|
227
|
+
|
|
228
|
+
options = ScrapeOptions(
|
|
229
|
+
days=days,
|
|
230
|
+
since=since,
|
|
231
|
+
until=until,
|
|
232
|
+
limit=limit,
|
|
233
|
+
delay=delay,
|
|
234
|
+
skip_comments=skip_comments,
|
|
235
|
+
skip_reactions=skip_reactions,
|
|
236
|
+
min_reactions=min_reactions,
|
|
237
|
+
top_comments=top_comments,
|
|
238
|
+
headless=headless,
|
|
239
|
+
verbose=ctx.verbose,
|
|
240
|
+
session_dir=session_dir,
|
|
241
|
+
browser_type=browser,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if not session_exists(session_dir):
|
|
245
|
+
if not ctx.quiet:
|
|
246
|
+
console.print("[yellow]No saved session found.[/yellow]")
|
|
247
|
+
|
|
248
|
+
if no_input or not sys.stdin.isatty():
|
|
249
|
+
console.print("[red]Please run 'forage login' first.[/red]")
|
|
250
|
+
raise SystemExit(3)
|
|
251
|
+
|
|
252
|
+
if click.confirm("Would you like to log in now?", default=True):
|
|
253
|
+
auth_login(session_dir=session_dir, browser_type=browser)
|
|
254
|
+
else:
|
|
255
|
+
raise SystemExit(3)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
result = scrape_group(group, options)
|
|
259
|
+
except AuthenticationError:
|
|
260
|
+
if not ctx.quiet:
|
|
261
|
+
console.print("[yellow]Session expired or invalid.[/yellow]")
|
|
262
|
+
|
|
263
|
+
if no_input or not sys.stdin.isatty():
|
|
264
|
+
console.print("[red]Please run 'forage login' to refresh your session.[/red]")
|
|
265
|
+
raise SystemExit(3)
|
|
266
|
+
|
|
267
|
+
if click.confirm("Session expired. Re-login?", default=True):
|
|
268
|
+
auth_login(session_dir=session_dir, browser_type=browser)
|
|
269
|
+
result = scrape_group(group, options)
|
|
270
|
+
else:
|
|
271
|
+
raise SystemExit(3)
|
|
272
|
+
except GroupNotFoundError as e:
|
|
273
|
+
console.print(f"[red]Group not found or access denied: {e}[/red]")
|
|
274
|
+
raise SystemExit(4)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
277
|
+
raise SystemExit(1)
|
|
278
|
+
|
|
279
|
+
if output_format == "sqlite":
|
|
280
|
+
from forage.exporter import export_to_sqlite
|
|
281
|
+
|
|
282
|
+
if not output:
|
|
283
|
+
console.print("[red]SQLite format requires --output file path[/red]")
|
|
284
|
+
raise SystemExit(2)
|
|
285
|
+
export_to_sqlite(result, output)
|
|
286
|
+
if not ctx.quiet:
|
|
287
|
+
console.print(f"[green]Data exported to {output}[/green]")
|
|
288
|
+
elif output_format == "csv":
|
|
289
|
+
from forage.exporter import export_to_csv
|
|
290
|
+
|
|
291
|
+
if not output:
|
|
292
|
+
console.print("[red]CSV format requires --output file path[/red]")
|
|
293
|
+
raise SystemExit(2)
|
|
294
|
+
export_to_csv(result, output)
|
|
295
|
+
if not ctx.quiet:
|
|
296
|
+
comments_path = output.with_suffix(".comments.csv")
|
|
297
|
+
console.print(f"[green]Posts exported to {output}[/green]")
|
|
298
|
+
console.print(f"[green]Comments exported to {comments_path}[/green]")
|
|
299
|
+
else:
|
|
300
|
+
json_output = result.model_dump_json(indent=2)
|
|
301
|
+
if output:
|
|
302
|
+
output.write_text(json_output)
|
|
303
|
+
if not ctx.quiet:
|
|
304
|
+
console.print(f"[green]Output written to {output}[/green]")
|
|
305
|
+
else:
|
|
306
|
+
click.echo(json_output)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
main()
|
forage/exporter.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Export functionality for scrape results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import sqlite3
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from forage.models import ScrapeResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def export_to_csv(result: ScrapeResult, output_path: Path) -> None:
|
|
13
|
+
"""Export scrape result to CSV files.
|
|
14
|
+
|
|
15
|
+
Creates two files:
|
|
16
|
+
- <output_path>: posts (one row per post)
|
|
17
|
+
- <output_path>.comments.csv: comments (one row per comment)
|
|
18
|
+
"""
|
|
19
|
+
# Posts CSV
|
|
20
|
+
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
|
21
|
+
writer = csv.writer(f)
|
|
22
|
+
writer.writerow([
|
|
23
|
+
"post_id",
|
|
24
|
+
"author_name",
|
|
25
|
+
"author_profile_url",
|
|
26
|
+
"content",
|
|
27
|
+
"timestamp",
|
|
28
|
+
"reactions_total",
|
|
29
|
+
"comments_count",
|
|
30
|
+
"group_name",
|
|
31
|
+
"group_id",
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
for post in result.posts:
|
|
35
|
+
writer.writerow([
|
|
36
|
+
post.id,
|
|
37
|
+
post.author.name if post.author else "",
|
|
38
|
+
post.author.profile_url if post.author else "",
|
|
39
|
+
post.content or "",
|
|
40
|
+
post.timestamp.isoformat() if post.timestamp else "",
|
|
41
|
+
post.reactions.total if post.reactions else 0,
|
|
42
|
+
post.comments_count,
|
|
43
|
+
result.group.name,
|
|
44
|
+
result.group.id,
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
# Comments CSV (separate file)
|
|
48
|
+
comments_path = output_path.with_suffix(".comments.csv")
|
|
49
|
+
with open(comments_path, "w", newline="", encoding="utf-8") as f:
|
|
50
|
+
writer = csv.writer(f)
|
|
51
|
+
writer.writerow([
|
|
52
|
+
"comment_id",
|
|
53
|
+
"post_id",
|
|
54
|
+
"parent_comment_id",
|
|
55
|
+
"author_name",
|
|
56
|
+
"author_profile_url",
|
|
57
|
+
"content",
|
|
58
|
+
"timestamp",
|
|
59
|
+
"reactions_total",
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
def write_comment(comment, post_id, parent_id=""):
|
|
63
|
+
writer.writerow([
|
|
64
|
+
comment.id,
|
|
65
|
+
post_id,
|
|
66
|
+
parent_id,
|
|
67
|
+
comment.author.name if comment.author else "",
|
|
68
|
+
comment.author.profile_url if comment.author else "",
|
|
69
|
+
comment.content or "",
|
|
70
|
+
comment.timestamp.isoformat() if comment.timestamp else "",
|
|
71
|
+
comment.reactions.total if comment.reactions else 0,
|
|
72
|
+
])
|
|
73
|
+
for reply in comment.replies:
|
|
74
|
+
write_comment(reply, post_id, parent_id=comment.id)
|
|
75
|
+
|
|
76
|
+
for post in result.posts:
|
|
77
|
+
for comment in post.comments:
|
|
78
|
+
write_comment(comment, post.id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def export_to_sqlite(result: ScrapeResult, db_path: Path) -> None:
|
|
82
|
+
"""Export scrape result to SQLite database.
|
|
83
|
+
|
|
84
|
+
Creates tables for groups, posts, comments, and reactions.
|
|
85
|
+
If the database exists, appends to it (upserts based on IDs).
|
|
86
|
+
"""
|
|
87
|
+
conn = sqlite3.connect(db_path)
|
|
88
|
+
cursor = conn.cursor()
|
|
89
|
+
|
|
90
|
+
# Create tables
|
|
91
|
+
cursor.executescript("""
|
|
92
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
93
|
+
id TEXT PRIMARY KEY,
|
|
94
|
+
name TEXT NOT NULL,
|
|
95
|
+
url TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS posts (
|
|
99
|
+
id TEXT PRIMARY KEY,
|
|
100
|
+
group_id TEXT NOT NULL,
|
|
101
|
+
author_name TEXT,
|
|
102
|
+
author_profile_url TEXT,
|
|
103
|
+
content TEXT,
|
|
104
|
+
timestamp TEXT,
|
|
105
|
+
reactions_total INTEGER DEFAULT 0,
|
|
106
|
+
reactions_like INTEGER DEFAULT 0,
|
|
107
|
+
reactions_love INTEGER DEFAULT 0,
|
|
108
|
+
reactions_haha INTEGER DEFAULT 0,
|
|
109
|
+
reactions_wow INTEGER DEFAULT 0,
|
|
110
|
+
reactions_sad INTEGER DEFAULT 0,
|
|
111
|
+
reactions_angry INTEGER DEFAULT 0,
|
|
112
|
+
comments_count INTEGER DEFAULT 0,
|
|
113
|
+
scraped_at TEXT,
|
|
114
|
+
FOREIGN KEY (group_id) REFERENCES groups(id)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
118
|
+
id TEXT PRIMARY KEY,
|
|
119
|
+
post_id TEXT NOT NULL,
|
|
120
|
+
parent_comment_id TEXT,
|
|
121
|
+
author_name TEXT,
|
|
122
|
+
author_profile_url TEXT,
|
|
123
|
+
content TEXT,
|
|
124
|
+
timestamp TEXT,
|
|
125
|
+
reactions_total INTEGER DEFAULT 0,
|
|
126
|
+
reactions_like INTEGER DEFAULT 0,
|
|
127
|
+
reactions_love INTEGER DEFAULT 0,
|
|
128
|
+
reactions_haha INTEGER DEFAULT 0,
|
|
129
|
+
reactions_wow INTEGER DEFAULT 0,
|
|
130
|
+
reactions_sad INTEGER DEFAULT 0,
|
|
131
|
+
reactions_angry INTEGER DEFAULT 0,
|
|
132
|
+
FOREIGN KEY (post_id) REFERENCES posts(id),
|
|
133
|
+
FOREIGN KEY (parent_comment_id) REFERENCES comments(id)
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_posts_group ON posts(group_id);
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_posts_timestamp ON posts(timestamp);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_comment_id);
|
|
140
|
+
""")
|
|
141
|
+
|
|
142
|
+
# Insert group
|
|
143
|
+
cursor.execute(
|
|
144
|
+
"""
|
|
145
|
+
INSERT OR REPLACE INTO groups (id, name, url)
|
|
146
|
+
VALUES (?, ?, ?)
|
|
147
|
+
""",
|
|
148
|
+
(result.group.id, result.group.name, result.group.url),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
scraped_at_str = result.scraped_at.isoformat() if result.scraped_at else None
|
|
152
|
+
|
|
153
|
+
# Insert posts and comments
|
|
154
|
+
for post in result.posts:
|
|
155
|
+
timestamp_str = post.timestamp.isoformat() if post.timestamp else None
|
|
156
|
+
|
|
157
|
+
cursor.execute(
|
|
158
|
+
"""
|
|
159
|
+
INSERT OR REPLACE INTO posts (
|
|
160
|
+
id, group_id, author_name, author_profile_url, content,
|
|
161
|
+
timestamp, reactions_total, reactions_like, reactions_love,
|
|
162
|
+
reactions_haha, reactions_wow, reactions_sad, reactions_angry,
|
|
163
|
+
comments_count, scraped_at
|
|
164
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
165
|
+
""",
|
|
166
|
+
(
|
|
167
|
+
post.id,
|
|
168
|
+
result.group.id,
|
|
169
|
+
post.author.name if post.author else None,
|
|
170
|
+
post.author.profile_url if post.author else None,
|
|
171
|
+
post.content,
|
|
172
|
+
timestamp_str,
|
|
173
|
+
post.reactions.total if post.reactions else 0,
|
|
174
|
+
post.reactions.like if post.reactions else 0,
|
|
175
|
+
post.reactions.love if post.reactions else 0,
|
|
176
|
+
post.reactions.haha if post.reactions else 0,
|
|
177
|
+
post.reactions.wow if post.reactions else 0,
|
|
178
|
+
post.reactions.sad if post.reactions else 0,
|
|
179
|
+
post.reactions.angry if post.reactions else 0,
|
|
180
|
+
post.comments_count,
|
|
181
|
+
scraped_at_str,
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Insert comments recursively
|
|
186
|
+
def insert_comment(comment, parent_id=None):
|
|
187
|
+
comment_timestamp = comment.timestamp.isoformat() if comment.timestamp else None
|
|
188
|
+
cursor.execute(
|
|
189
|
+
"""
|
|
190
|
+
INSERT OR REPLACE INTO comments (
|
|
191
|
+
id, post_id, parent_comment_id, author_name, author_profile_url,
|
|
192
|
+
content, timestamp, reactions_total, reactions_like, reactions_love,
|
|
193
|
+
reactions_haha, reactions_wow, reactions_sad, reactions_angry
|
|
194
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
195
|
+
""",
|
|
196
|
+
(
|
|
197
|
+
comment.id,
|
|
198
|
+
post.id,
|
|
199
|
+
parent_id,
|
|
200
|
+
comment.author.name if comment.author else None,
|
|
201
|
+
comment.author.profile_url if comment.author else None,
|
|
202
|
+
comment.content,
|
|
203
|
+
comment_timestamp,
|
|
204
|
+
comment.reactions.total if comment.reactions else 0,
|
|
205
|
+
comment.reactions.like if comment.reactions else 0,
|
|
206
|
+
comment.reactions.love if comment.reactions else 0,
|
|
207
|
+
comment.reactions.haha if comment.reactions else 0,
|
|
208
|
+
comment.reactions.wow if comment.reactions else 0,
|
|
209
|
+
comment.reactions.sad if comment.reactions else 0,
|
|
210
|
+
comment.reactions.angry if comment.reactions else 0,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Insert nested replies
|
|
215
|
+
for reply in comment.replies:
|
|
216
|
+
insert_comment(reply, parent_id=comment.id)
|
|
217
|
+
|
|
218
|
+
for comment in post.comments:
|
|
219
|
+
insert_comment(comment)
|
|
220
|
+
|
|
221
|
+
conn.commit()
|
|
222
|
+
conn.close()
|