gitcast 1.0.0__tar.gz → 1.0.1__tar.gz

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.
Files changed (85) hide show
  1. {gitcast-1.0.0 → gitcast-1.0.1}/MANIFEST.in +1 -0
  2. {gitcast-1.0.0/gitcast.egg-info → gitcast-1.0.1}/PKG-INFO +1 -1
  3. gitcast-1.0.1/assets/__init__.py +1 -0
  4. gitcast-1.0.1/cli/.env.example +26 -0
  5. gitcast-1.0.1/cli/gitcast.py +106 -0
  6. {gitcast-1.0.0 → gitcast-1.0.1}/core/capture.py +7 -3
  7. {gitcast-1.0.0 → gitcast-1.0.1}/core/screenshot_session.py +6 -3
  8. {gitcast-1.0.0 → gitcast-1.0.1/gitcast.egg-info}/PKG-INFO +1 -1
  9. {gitcast-1.0.0 → gitcast-1.0.1}/gitcast.egg-info/SOURCES.txt +3 -11
  10. {gitcast-1.0.0 → gitcast-1.0.1}/gitcast.egg-info/top_level.txt +2 -1
  11. {gitcast-1.0.0 → gitcast-1.0.1}/setup.py +1 -1
  12. gitcast-1.0.1/web/__init__.py +1 -0
  13. gitcast-1.0.0/cli/gitcast.py +0 -56
  14. gitcast-1.0.0/storage/cleanup.py +0 -60
  15. gitcast-1.0.0/storage/engagement.py +0 -114
  16. gitcast-1.0.0/storage/insights.py +0 -203
  17. gitcast-1.0.0/storage/key_manager.py +0 -45
  18. gitcast-1.0.0/storage/logger.py +0 -208
  19. gitcast-1.0.0/storage/metrics.py +0 -119
  20. gitcast-1.0.0/storage/sprint.py +0 -40
  21. gitcast-1.0.0/storage/streak.py +0 -0
  22. gitcast-1.0.0/storage/supabase_client.py +0 -25
  23. gitcast-1.0.0/storage/tone_memory.py +0 -139
  24. gitcast-1.0.0/ui/__init__.py +0 -0
  25. {gitcast-1.0.0 → gitcast-1.0.1}/.env.example +0 -0
  26. {gitcast-1.0.0 → gitcast-1.0.1}/LICENSE +0 -0
  27. {gitcast-1.0.0 → gitcast-1.0.1}/README.md +0 -0
  28. {gitcast-1.0.0 → gitcast-1.0.1}/ai/__init__.py +0 -0
  29. {gitcast-1.0.0 → gitcast-1.0.1}/ai/formatter.py +0 -0
  30. {gitcast-1.0.0 → gitcast-1.0.1}/ai/generator.py +0 -0
  31. {gitcast-1.0.0 → gitcast-1.0.1}/ai/prompts.py +0 -0
  32. {gitcast-1.0.0 → gitcast-1.0.1}/ai/viral_patterns.py +0 -0
  33. {gitcast-1.0.0 → gitcast-1.0.1}/api/__init__.py +0 -0
  34. {gitcast-1.0.0 → gitcast-1.0.1}/api/analytics.py +0 -0
  35. {gitcast-1.0.0 → gitcast-1.0.1}/api/auth.py +0 -0
  36. {gitcast-1.0.0 → gitcast-1.0.1}/api/auth_middleware.py +0 -0
  37. {gitcast-1.0.0 → gitcast-1.0.1}/api/auth_routes.py +0 -0
  38. {gitcast-1.0.0 → gitcast-1.0.1}/api/monitoring.py +0 -0
  39. {gitcast-1.0.0 → gitcast-1.0.1}/api/payload.py +0 -0
  40. {gitcast-1.0.0 → gitcast-1.0.1}/api/ratelimit.py +0 -0
  41. {gitcast-1.0.0 → gitcast-1.0.1}/api/routes.py +0 -0
  42. {gitcast-1.0.0 → gitcast-1.0.1}/api/server.py +0 -0
  43. {gitcast-1.0.0 → gitcast-1.0.1}/api/validators.py +0 -0
  44. {gitcast-1.0.0 → gitcast-1.0.1}/assets/favicon-16x16.png +0 -0
  45. {gitcast-1.0.0 → gitcast-1.0.1}/assets/favicon-32x32.png +0 -0
  46. {gitcast-1.0.0 → gitcast-1.0.1}/assets/favicon-64x64.png +0 -0
  47. {gitcast-1.0.0 → gitcast-1.0.1}/assets/favicon.ico +0 -0
  48. {gitcast-1.0.0 → gitcast-1.0.1}/assets/icon.png +0 -0
  49. {gitcast-1.0.0 → gitcast-1.0.1}/cli/__init__.py +0 -0
  50. {gitcast-1.0.0 → gitcast-1.0.1}/config/__init__.py +0 -0
  51. {gitcast-1.0.0 → gitcast-1.0.1}/config/settings.py +0 -0
  52. {gitcast-1.0.0 → gitcast-1.0.1}/core/__init__.py +0 -0
  53. {gitcast-1.0.0 → gitcast-1.0.1}/core/codebase_reader.py +0 -0
  54. {gitcast-1.0.0 → gitcast-1.0.1}/core/framing.py +0 -0
  55. {gitcast-1.0.0 → gitcast-1.0.1}/core/hotkey.py +0 -0
  56. {gitcast-1.0.0 → gitcast-1.0.1}/core/log_stream.py +0 -0
  57. {gitcast-1.0.0 → gitcast-1.0.1}/core/ocr.py +0 -0
  58. {gitcast-1.0.0 → gitcast-1.0.1}/core/security.py +0 -0
  59. {gitcast-1.0.0 → gitcast-1.0.1}/core/tray.py +0 -0
  60. {gitcast-1.0.0 → gitcast-1.0.1}/docs/api-keys.md +0 -0
  61. {gitcast-1.0.0 → gitcast-1.0.1}/docs/article-generator.md +0 -0
  62. {gitcast-1.0.0 → gitcast-1.0.1}/docs/capture-flow.md +0 -0
  63. {gitcast-1.0.0 → gitcast-1.0.1}/docs/changelog.md +0 -0
  64. {gitcast-1.0.0 → gitcast-1.0.1}/docs/contributing.md +0 -0
  65. {gitcast-1.0.0 → gitcast-1.0.1}/docs/dashboard.md +0 -0
  66. {gitcast-1.0.0 → gitcast-1.0.1}/docs/insights.md +0 -0
  67. {gitcast-1.0.0 → gitcast-1.0.1}/docs/introduction.md +0 -0
  68. {gitcast-1.0.0 → gitcast-1.0.1}/docs/post-formats.md +0 -0
  69. {gitcast-1.0.0 → gitcast-1.0.1}/docs/providers.md +0 -0
  70. {gitcast-1.0.0 → gitcast-1.0.1}/docs/quickstart.md +0 -0
  71. {gitcast-1.0.0 → gitcast-1.0.1}/docs/sprint-mode.md +0 -0
  72. {gitcast-1.0.0 → gitcast-1.0.1}/gitcast.egg-info/dependency_links.txt +0 -0
  73. {gitcast-1.0.0 → gitcast-1.0.1}/gitcast.egg-info/entry_points.txt +0 -0
  74. {gitcast-1.0.0 → gitcast-1.0.1}/gitcast.egg-info/requires.txt +0 -0
  75. {gitcast-1.0.0 → gitcast-1.0.1}/publisher/__init__.py +0 -0
  76. {gitcast-1.0.0 → gitcast-1.0.1}/publisher/clipboard.py +0 -0
  77. {gitcast-1.0.0 → gitcast-1.0.1}/publisher/twitter.py +0 -0
  78. {gitcast-1.0.0 → gitcast-1.0.1}/pyproject.toml +0 -0
  79. {gitcast-1.0.0 → gitcast-1.0.1}/requirements.txt +0 -0
  80. {gitcast-1.0.0 → gitcast-1.0.1}/setup.cfg +0 -0
  81. {gitcast-1.0.0 → gitcast-1.0.1}/tests/test_keys.py +0 -0
  82. {gitcast-1.0.0 → gitcast-1.0.1}/tests/test_ocr_fix.py +0 -0
  83. {gitcast-1.0.0/storage → gitcast-1.0.1/ui}/__init__.py +0 -0
  84. {gitcast-1.0.0 → gitcast-1.0.1}/web/index.html +0 -0
  85. {gitcast-1.0.0 → gitcast-1.0.1}/web/landing.html +0 -0
