@opendirectory.dev/skills 0.1.0

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 (212) hide show
  1. package/.claude/skills/claude-md-generator/.env.example +7 -0
  2. package/.claude/skills/claude-md-generator/README.md +78 -0
  3. package/.claude/skills/claude-md-generator/SKILL.md +248 -0
  4. package/.claude/skills/claude-md-generator/evals/evals.json +35 -0
  5. package/.claude/skills/claude-md-generator/references/section-guide.md +175 -0
  6. package/dist/e2e.test.d.ts +1 -0
  7. package/dist/e2e.test.js +62 -0
  8. package/dist/fs-adapters.d.ts +4 -0
  9. package/dist/fs-adapters.js +101 -0
  10. package/dist/fs-adapters.test.d.ts +1 -0
  11. package/dist/fs-adapters.test.js +108 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.js +211 -0
  14. package/dist/transformers.d.ts +6 -0
  15. package/dist/transformers.js +2 -0
  16. package/package.json +25 -0
  17. package/registry.json +226 -0
  18. package/skills/blog-cover-image-cli/.github/workflows/publish.yml +19 -0
  19. package/skills/blog-cover-image-cli/LICENSE +15 -0
  20. package/skills/blog-cover-image-cli/README.md +126 -0
  21. package/skills/blog-cover-image-cli/SKILL.md +7 -0
  22. package/skills/blog-cover-image-cli/agent-skill/blog-cover-generator/README.md +30 -0
  23. package/skills/blog-cover-image-cli/agent-skill/blog-cover-generator/SKILL.md +72 -0
  24. package/skills/blog-cover-image-cli/bin/cli.js +226 -0
  25. package/skills/blog-cover-image-cli/examples/100x_UX_Research_AI_Agent.png +0 -0
  26. package/skills/blog-cover-image-cli/examples/Firecrawl-supabase-bolt.png +0 -0
  27. package/skills/blog-cover-image-cli/examples/Git-City_Case_study_Cover_Image.jpg +0 -0
  28. package/skills/blog-cover-image-cli/examples/THE DISTRIBUTION LAYER (2).png +0 -0
  29. package/skills/blog-cover-image-cli/examples/canva-perplexity-duolingo-cover-image.png +0 -0
  30. package/skills/blog-cover-image-cli/examples/gamma-mistral-veed.png +0 -0
  31. package/skills/blog-cover-image-cli/examples/server-survival-case-study-cover-image(1).png +0 -0
  32. package/skills/blog-cover-image-cli/examples/viral-meme-automation.png +0 -0
  33. package/skills/blog-cover-image-cli/index.js +2 -0
  34. package/skills/blog-cover-image-cli/package-lock.json +2238 -0
  35. package/skills/blog-cover-image-cli/package.json +37 -0
  36. package/skills/blog-cover-image-cli/src/geminiGenerator.js +126 -0
  37. package/skills/blog-cover-image-cli/src/imageValidator.js +54 -0
  38. package/skills/blog-cover-image-cli/src/logoFetcher.js +86 -0
  39. package/skills/claude-md-generator/.env.example +7 -0
  40. package/skills/claude-md-generator/README.md +78 -0
  41. package/skills/claude-md-generator/SKILL.md +254 -0
  42. package/skills/claude-md-generator/evals/evals.json +35 -0
  43. package/skills/claude-md-generator/references/section-guide.md +175 -0
  44. package/skills/cook-the-blog/README.md +86 -0
  45. package/skills/cook-the-blog/SKILL.md +130 -0
  46. package/skills/dependency-update-bot/.env.example +13 -0
  47. package/skills/dependency-update-bot/README.md +101 -0
  48. package/skills/dependency-update-bot/SKILL.md +376 -0
  49. package/skills/dependency-update-bot/evals/evals.json +45 -0
  50. package/skills/dependency-update-bot/references/changelog-patterns.md +201 -0
  51. package/skills/docs-from-code/.env.example +13 -0
  52. package/skills/docs-from-code/README.md +97 -0
  53. package/skills/docs-from-code/SKILL.md +160 -0
  54. package/skills/docs-from-code/evals/evals.json +29 -0
  55. package/skills/docs-from-code/references/extraction-guide.md +174 -0
  56. package/skills/docs-from-code/references/output-template.md +135 -0
  57. package/skills/docs-from-code/scripts/extract_py.py +238 -0
  58. package/skills/docs-from-code/scripts/extract_ts.ts +284 -0
  59. package/skills/docs-from-code/scripts/package.json +18 -0
  60. package/skills/explain-this-pr/README.md +74 -0
  61. package/skills/explain-this-pr/SKILL.md +130 -0
  62. package/skills/explain-this-pr/evals/evals.json +35 -0
  63. package/skills/google-trends-api-skills/README.md +78 -0
  64. package/skills/google-trends-api-skills/SKILL.md +7 -0
  65. package/skills/google-trends-api-skills/google-trends-api/SKILL.md +163 -0
  66. package/skills/google-trends-api-skills/google-trends-api/references/api-responses.md +188 -0
  67. package/skills/google-trends-api-skills/google-trends-api/scripts/discover_keywords.py +344 -0
  68. package/skills/google-trends-api-skills/seo-keyword-research/SKILL.md +205 -0
  69. package/skills/google-trends-api-skills/seo-keyword-research/references/keyword-placement-guide.md +89 -0
  70. package/skills/google-trends-api-skills/seo-keyword-research/references/tech-blog-examples.md +207 -0
  71. package/skills/google-trends-api-skills/seo-keyword-research/scripts/blog_seo_research.py +373 -0
  72. package/skills/hackernews-intel/.env.example +33 -0
  73. package/skills/hackernews-intel/README.md +161 -0
  74. package/skills/hackernews-intel/SKILL.md +156 -0
  75. package/skills/hackernews-intel/evals/evals.json +35 -0
  76. package/skills/hackernews-intel/package.json +15 -0
  77. package/skills/hackernews-intel/scripts/monitor-hn.js +258 -0
  78. package/skills/kill-the-standup/.env.example +22 -0
  79. package/skills/kill-the-standup/README.md +84 -0
  80. package/skills/kill-the-standup/SKILL.md +169 -0
  81. package/skills/kill-the-standup/evals/evals.json +35 -0
  82. package/skills/kill-the-standup/references/standup-format.md +102 -0
  83. package/skills/linkedin-post-generator/.env.example +14 -0
  84. package/skills/linkedin-post-generator/README.md +107 -0
  85. package/skills/linkedin-post-generator/SKILL.md +228 -0
  86. package/skills/linkedin-post-generator/evals/evals.json +35 -0
  87. package/skills/linkedin-post-generator/references/linkedin-format.md +216 -0
  88. package/skills/linkedin-post-generator/references/output-template.md +154 -0
  89. package/skills/llms-txt-generator/.env.example +18 -0
  90. package/skills/llms-txt-generator/README.md +142 -0
  91. package/skills/llms-txt-generator/SKILL.md +176 -0
  92. package/skills/llms-txt-generator/evals/evals.json +35 -0
  93. package/skills/llms-txt-generator/references/llms-txt-spec.md +88 -0
  94. package/skills/llms-txt-generator/references/output-template.md +76 -0
  95. package/skills/llms-txt-generator/test-output/genzcareer.in/llms.txt +31 -0
  96. package/skills/luma-attendees-scraper/README.md +170 -0
  97. package/skills/luma-attendees-scraper/SKILL.md +7 -0
  98. package/skills/luma-attendees-scraper/luma_attendees_export.js +223 -0
  99. package/skills/meeting-brief-generator/.env.example +21 -0
  100. package/skills/meeting-brief-generator/README.md +90 -0
  101. package/skills/meeting-brief-generator/SKILL.md +275 -0
  102. package/skills/meeting-brief-generator/evals/evals.json +35 -0
  103. package/skills/meeting-brief-generator/references/brief-format.md +114 -0
  104. package/skills/meeting-brief-generator/references/output-template.md +150 -0
  105. package/skills/meta-ads-skill/README.md +100 -0
  106. package/skills/meta-ads-skill/SKILL.md +7 -0
  107. package/skills/meta-ads-skill/meta-ads-skill/SKILL.md +41 -0
  108. package/skills/meta-ads-skill/meta-ads-skill/references/report_templates.md +47 -0
  109. package/skills/meta-ads-skill/meta-ads-skill/references/workflows.md +51 -0
  110. package/skills/meta-ads-skill/meta-ads-skill/scripts/auth_check.py +22 -0
  111. package/skills/meta-ads-skill/meta-ads-skill/scripts/formatters.py +46 -0
  112. package/skills/newsletter-digest/.env.example +20 -0
  113. package/skills/newsletter-digest/README.md +147 -0
  114. package/skills/newsletter-digest/SKILL.md +221 -0
  115. package/skills/newsletter-digest/evals/evals.json +35 -0
  116. package/skills/newsletter-digest/feeds.json +7 -0
  117. package/skills/newsletter-digest/package.json +15 -0
  118. package/skills/newsletter-digest/references/digest-format.md +123 -0
  119. package/skills/newsletter-digest/references/output-template.md +136 -0
  120. package/skills/newsletter-digest/scripts/fetch-feeds.js +141 -0
  121. package/skills/newsletter-digest/scripts/ghost-publish.js +147 -0
  122. package/skills/noise2blog/.env.example +16 -0
  123. package/skills/noise2blog/README.md +107 -0
  124. package/skills/noise2blog/SKILL.md +229 -0
  125. package/skills/noise2blog/evals/evals.json +35 -0
  126. package/skills/noise2blog/references/blog-format.md +188 -0
  127. package/skills/noise2blog/references/output-template.md +184 -0
  128. package/skills/outreach-sequence-builder/.env.example +12 -0
  129. package/skills/outreach-sequence-builder/README.md +108 -0
  130. package/skills/outreach-sequence-builder/SKILL.md +248 -0
  131. package/skills/outreach-sequence-builder/evals/evals.json +36 -0
  132. package/skills/outreach-sequence-builder/references/output-template.md +171 -0
  133. package/skills/outreach-sequence-builder/references/sequence-format.md +167 -0
  134. package/skills/outreach-sequence-builder/references/signal-playbook.md +117 -0
  135. package/skills/position-me/README.md +71 -0
  136. package/skills/position-me/SKILL.md +7 -0
  137. package/skills/position-me/position-me/SKILL.md +50 -0
  138. package/skills/position-me/position-me/references/EVALUATION_SOP.md +40 -0
  139. package/skills/position-me/position-me/references/REPORT_TEMPLATE.md +58 -0
  140. package/skills/position-me/position-me/scripts/extract_links.py +49 -0
  141. package/skills/pr-description-writer/README.md +81 -0
  142. package/skills/pr-description-writer/SKILL.md +141 -0
  143. package/skills/pr-description-writer/evals/evals.json +35 -0
  144. package/skills/pr-description-writer/references/pr-format-guide.md +145 -0
  145. package/skills/producthunt-launch-kit/.env.example +7 -0
  146. package/skills/producthunt-launch-kit/README.md +95 -0
  147. package/skills/producthunt-launch-kit/SKILL.md +380 -0
  148. package/skills/producthunt-launch-kit/evals/evals.json +35 -0
  149. package/skills/producthunt-launch-kit/references/copy-rules.md +124 -0
  150. package/skills/reddit-icp-monitor/.env.example +16 -0
  151. package/skills/reddit-icp-monitor/README.md +117 -0
  152. package/skills/reddit-icp-monitor/SKILL.md +271 -0
  153. package/skills/reddit-icp-monitor/evals/evals.json +40 -0
  154. package/skills/reddit-icp-monitor/references/icp-format.md +131 -0
  155. package/skills/reddit-icp-monitor/references/reply-rules.md +110 -0
  156. package/skills/reddit-post-engine/.env.example +13 -0
  157. package/skills/reddit-post-engine/README.md +103 -0
  158. package/skills/reddit-post-engine/SKILL.md +303 -0
  159. package/skills/reddit-post-engine/evals/evals.json +35 -0
  160. package/skills/reddit-post-engine/references/subreddit-playbook.md +156 -0
  161. package/skills/schema-markup-generator/.env.example +19 -0
  162. package/skills/schema-markup-generator/README.md +114 -0
  163. package/skills/schema-markup-generator/SKILL.md +192 -0
  164. package/skills/schema-markup-generator/evals/evals.json +35 -0
  165. package/skills/schema-markup-generator/references/json-ld-spec.md +263 -0
  166. package/skills/schema-markup-generator/references/output-template.md +556 -0
  167. package/skills/show-hn-writer/.env.example +14 -0
  168. package/skills/show-hn-writer/README.md +88 -0
  169. package/skills/show-hn-writer/SKILL.md +303 -0
  170. package/skills/show-hn-writer/evals/evals.json +35 -0
  171. package/skills/show-hn-writer/references/hn-rules.md +74 -0
  172. package/skills/show-hn-writer/references/title-formulas.md +93 -0
  173. package/skills/stargazer/README.md +79 -0
  174. package/skills/stargazer/SKILL.md +7 -0
  175. package/skills/stargazer/stargazer-skill/SKILL.md +58 -0
  176. package/skills/stargazer/stargazer-skill/assets/.env.example +18 -0
  177. package/skills/stargazer/stargazer-skill/scripts/convert_to_csv.py +63 -0
  178. package/skills/stargazer/stargazer-skill/scripts/count_emails.py +52 -0
  179. package/skills/stargazer/stargazer-skill/scripts/stargazer_deep_extractor.py +450 -0
  180. package/skills/tweet-thread-from-blog/.env.example +14 -0
  181. package/skills/tweet-thread-from-blog/README.md +109 -0
  182. package/skills/tweet-thread-from-blog/SKILL.md +177 -0
  183. package/skills/tweet-thread-from-blog/evals/evals.json +35 -0
  184. package/skills/tweet-thread-from-blog/references/output-template.md +193 -0
  185. package/skills/tweet-thread-from-blog/references/thread-format.md +107 -0
  186. package/skills/twitter-GTM-find-skill/README.md +43 -0
  187. package/skills/twitter-GTM-find-skill/SKILL.md +7 -0
  188. package/skills/twitter-GTM-find-skill/twitter-GTM-find/SKILL.md +37 -0
  189. package/skills/twitter-GTM-find-skill/twitter-GTM-find/references/icp-checklist.md +35 -0
  190. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/package.json +23 -0
  191. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/run_pipeline.sh +8 -0
  192. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/debug.ts +23 -0
  193. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/extractor.ts +79 -0
  194. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/icp-filter.ts +87 -0
  195. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/index.ts +94 -0
  196. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/scraper.ts +41 -0
  197. package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/tsconfig.json +13 -0
  198. package/skills/yc-intent-radar-skill/README.md +39 -0
  199. package/skills/yc-intent-radar-skill/SKILL.md +7 -0
  200. package/skills/yc-intent-radar-skill/yc-jobs-scraper/SKILL.md +59 -0
  201. package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/auth.js +29 -0
  202. package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/db.js +62 -0
  203. package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/export_radar_candidates.js +40 -0
  204. package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/package-lock.json +1525 -0
  205. package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/package.json +12 -0
  206. package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/scraper.js +217 -0
  207. package/src/e2e.test.ts +35 -0
  208. package/src/fs-adapters.test.ts +91 -0
  209. package/src/fs-adapters.ts +65 -0
  210. package/src/index.ts +182 -0
  211. package/src/transformers.ts +6 -0
  212. package/tsconfig.json +8 -0