@@ -1,6 +1,7 @@
1
1
  include README.md
2
2
  include LICENSE
3
3
  include .env.example
4
+ include cli/.env.example
4
5
  include requirements.txt
5
6
  recursive-include web *
6
7
  recursive-include assets *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcast
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: git diff → published post. under 60 seconds.
5
5
  Home-page: https://github.com/drizzy765/gitcast
6
6
  Author: Timilehin Agoro
@@ -0,0 +1 @@
1
+ # Make assets directory a Python package so it gets packaged and installed to site-packages.
@@ -0,0 +1,26 @@
1
+ # Groq — primary AI inference
2
+ GROQ_API_KEY=your_groq_api_key_here
3
+
4
+ # DeepSeek & Moonshot — specialized/free-tier fallbacks
5
+ DEEPSEEK_API_KEY=your_deepseek_api_key_here
6
+ MOONSHOT_API_KEY=your_moonshot_api_key_here
7
+ CEREBRAS_API_KEY=your_cerebras_api_key_here
8
+ OPENROUTER_API_KEY=your_openrouter_api_key_here
9
+
10
+ # Gemini — vision fallback (when OCR confidence is too low)
11
+ GEMINI_API_KEY=your_gemini_api_key_here
12
+
13
+ # Optional model overrides
14
+ GROQ_MODEL=llama-3.3-70b-versatile
15
+ DEEPSEEK_MODEL=deepseek-chat
16
+ MOONSHOT_MODEL=moonshot-v1-8k
17
+ GEMINI_MODEL=gemini-2.0-flash
18
+ CEREBRAS_MODEL=llama-3.3-70b
19
+ OPENROUTER_MODEL=meta-llama/llama-3.3-70b-instruct:free
20
+
21
+ # X (Twitter) API v2 — all five are required for posting
22
+ TWITTER_API_KEY=your_twitter_api_key_here
23
+ TWITTER_API_SECRET=your_twitter_api_secret_here
24
+ TWITTER_ACCESS_TOKEN=your_twitter_access_token_here
25
+ TWITTER_ACCESS_SECRET=your_twitter_access_secret_here
26
+ TWITTER_BEARER_TOKEN=your_twitter_bearer_token_here
@@ -0,0 +1,106 @@
1
+ import sys
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import httpx
6
+ import webbrowser
7
+
8
+ def main():
9
+ if "--setup" in sys.argv:
10
+ env_path = os.path.join(
11
+ os.path.dirname(__file__), '..', '.env')
12
+ example_path = os.path.join(
13
+ os.path.dirname(__file__), '..', '.env.example')
14
+ if not os.path.exists(example_path):
15
+ example_path = os.path.join(
16
+ os.path.dirname(__file__), '.env.example')
17
+
18
+ if not os.path.exists(env_path):
19
+ if os.path.exists(example_path):
20
+ shutil.copy(example_path, env_path)
21
+ else:
22
+ # If neither is found, create an empty or minimal env file
23
+ with open(env_path, 'w') as f:
24
+ f.write("# Gitcast Environment Variables\nGROQ_API_KEY=\nGEMINI_API_KEY=\n")
25
+
26
+ try:
27
+ if sys.platform == "win32":
28
+ subprocess.run(["notepad", env_path])
29
+ elif sys.platform == "darwin":
30
+ subprocess.run(["open", "-t", env_path])
31
+ else:
32
+ try:
33
+ subprocess.run(["xdg-open", env_path])
34
+ except Exception:
35
+ print(f"[Gitcast] Created/configured .env at: {env_path}")
36
+ print("Please open it in an editor to add your API keys.")
37
+ except Exception as e:
38
+ print(f"[Gitcast] Created/configured .env at: {env_path}")
39
+ print(f"Could not automatically open editor: {e}")
40
+ sys.exit(0)
41
+
42
+ if len(sys.argv) < 2:
43
+ # start FastAPI server in background thread
44
+ import threading
45
+ import webbrowser
46
+ import time
47
+ import urllib.request
48
+
49
+ def start_server():
50
+ from api.server import start_server as run
51
+ run()
52
+
53
+ server_thread = threading.Thread(
54
+ target=start_server, daemon=True)
55
+ server_thread.start()
56
+
57
+ # wait for server to be ready (max 5 seconds)
58
+ for _ in range(10):
59
+ try:
60
+ urllib.request.urlopen(
61
+ "http://127.0.0.1:8000/health",
62
+ timeout=1)
63
+ break
64
+ except Exception:
65
+ time.sleep(0.5)
66
+
67
+ # open browser
68
+ webbrowser.open("http://127.0.0.1:8000")
69
+
70
+ # start tray and hotkey
71
+ from core.tray import run_tray
72
+ from main import on_trigger
73
+ run_tray(trigger_callback=on_trigger)
74
+ return
75
+
76
+ command = sys.argv[1]
77
+
78
+ if command == "capture":
79
+ from core.screenshot_session import ScreenshotSession
80
+ session = ScreenshotSession()
81
+ session.run()
82
+ return
83
+
84
+ thought = " ".join(sys.argv[1:])
85
+ print(f"[Gitcast] Initializing capture with thought: '{thought}'")
86
+
87
+ try:
88
+ # Call the internal trigger endpoint
89
+ response = httpx.post(
90
+ "http://127.0.0.1:8000/api/cli/trigger",
91
+ json={"thought": thought},
92
+ timeout=30
93
+ )
94
+
95
+ if response.status_code == 200:
96
+ print("[Gitcast] Context captured. Opening Draft Room...")
97
+ webbrowser.open("http://127.0.0.1:8000")
98
+ else:
99
+ print(f"[Gitcast Error] Failed to trigger: {response.text}")
100
+
101
+ except Exception as e:
102
+ print(f"[Gitcast Error] Connection failed: {e}")
103
+ print("Is Gitcast running? (python -m gitcast or python main.py)")
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -7,6 +7,8 @@ from config.settings import STORAGE_DIR
7
7
  from core.log_stream import stream_log
8
8
  from api.analytics import track
9
9
  BASE_DIR = STORAGE_DIR.parent.parent
10
+ screenshot_dir = STORAGE_DIR / "screenshots"
11
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
10
12
  from .framing import add_programming_frame
11
13
 
12
14
 
@@ -218,11 +220,13 @@ def run_capture(delay: float = 5.0) -> dict:
218
220
  # Apply programming frame if capture succeeded
219
221
  if screenshot.get("success") and screenshot.get("path"):
220
222
  try:
221
- framed_path = add_programming_frame(screenshot["path"])
222
- screenshot["framed_path"] = framed_path
223
+ abs_path = str(screenshot_dir / Path(screenshot["path"]).name)
224
+ framed_path = add_programming_frame(abs_path)
225
+ rel_framed_path = str(Path(framed_path).relative_to(BASE_DIR)).replace("\\", "/")
226
+ screenshot["framed_path"] = rel_framed_path
223
227
  # By default, use the framed path for downstream preview/publishing
224
228
  screenshot["raw_path"] = screenshot["path"]
225
- screenshot["path"] = framed_path
229
+ screenshot["path"] = rel_framed_path
226
230
  except Exception as e:
227
231
  stream_log("Capture", "WARN", f"framing failed: {e}")
228
232
  screenshot["framed_path"] = ""
@@ -17,6 +17,8 @@ from core.ocr import run_ocr
17
17
  from core.security import scan_for_secrets, delete_capture
18
18
  from config.settings import STORAGE_DIR, CURRENT_DRAFT, get_project_narrative
19
19
 
20
+ screenshot_dir = STORAGE_DIR / "screenshots"
21
+
20
22
  console = Console()
21
23
 
22
24
  class ScreenshotSession:
@@ -127,12 +129,13 @@ class ScreenshotSession:
127
129
  return
128
130
 
129
131
  # OCR and Security (Silent progress)
130
- ocr = run_ocr(shot["path"])
132
+ abs_shot_path = str(screenshot_dir / Path(shot["path"]).name)
133
+ ocr = run_ocr(abs_shot_path)
131
134
  security = scan_for_secrets(ocr["text"] or ocr["raw_text"])
132
135
 
133
136
  if not security["clean"]:
134
137
  console.print(f"\n[red]⚠️ Shot {len(self.screenshots)+1} blocked (sensitive content).[/red]")
135
- delete_capture(shot["path"])
138
+ delete_capture(abs_shot_path)
136
139
  return
137
140
 
138
141
  # Add to list with default purpose
@@ -212,7 +215,7 @@ class ScreenshotSession:
212
215
  if keep:
213
216
  final_shots.append(s)
214
217
  else:
215
- delete_capture(s["path"])
218
+ delete_capture(str(screenshot_dir / Path(s["path"]).name))
216
219
 
217
220
  self.screenshots = final_shots
218
221
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcast
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: git diff → published post. under 60 seconds.
5
5
  Home-page: https://github.com/drizzy765/gitcast
6
6
  Author: Timilehin Agoro
@@ -21,11 +21,13 @@ api/ratelimit.py
21
21
  api/routes.py
22
22
  api/server.py
23
23
  api/validators.py
24
+ assets/__init__.py
24
25
  assets/favicon-16x16.png
25
26
  assets/favicon-32x32.png
26
27
  assets/favicon-64x64.png
27
28
  assets/favicon.ico
28
29
  assets/icon.png
30
+ cli/.env.example
29
31
  cli/__init__.py
30
32
  cli/gitcast.py
31
33
  config/__init__.py
@@ -61,19 +63,9 @@ gitcast.egg-info/top_level.txt
61
63
  publisher/__init__.py
62
64
  publisher/clipboard.py
63
65
  publisher/twitter.py
64
- storage/__init__.py
65
- storage/cleanup.py
66
- storage/engagement.py
67
- storage/insights.py
68
- storage/key_manager.py
69
- storage/logger.py
70
- storage/metrics.py
71
- storage/sprint.py
72
- storage/streak.py
73
- storage/supabase_client.py
74
- storage/tone_memory.py
75
66
  tests/test_keys.py
76
67
  tests/test_ocr_fix.py
77
68
  ui/__init__.py
69
+ web/__init__.py
78
70
  web/index.html
79
71
  web/landing.html
@@ -1,8 +1,9 @@
1
1
  ai
2
2
  api
3
+ assets
3
4
  cli
4
5
  config
5
6
  core
6
7
  publisher
7
- storage
8
8
  ui
9
+ web
@@ -19,7 +19,7 @@ if os.path.exists(requirements_path):
19
19
 
20
20
  setup(
21
21
  name="gitcast",
22
- version="1.0.0",
22
+ version="1.0.1",
23
23
  description="git diff → published post. under 60 seconds.",
24
24
  long_description=long_description,
25
25
  long_description_content_type="text/markdown",
@@ -0,0 +1 @@
1
+ # Make web directory a Python package so it gets packaged and installed to site-packages.
@@ -1,56 +0,0 @@
1
- import sys
2
- import os
3
- import shutil
4
- import subprocess
5
- import httpx
6
- import webbrowser
7
-
8
- def main():
9
- if "--setup" in sys.argv:
10
- env_path = os.path.join(
11
- os.path.dirname(__file__), '..', '.env')
12
- example_path = os.path.join(
13
- os.path.dirname(__file__), '..', '.env.example')
14
- if not os.path.exists(env_path):
15
- shutil.copy(example_path, env_path)
16
- subprocess.run(["notepad", env_path])
17
- sys.exit(0)
18
-
19
- if len(sys.argv) < 2:
20
- print("Usage:")
21
- print(" gitcast \"your thought here\" -> Quick single capture")
22
- print(" gitcast capture -> Start interactive multi-shot session")
23
- print(" gitcast --setup -> Setup environment variables")
24
- sys.exit(1)
25
-
26
- command = sys.argv[1]
27
-
28
- if command == "capture":
29
- from core.screenshot_session import ScreenshotSession
30
- session = ScreenshotSession()
31
- session.run()
32
- return
33
-
34
- thought = " ".join(sys.argv[1:])
35
- print(f"[Gitcast] Initializing capture with thought: '{thought}'")
36
-
37
- try:
38
- # Call the internal trigger endpoint
39
- response = httpx.post(
40
- "http://127.0.0.1:8000/api/cli/trigger",
41
- json={"thought": thought},
42
- timeout=30
43
- )
44
-
45
- if response.status_code == 200:
46
- print("[Gitcast] Context captured. Opening Draft Room...")
47
- webbrowser.open("http://127.0.0.1:8000")
48
- else:
49
- print(f"[Gitcast Error] Failed to trigger: {response.text}")
50
-
51
- except Exception as e:
52
- print(f"[Gitcast Error] Connection failed: {e}")
53
- print("Is Gitcast running? (python -m gitcast or python main.py)")
54
-
55
- if __name__ == "__main__":
56
- main()
@@ -1,60 +0,0 @@
1
- import os
2
- import time
3
- from pathlib import Path
4
- from config.settings import STORAGE_DIR, screenshot_retention_hours
5
-
6
- # [Cleanup] module for managing screenshot retention policy
7
-
8
- def run_cleanup():
9
- """Deletes screenshots older than screenshot_retention_hours."""
10
- screenshots_dir = STORAGE_DIR / "screenshots"
11
- if not screenshots_dir.exists():
12
- print("[Cleanup] No screenshots directory found.")
13
- return
14
-
15
- now = time.time()
16
- retention_sec = screenshot_retention_hours * 3600
17
- deleted_count = 0
18
-
19
- for file_path in screenshots_dir.glob("*.png"):
20
- file_age = now - file_path.stat().st_mtime
21
- if file_age > retention_sec:
22
- try:
23
- # Use secure delete if possible, but for cleanup standard unlink is usually fine
24
- # unless the user specifically wants secure delete for all old files.
25
- # Given core/security.py exists, we could use it, but it might be overkill for bulk cleanup.
26
- file_path.unlink()
27
- deleted_count += 1
28
- except Exception as e:
29
- print(f"[Cleanup] Error deleting {file_path.name}: {e}")
30
-
31
- if deleted_count > 0:
32
- print(f"[Cleanup] Deleted {deleted_count} old screenshots.")
33
- else:
34
- print("[Cleanup] No old screenshots to delete.")
35
-
36
- def get_storage_stats():
37
- """Returns count of screenshots, total size in MB, and oldest file date."""
38
- screenshots_dir = STORAGE_DIR / "screenshots"
39
- if not screenshots_dir.exists():
40
- return {"count": 0, "total_size_mb": 0.0, "oldest_file": "N/A"}
41
-
42
- files = list(screenshots_dir.glob("*.png"))
43
- if not files:
44
- return {"count": 0, "total_size_mb": 0.0, "oldest_file": "N/A"}
45
-
46
- total_size = sum(f.stat().st_size for f in files)
47
- oldest_time = min(f.stat().st_mtime for f in files)
48
- oldest_date = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(oldest_time))
49
-
50
- return {
51
- "count": len(files),
52
- "total_size_mb": round(total_size / (1024 * 1024), 2),
53
- "oldest_file": oldest_date
54
- }
55
-
56
- if __name__ == "__main__":
57
- print("=== STORAGE CLEANUP TEST ===")
58
- stats = get_storage_stats()
59
- print(f"Current stats: {stats}")
60
- run_cleanup()
@@ -1,114 +0,0 @@
1
- import json
2
- import time
3
- import threading
4
- from pathlib import Path
5
- import tweepy
6
- from config.settings import ENGAGEMENT_LOG, TWITTER_BEARER_TOKEN, POST_LOG
7
- from storage.tone_memory import update_engagement
8
-
9
- # [Engagement] module for tracking X metrics 24h after publish
10
-
11
- def log_pending_fetch(tweet_id: str, post_text: str):
12
- """Saves a tweet ID to ENGAGEMENT_LOG for background processing."""
13
- entry = {
14
- "tweet_id": tweet_id,
15
- "post_text": post_text,
16
- "publish_timestamp": time.time(),
17
- "fetched": False
18
- }
19
- try:
20
- with open(ENGAGEMENT_LOG, "a", encoding="utf-8") as f:
21
- f.write(json.dumps(entry) + "\n")
22
- print(f"[Engagement] Logged pending fetch for tweet {tweet_id}")
23
- except Exception as e:
24
- print(f"[Engagement] Error logging pending fetch: {e}")
25
-
26
- def get_pending_fetches():
27
- """Returns list of entries due for metrics fetch (24h+ old, not fetched)."""
28
- if not ENGAGEMENT_LOG.exists():
29
- return []
30
-
31
- pending = []
32
- now = time.time()
33
- try:
34
- with open(ENGAGEMENT_LOG, "r", encoding="utf-8") as f:
35
- for line in f:
36
- if not line.strip(): continue
37
- entry = json.loads(line)
38
- # 24 hours = 86400 seconds
39
- if not entry.get("fetched") and (now - entry["publish_timestamp"] > 86400):
40
- pending.append(entry)
41
- except Exception as e:
42
- print(f"[Engagement] Error reading pending fetches: {e}")
43
-
44
- return pending
45
-
46
- def fetch_and_store_metrics(tweet_id: str, post_text: str):
47
- """Calls X API v2 to get metrics and updates tone_memory."""
48
- if not TWITTER_BEARER_TOKEN:
49
- print("[Engagement] No Twitter Bearer Token found, skipping fetch.")
50
- return False
51
-
52
- try:
53
- client = tweepy.Client(bearer_token=TWITTER_BEARER_TOKEN)
54
- response = client.get_tweet(tweet_id, tweet_fields=["public_metrics"])
55
-
56
- if response.data:
57
- metrics = response.data.public_metrics
58
- likes = metrics.get("like_count", 0)
59
- retweets = metrics.get("retweet_count", 0)
60
- replies = metrics.get("reply_count", 0)
61
-
62
- update_engagement(post_text, likes, retweets, replies)
63
- return True
64
- else:
65
- print(f"[Engagement] Could not find tweet {tweet_id}")
66
- return False
67
- except Exception as e:
68
- print(f"[Engagement] X API error for {tweet_id}: {e}")
69
- return False
70
-
71
- def mark_as_fetched(tweet_id: str):
72
- """Updates ENGAGEMENT_LOG to mark a tweet as fetched."""
73
- if not ENGAGEMENT_LOG.exists():
74
- return
75
-
76
- entries = []
77
- try:
78
- with open(ENGAGEMENT_LOG, "r", encoding="utf-8") as f:
79
- for line in f:
80
- if not line.strip(): continue
81
- entry = json.loads(line)
82
- if entry["tweet_id"] == tweet_id:
83
- entry["fetched"] = True
84
- entries.append(entry)
85
-
86
- with open(ENGAGEMENT_LOG, "w", encoding="utf-8") as f:
87
- for e in entries:
88
- f.write(json.dumps(e) + "\n")
89
- except Exception as e:
90
- print(f"[Engagement] Error marking as fetched: {e}")
91
-
92
- def run_engagement_worker():
93
- """Background thread that runs hourly and processes pending fetches."""
94
- def worker():
95
- while True:
96
- print("[Engagement] Checking for pending metrics fetches...")
97
- pending = get_pending_fetches()
98
- for entry in pending:
99
- success = fetch_and_store_metrics(entry["tweet_id"], entry["post_text"])
100
- if success:
101
- mark_as_fetched(entry["tweet_id"])
102
-
103
- # Sleep for 1 hour
104
- time.sleep(3600)
105
-
106
- thread = threading.Thread(target=worker, daemon=True)
107
- thread.start()
108
- print("[Engagement] Background worker started.")
109
-
110
- if __name__ == "__main__":
111
- print("=== ENGAGEMENT TRACKING TEST ===")
112
- log_pending_fetch("123456789", "Test post text")
113
- print(f"Pending: {get_pending_fetches()}")
114
- # Note: X API call will fail without real token in test