@@ -0,0 +1,63 @@
1
+ import os
2
+ import json
3
+ import csv
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+ TARGET_OWNER = os.environ.get("TARGET_OWNER", "Gitlawb")
8
+ TARGET_REPO = os.environ.get("TARGET_REPO", "openclaude")
9
+
10
+
11
+ def convert_jsonl_to_csv():
12
+ jsonl_filename = f"{TARGET_OWNER}_{TARGET_REPO}_detailed.jsonl"
13
+ csv_filename = f"{TARGET_OWNER}_{TARGET_REPO}_detailed.csv"
14
+
15
+ if not os.path.exists(jsonl_filename):
16
+ print(f"Error: The file '{jsonl_filename}' does not exist.")
17
+ return
18
+
19
+ headers = [
20
+ "login",
21
+ "name",
22
+ "email",
23
+ "location",
24
+ "company",
25
+ "blog",
26
+ "twitter",
27
+ "followers",
28
+ "public_repos",
29
+ "profile_url",
30
+ "email_found",
31
+ ]
32
+
33
+ total_converted = 0
34
+
35
+ with (
36
+ open(jsonl_filename, "r", encoding="utf-8") as infile,
37
+ open(csv_filename, "w", encoding="utf-8-sig", newline="") as outfile,
38
+ ):
39
+ writer = csv.DictWriter(outfile, fieldnames=headers)
40
+ writer.writeheader()
41
+
42
+ for line in infile:
43
+ if not line.strip():
44
+ continue
45
+ try:
46
+ data = json.loads(line)
47
+ row = {header: data.get(header, "") for header in headers}
48
+
49
+ for k, v in row.items():
50
+ if v is None:
51
+ row[k] = ""
52
+
53
+ writer.writerow(row)
54
+ total_converted += 1
55
+ except json.JSONDecodeError:
56
+ pass
57
+
58
+ print(f"\nSuccessfully converted {total_converted} records to CSV!")
59
+ print(f"Saved as: {csv_filename}\n")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ convert_jsonl_to_csv()
@@ -0,0 +1,52 @@
1
+ import os
2
+ import json
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+ TARGET_OWNER = os.environ.get("TARGET_OWNER", "Gitlawb")
7
+ TARGET_REPO = os.environ.get("TARGET_REPO", "openclaude")
8
+
9
+
10
+ def analyze_jsonl():
11
+ filename = f"{TARGET_OWNER}_{TARGET_REPO}_detailed.jsonl"
12
+
13
+ if not os.path.exists(filename):
14
+ print(f"Error: The file '{filename}' does not exist yet.")
15
+ print(
16
+ "Make sure you have run the deep extractor script and it has started saving data."
17
+ )
18
+ return
19
+
20
+ total_scraped = 0
21
+ emails_found = 0
22
+ emails_null = 0
23
+
24
+ with open(filename, "r", encoding="utf-8") as f:
25
+ for line in f:
26
+ if not line.strip():
27
+ continue
28
+ try:
29
+ data = json.loads(line)
30
+ total_scraped += 1
31
+
32
+ if data.get("email"):
33
+ emails_found += 1
34
+ else:
35
+ emails_null += 1
36
+ except json.JSONDecodeError:
37
+ pass
38
+
39
+ print(f"\n--- Scraping Stats for {TARGET_OWNER}/{TARGET_REPO} ---")
40
+ print(f"Total Stargazers Processed: {total_scraped}")
41
+ print(f"Emails Successfully Found: {emails_found}")
42
+ print(f"Emails Null (Hidden): {emails_null}")
43
+
44
+ if total_scraped > 0:
45
+ success_rate = (emails_found / total_scraped) * 100
46
+ print(f"Overall Extraction Rate: {success_rate:.2f}%")
47
+
48
+ print("-" * 50 + "\n")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ analyze_jsonl()
@@ -0,0 +1,450 @@
1
+ import asyncio
2
+ import os
3
+ import json
4
+ import re
5
+ import time
6
+ import random
7
+ import datetime
8
+ from typing import Coroutine, Any, Optional
9
+ import aiohttp
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv()
13
+
14
+ TARGET_OWNER = os.environ.get("TARGET_OWNER")
15
+ if not TARGET_OWNER:
16
+ raise ValueError("TARGET_OWNER environment variable is not set")
17
+
18
+ TARGET_REPO = os.environ.get("TARGET_REPO")
19
+ if not TARGET_REPO:
20
+ raise ValueError("TARGET_REPO environment variable is not set")
21
+
22
+ MAX_USERS = int(os.environ.get("MAX_USERS", "25000"))
23
+ MAX_CONCURRENT = int(os.environ.get("MAX_CONCURRENT", "10"))
24
+
25
+ API_BASE_URL = "https://api.github.com"
26
+ OUTPUT_FILENAME = f"{TARGET_OWNER}_{TARGET_REPO}_detailed.jsonl"
27
+
28
+
29
+ class Token:
30
+ def __init__(self, pat: str):
31
+ self.pat = pat
32
+ self.rate_limit_reset = 0.0
33
+ self.search_lock = asyncio.Lock()
34
+ self.last_search_time = 0.0
35
+
36
+
37
+ class TokenPool:
38
+ def __init__(self, pats: list[str]):
39
+ if not pats:
40
+ raise ValueError("No GITHUB_PATS provided")
41
+ self.tokens = [Token(pat.strip()) for pat in pats if pat.strip()]
42
+ if not self.tokens:
43
+ raise ValueError("No valid GITHUB_PATS provided")
44
+ self.lock = asyncio.Lock()
45
+ self.current_index = 0
46
+
47
+ async def get_token(self) -> Token:
48
+ while True:
49
+ async with self.lock:
50
+ now = time.time()
51
+ for _ in range(len(self.tokens)):
52
+ token = self.tokens[self.current_index]
53
+ self.current_index = (self.current_index + 1) % len(self.tokens)
54
+ if token.rate_limit_reset <= now:
55
+ return token
56
+
57
+ soonest_reset = min(t.rate_limit_reset for t in self.tokens)
58
+ sleep_time = max(0.1, soonest_reset - now)
59
+
60
+ print(f"All tokens rate limited. Sleeping for {sleep_time:.2f} seconds...")
61
+ await asyncio.sleep(sleep_time)
62
+
63
+ async def mark_rate_limited(self, token: Token, reset_time: float):
64
+ async with self.lock:
65
+ token.rate_limit_reset = max(token.rate_limit_reset, reset_time)
66
+
67
+
68
+ # Initialize TokenPool
69
+ pats_env = os.environ.get("GITHUB_PATS")
70
+ if not pats_env:
71
+ raise ValueError(
72
+ "GITHUB_PATS environment variable is not set in the environment or .env file"
73
+ )
74
+
75
+ token_pool = TokenPool(pats_env.split(","))
76
+
77
+
78
+ def validate_email(email: Optional[str], username: str) -> bool:
79
+ if not email or not isinstance(email, str):
80
+ return False
81
+ email = email.strip().lower()
82
+ if not email:
83
+ return False
84
+ if "noreply.github.com" in email:
85
+ return False
86
+ if "dependabot" in email or "github-actions" in email:
87
+ return False
88
+ return True
89
+
90
+
91
+ async def send_req_until_success(
92
+ method: str, url: str, is_search: bool = False, **kwargs
93
+ ) -> Any:
94
+ delay_sec = 2
95
+ retry_number = 0
96
+ while True:
97
+ retry_number += 1
98
+ token = await token_pool.get_token()
99
+
100
+ if is_search:
101
+ async with token.search_lock:
102
+ now = time.time()
103
+ elapsed = now - token.last_search_time
104
+ if elapsed < 2.1:
105
+ await asyncio.sleep(2.1 - elapsed)
106
+ token.last_search_time = time.time()
107
+
108
+ headers = kwargs.get("headers", {})
109
+ headers["Authorization"] = f"Bearer {token.pat}"
110
+ headers["Accept"] = "application/vnd.github.v3+json"
111
+ kwargs["headers"] = headers
112
+
113
+ await asyncio.sleep(random.uniform(0.1, 0.4))
114
+
115
+ try:
116
+ async with aiohttp.request(method, url, **kwargs) as res:
117
+ if res.status == 401:
118
+ print(
119
+ f"Unauthorized token: {token.pat[:4]}... Marking as rate limited for 60s."
120
+ )
121
+ await token_pool.mark_rate_limited(token, time.time() + 60)
122
+ continue
123
+ if res.status == 404:
124
+ return {}
125
+ if res.status == 403 or res.status == 429:
126
+ retry_after = res.headers.get("Retry-After")
127
+ if retry_after:
128
+ reset_time = time.time() + float(retry_after) + 1.0
129
+ else:
130
+ reset_header = res.headers.get("x-ratelimit-reset")
131
+ if reset_header:
132
+ reset_time = float(reset_header) + 1.0
133
+ else:
134
+ reset_time = time.time() + (delay_sec * retry_number)
135
+
136
+ await token_pool.mark_rate_limited(token, reset_time)
137
+ continue
138
+ if res.status != 200:
139
+ if res.status < 500:
140
+ return {}
141
+ print(f"Server error {res.status} for {url}, retrying...")
142
+ await asyncio.sleep(delay_sec)
143
+ continue
144
+ json_resp = await res.json()
145
+ return json_resp
146
+ except Exception as e:
147
+ await asyncio.sleep(delay_sec)
148
+ continue
149
+
150
+
151
+ async def get_stargazers(url: str, page: int) -> list[str]:
152
+ params = {
153
+ "per_page": "100",
154
+ "page": str(page),
155
+ }
156
+ resp = await send_req_until_success("GET", url, params=params)
157
+ if isinstance(resp, list):
158
+ return [u["login"] for u in resp if "login" in u]
159
+ return []
160
+
161
+
162
+ async def get_all_stargazers(owner: str, repo: str) -> list[str]:
163
+ url = f"{API_BASE_URL}/repos/{owner}/{repo}/stargazers"
164
+ res = []
165
+ page = 1
166
+ while True:
167
+ print(f"Fetching stargazers page {page}...")
168
+ stargazers = await get_stargazers(url, page)
169
+ if not stargazers:
170
+ break
171
+ res.extend(stargazers)
172
+ if len(res) >= MAX_USERS:
173
+ res = res[:MAX_USERS]
174
+ break
175
+ page += 1
176
+ return res
177
+
178
+
179
+ async def get_user_profile(username: str) -> dict:
180
+ """Fetch basic profile data via REST API."""
181
+ url = f"{API_BASE_URL}/users/{username}"
182
+ resp = await send_req_until_success("GET", url)
183
+ return resp if isinstance(resp, dict) else {}
184
+
185
+
186
+ async def get_emails_from_events(username: str) -> tuple[set, list]:
187
+ """Fallback 1: Extract emails from Public PushEvents via REST."""
188
+ url = f"{API_BASE_URL}/users/{username}/events/public"
189
+ resp = await send_req_until_success("GET", url)
190
+
191
+ extracted_emails = set()
192
+ commit_urls = []
193
+ if isinstance(resp, list):
194
+ for event in resp:
195
+ if event.get("type") == "PushEvent":
196
+ commits = event.get("payload", {}).get("commits", [])
197
+ for commit in commits:
198
+ author = commit.get("author", {})
199
+ email = author.get("email")
200
+ if validate_email(email, username):
201
+ extracted_emails.add(email)
202
+ url = commit.get("url")
203
+ if url:
204
+ commit_urls.append(url)
205
+ return extracted_emails, commit_urls
206
+
207
+
208
+ async def get_emails_from_gpg(username: str) -> set:
209
+ url = f"{API_BASE_URL}/users/{username}/gpg_keys"
210
+ resp = await send_req_until_success("GET", url)
211
+ extracted_emails = set()
212
+ if isinstance(resp, list):
213
+ for key in resp:
214
+ emails = key.get("emails", [])
215
+ for email_obj in emails:
216
+ email = email_obj.get("email")
217
+ if validate_email(email, username):
218
+ extracted_emails.add(email)
219
+ return extracted_emails
220
+
221
+
222
+ async def get_emails_from_patch(commit_urls: list[str]) -> set:
223
+ extracted_emails = set()
224
+ for url in commit_urls:
225
+ patch_url = url + ".patch"
226
+ try:
227
+ await asyncio.sleep(random.uniform(0.1, 0.4))
228
+ async with aiohttp.request("GET", patch_url) as res:
229
+ if res.status == 200:
230
+ text = await res.text()
231
+ match = re.search(r"^From:\s+.*?\s+<([^>]+)>", text, re.MULTILINE)
232
+ if match:
233
+ email = match.group(1)
234
+ if validate_email(email, ""):
235
+ extracted_emails.add(email)
236
+ except Exception:
237
+ pass
238
+ return extracted_emails
239
+
240
+
241
+ async def get_emails_from_global_search(username: str) -> set:
242
+ extracted_emails = set()
243
+ url = (
244
+ f"{API_BASE_URL}/search/commits?q=author:{username}&sort=author-date&order=desc"
245
+ )
246
+
247
+ try:
248
+ resp = await send_req_until_success("GET", url, is_search=True)
249
+ items = resp.get("items", [])
250
+ for item in items:
251
+ author = item.get("author")
252
+ if author and author.get("login", "").lower() == username.lower():
253
+ commit = item.get("commit", {})
254
+ author_data = commit.get("author", {})
255
+ email = author_data.get("email")
256
+ if validate_email(email, username):
257
+ extracted_emails.add(email)
258
+ except Exception:
259
+ pass
260
+
261
+ return extracted_emails
262
+
263
+
264
+ async def get_emails_from_graphql(username: str) -> set:
265
+ url = "https://api.github.com/graphql"
266
+ query = """
267
+ query GetRecentCommits($login: String!) {
268
+ user(login: $login) {
269
+ repositories(
270
+ first: 20
271
+ isFork: false
272
+ ownerAffiliations: OWNER
273
+ orderBy: {field: PUSHED_AT, direction: DESC}
274
+ ) {
275
+ nodes {
276
+ defaultBranchRef {
277
+ target {
278
+ ... on Commit {
279
+ history(first: 10) {
280
+ nodes {
281
+ author {
282
+ email
283
+ user {
284
+ login
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ """
297
+
298
+ resp = await send_req_until_success(
299
+ "POST", url, json={"query": query, "variables": {"login": username}}
300
+ )
301
+
302
+ extracted_emails = set()
303
+ try:
304
+ repos = (
305
+ resp.get("data", {})
306
+ .get("user", {})
307
+ .get("repositories", {})
308
+ .get("nodes", [])
309
+ )
310
+ for repo in repos:
311
+ if not repo or not repo.get("defaultBranchRef"):
312
+ continue
313
+ commits = repo["defaultBranchRef"]["target"]["history"]["nodes"]
314
+ for commit in commits:
315
+ author_data = commit.get("author", {})
316
+ github_user = author_data.get("user")
317
+
318
+ if (
319
+ github_user
320
+ and github_user.get("login", "").lower() == username.lower()
321
+ ):
322
+ email = author_data.get("email")
323
+ if email and "noreply.github.com" not in email:
324
+ extracted_emails.add(email)
325
+ except Exception as e:
326
+ pass
327
+
328
+ return extracted_emails
329
+
330
+
331
+ async def process_user(username: str) -> dict:
332
+ profile = await get_user_profile(username)
333
+ email = profile.get("email")
334
+
335
+ if not validate_email(email, username):
336
+ email = None
337
+
338
+ if not email:
339
+ events_emails, commit_urls = await get_emails_from_events(username)
340
+ if events_emails:
341
+ email = list(events_emails)[0]
342
+
343
+ if not email:
344
+ gpg_emails = await get_emails_from_gpg(username)
345
+ if gpg_emails:
346
+ email = list(gpg_emails)[0]
347
+
348
+ if not email:
349
+ if commit_urls:
350
+ patch_emails = await get_emails_from_patch(commit_urls)
351
+ if patch_emails:
352
+ email = list(patch_emails)[0]
353
+
354
+ if not email:
355
+ search_emails = await get_emails_from_global_search(username)
356
+ if search_emails:
357
+ email = list(search_emails)[0]
358
+
359
+ return {
360
+ "login": username,
361
+ "email": email,
362
+ "name": profile.get("name"),
363
+ "location": profile.get("location"),
364
+ "company": profile.get("company"),
365
+ "blog": profile.get("blog"),
366
+ "twitter": profile.get("twitter_username"),
367
+ "followers": profile.get("followers"),
368
+ "public_repos": profile.get("public_repos"),
369
+ "profile_url": profile.get("html_url"),
370
+ "email_found": bool(email),
371
+ }
372
+
373
+
374
+ def load_processed_users(filename: str) -> set[str]:
375
+ processed = set()
376
+ if os.path.exists(filename):
377
+ with open(filename, "r", encoding="utf-8") as f:
378
+ for line in f:
379
+ if line.strip():
380
+ try:
381
+ data = json.loads(line)
382
+ if "login" in data:
383
+ processed.add(data["login"])
384
+ except json.JSONDecodeError:
385
+ pass
386
+ return processed
387
+
388
+
389
+ async def main():
390
+ print(f"Targeting: {TARGET_OWNER}/{TARGET_REPO}")
391
+
392
+ stargazers_list = await get_all_stargazers(TARGET_OWNER, TARGET_REPO)
393
+ stargazers = []
394
+ seen = set()
395
+ for u in stargazers_list:
396
+ if u not in seen:
397
+ seen.add(u)
398
+ stargazers.append(u)
399
+
400
+ print("Stargazers count:", len(stargazers))
401
+
402
+ processed_users = load_processed_users(OUTPUT_FILENAME)
403
+ print(f"Already processed: {len(processed_users)}")
404
+
405
+ to_process = [u for u in stargazers if u not in processed_users]
406
+ total_to_process = len(to_process)
407
+ print(f"Remaining to process: {total_to_process}")
408
+
409
+ if not to_process:
410
+ print("All users processed.")
411
+ return
412
+
413
+ file_lock = asyncio.Lock()
414
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT)
415
+
416
+ processed_count = 0
417
+ start_time = time.time()
418
+
419
+ PRINT_INTERVAL = 20
420
+
421
+ async def process_and_save(username: str):
422
+ nonlocal processed_count
423
+ async with semaphore:
424
+ result = await process_user(username)
425
+ async with file_lock:
426
+ with open(OUTPUT_FILENAME, "a", encoding="utf-8") as f:
427
+ f.write(json.dumps(result, ensure_ascii=False) + "\n")
428
+
429
+ processed_count += 1
430
+ if (
431
+ processed_count % PRINT_INTERVAL == 0
432
+ or processed_count == total_to_process
433
+ ):
434
+ elapsed = time.time() - start_time
435
+ rate = processed_count / elapsed if elapsed > 0 else 0
436
+ remaining = total_to_process - processed_count
437
+ eta_seconds = remaining / rate if rate > 0 else 0
438
+ eta_str = str(datetime.timedelta(seconds=int(eta_seconds)))
439
+ print(
440
+ f"[{processed_count}/{total_to_process}] Rate: {rate:.2f} users/sec | ETA: {eta_str}"
441
+ )
442
+
443
+ tasks = [asyncio.create_task(process_and_save(username)) for username in to_process]
444
+ await asyncio.gather(*tasks)
445
+
446
+ print(f"Finished writing deep extraction results to {OUTPUT_FILENAME}")
447
+
448
+
449
+ if __name__ == "__main__":
450
+ asyncio.run(main())
@@ -0,0 +1,14 @@
1
+ # tweet-thread-from-blog -- Environment Variables
2
+ # =================================================
3
+ # The agent writes the thread -- no LLM API key needed.
4
+ # Composio is optional, for direct X/Twitter posting.
5
+
6
+ # Required for direct posting via Composio.
7
+ # Without this key, the agent outputs the thread as numbered text for copy-paste.
8
+ #
9
+ # Setup steps:
10
+ # 1. Get your API key at: https://app.composio.dev/settings
11
+ # 2. Connect your X/Twitter account at: https://app.composio.dev/app/twitter
12
+ # 3. Complete the OAuth flow
13
+ # 4. The agent posts via TWITTER_CREATION_OF_A_POST + reply chains
14
+ COMPOSIO_API_KEY=your_composio_api_key_here
@@ -0,0 +1,109 @@
1
+ # tweet-thread-from-blog
2
+
3
+ <img width="1280" height="640" alt="tweet-thread-from-blog" src="https://github.com/user-attachments/assets/18b96a6b-9477-444d-b169-ea14a63e9fdf" />
4
+
5
+
6
+ Turn any blog post or article into a Twitter/X thread. The agent reads the content, picks the right thread style, and writes 7-10 tweets with a strong hook, one insight per tweet, and a CTA. Optionally posts the full thread to X via Composio using a reply chain.
7
+
8
+ ## Thread Styles
9
+
10
+ | Style | Use When | Example Input |
11
+ |-------|----------|---------------|
12
+ | Data/Insight | Evidence-based article with stats or research findings | Engineering blog post with benchmark numbers |
13
+ | How-To | Tutorial or step-by-step guide | "How to set up X in 10 minutes" post |
14
+ | Story/Journey | Personal experience, build log, lessons learned | Indie hacker retrospective |
15
+ | Hot Take | Opinion piece, contrarian argument | "Why X is wrong" editorial |
16
+
17
+ The agent auto-detects the right style. Override it by specifying the style in your prompt.
18
+
19
+ ## Requirements
20
+
21
+ No LLM API key needed. The agent reads the page and writes the thread.
22
+
23
+ **Note on X/Twitter posting:** Twitter's API v2 now requires a paid developer account (Basic tier, $100/month minimum). As a result, the Composio Twitter integration returns a 403 error for most users. The skill still generates complete, ready-to-post threads — just copy-paste them manually. Direct posting via Composio is documented below but is only viable if you have a paid Twitter developer account connected.
24
+
25
+ ## Setup
26
+
27
+ ### 1. Configure environment variables (optional)
28
+
29
+ ```bash
30
+ cp .env.example .env
31
+ # Add COMPOSIO_API_KEY if you want direct posting to X
32
+ ```
33
+
34
+ ### 2. Connect X/Twitter via Composio (optional, requires paid Twitter developer account)
35
+
36
+ Twitter's API v2 now requires a paid developer account before Composio can post on your behalf. If you get a 403 error, this is why. Skip this step and use copy-paste output instead.
37
+
38
+ 1. Sign up for Twitter Developer Portal (Basic tier, ~$100/month): https://developer.twitter.com/en/portal/products
39
+ 2. Get your Composio API key at: https://app.composio.dev/settings
40
+ 3. Connect your X/Twitter account at: https://app.composio.dev/app/twitter
41
+ 4. Complete the OAuth flow
42
+
43
+ ## How to Use
44
+
45
+ From a URL:
46
+
47
+ ```
48
+ "Turn this into a tweet thread: https://example.com/blog/post"
49
+ "Create a Twitter thread from this article: https://example.com/post"
50
+ "Write a tweet thread about this blog post: https://example.com/tutorial"
51
+ ```
52
+
53
+ From pasted content:
54
+
55
+ ```
56
+ "Make a thread from this: [paste article text]"
57
+ "Turn this into a Twitter thread: [paste blog post]"
58
+ ```
59
+
60
+ With a style override:
61
+
62
+ ```
63
+ "Turn this into a How-To thread: https://example.com/data-article"
64
+ "Write a Hot Take thread from this: https://example.com/opinion"
65
+ ```
66
+
67
+ With direct posting (requires paid Twitter developer account + Composio setup):
68
+
69
+ ```
70
+ "Post this blog post as a Twitter thread: https://example.com/post"
71
+ ```
72
+
73
+ ## Output
74
+
75
+ | Output | Description |
76
+ |--------|-------------|
77
+ | Full thread | 7-10 numbered tweets, each under 280 characters |
78
+ | Alternative hook | A second hook tweet in a different format |
79
+ | Posted confirmation | If COMPOSIO_API_KEY is set and you confirm, the thread posts as a reply chain on X |
80
+
81
+ ## How Threads Are Formatted
82
+
83
+ Hook first. Tweet 1 leads with the most surprising insight or a curiosity gap. It never announces the thread.
84
+
85
+ One idea per tweet. If a tweet can be split, it gets split. Each tweet stands alone.
86
+
87
+ Numbered throughout. Every tweet starts with its position: "1/8", "2/8", etc.
88
+
89
+ CTA last. The final tweet is the only one with a URL or call to action.
90
+
91
+ No hashtags. Hashtags reduce the quality signal of technical threads.
92
+
93
+ ## Project Structure
94
+
95
+ ```
96
+ tweet-thread-from-blog/
97
+ ├── SKILL.md
98
+ ├── README.md
99
+ ├── .env.example
100
+ ├── evals/
101
+ │ └── evals.json
102
+ └── references/
103
+ ├── thread-format.md
104
+ └── output-template.md
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT