@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.
- package/.claude/skills/claude-md-generator/.env.example +7 -0
- package/.claude/skills/claude-md-generator/README.md +78 -0
- package/.claude/skills/claude-md-generator/SKILL.md +248 -0
- package/.claude/skills/claude-md-generator/evals/evals.json +35 -0
- package/.claude/skills/claude-md-generator/references/section-guide.md +175 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +62 -0
- package/dist/fs-adapters.d.ts +4 -0
- package/dist/fs-adapters.js +101 -0
- package/dist/fs-adapters.test.d.ts +1 -0
- package/dist/fs-adapters.test.js +108 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +211 -0
- package/dist/transformers.d.ts +6 -0
- package/dist/transformers.js +2 -0
- package/package.json +25 -0
- package/registry.json +226 -0
- package/skills/blog-cover-image-cli/.github/workflows/publish.yml +19 -0
- package/skills/blog-cover-image-cli/LICENSE +15 -0
- package/skills/blog-cover-image-cli/README.md +126 -0
- package/skills/blog-cover-image-cli/SKILL.md +7 -0
- package/skills/blog-cover-image-cli/agent-skill/blog-cover-generator/README.md +30 -0
- package/skills/blog-cover-image-cli/agent-skill/blog-cover-generator/SKILL.md +72 -0
- package/skills/blog-cover-image-cli/bin/cli.js +226 -0
- package/skills/blog-cover-image-cli/examples/100x_UX_Research_AI_Agent.png +0 -0
- package/skills/blog-cover-image-cli/examples/Firecrawl-supabase-bolt.png +0 -0
- package/skills/blog-cover-image-cli/examples/Git-City_Case_study_Cover_Image.jpg +0 -0
- package/skills/blog-cover-image-cli/examples/THE DISTRIBUTION LAYER (2).png +0 -0
- package/skills/blog-cover-image-cli/examples/canva-perplexity-duolingo-cover-image.png +0 -0
- package/skills/blog-cover-image-cli/examples/gamma-mistral-veed.png +0 -0
- package/skills/blog-cover-image-cli/examples/server-survival-case-study-cover-image(1).png +0 -0
- package/skills/blog-cover-image-cli/examples/viral-meme-automation.png +0 -0
- package/skills/blog-cover-image-cli/index.js +2 -0
- package/skills/blog-cover-image-cli/package-lock.json +2238 -0
- package/skills/blog-cover-image-cli/package.json +37 -0
- package/skills/blog-cover-image-cli/src/geminiGenerator.js +126 -0
- package/skills/blog-cover-image-cli/src/imageValidator.js +54 -0
- package/skills/blog-cover-image-cli/src/logoFetcher.js +86 -0
- package/skills/claude-md-generator/.env.example +7 -0
- package/skills/claude-md-generator/README.md +78 -0
- package/skills/claude-md-generator/SKILL.md +254 -0
- package/skills/claude-md-generator/evals/evals.json +35 -0
- package/skills/claude-md-generator/references/section-guide.md +175 -0
- package/skills/cook-the-blog/README.md +86 -0
- package/skills/cook-the-blog/SKILL.md +130 -0
- package/skills/dependency-update-bot/.env.example +13 -0
- package/skills/dependency-update-bot/README.md +101 -0
- package/skills/dependency-update-bot/SKILL.md +376 -0
- package/skills/dependency-update-bot/evals/evals.json +45 -0
- package/skills/dependency-update-bot/references/changelog-patterns.md +201 -0
- package/skills/docs-from-code/.env.example +13 -0
- package/skills/docs-from-code/README.md +97 -0
- package/skills/docs-from-code/SKILL.md +160 -0
- package/skills/docs-from-code/evals/evals.json +29 -0
- package/skills/docs-from-code/references/extraction-guide.md +174 -0
- package/skills/docs-from-code/references/output-template.md +135 -0
- package/skills/docs-from-code/scripts/extract_py.py +238 -0
- package/skills/docs-from-code/scripts/extract_ts.ts +284 -0
- package/skills/docs-from-code/scripts/package.json +18 -0
- package/skills/explain-this-pr/README.md +74 -0
- package/skills/explain-this-pr/SKILL.md +130 -0
- package/skills/explain-this-pr/evals/evals.json +35 -0
- package/skills/google-trends-api-skills/README.md +78 -0
- package/skills/google-trends-api-skills/SKILL.md +7 -0
- package/skills/google-trends-api-skills/google-trends-api/SKILL.md +163 -0
- package/skills/google-trends-api-skills/google-trends-api/references/api-responses.md +188 -0
- package/skills/google-trends-api-skills/google-trends-api/scripts/discover_keywords.py +344 -0
- package/skills/google-trends-api-skills/seo-keyword-research/SKILL.md +205 -0
- package/skills/google-trends-api-skills/seo-keyword-research/references/keyword-placement-guide.md +89 -0
- package/skills/google-trends-api-skills/seo-keyword-research/references/tech-blog-examples.md +207 -0
- package/skills/google-trends-api-skills/seo-keyword-research/scripts/blog_seo_research.py +373 -0
- package/skills/hackernews-intel/.env.example +33 -0
- package/skills/hackernews-intel/README.md +161 -0
- package/skills/hackernews-intel/SKILL.md +156 -0
- package/skills/hackernews-intel/evals/evals.json +35 -0
- package/skills/hackernews-intel/package.json +15 -0
- package/skills/hackernews-intel/scripts/monitor-hn.js +258 -0
- package/skills/kill-the-standup/.env.example +22 -0
- package/skills/kill-the-standup/README.md +84 -0
- package/skills/kill-the-standup/SKILL.md +169 -0
- package/skills/kill-the-standup/evals/evals.json +35 -0
- package/skills/kill-the-standup/references/standup-format.md +102 -0
- package/skills/linkedin-post-generator/.env.example +14 -0
- package/skills/linkedin-post-generator/README.md +107 -0
- package/skills/linkedin-post-generator/SKILL.md +228 -0
- package/skills/linkedin-post-generator/evals/evals.json +35 -0
- package/skills/linkedin-post-generator/references/linkedin-format.md +216 -0
- package/skills/linkedin-post-generator/references/output-template.md +154 -0
- package/skills/llms-txt-generator/.env.example +18 -0
- package/skills/llms-txt-generator/README.md +142 -0
- package/skills/llms-txt-generator/SKILL.md +176 -0
- package/skills/llms-txt-generator/evals/evals.json +35 -0
- package/skills/llms-txt-generator/references/llms-txt-spec.md +88 -0
- package/skills/llms-txt-generator/references/output-template.md +76 -0
- package/skills/llms-txt-generator/test-output/genzcareer.in/llms.txt +31 -0
- package/skills/luma-attendees-scraper/README.md +170 -0
- package/skills/luma-attendees-scraper/SKILL.md +7 -0
- package/skills/luma-attendees-scraper/luma_attendees_export.js +223 -0
- package/skills/meeting-brief-generator/.env.example +21 -0
- package/skills/meeting-brief-generator/README.md +90 -0
- package/skills/meeting-brief-generator/SKILL.md +275 -0
- package/skills/meeting-brief-generator/evals/evals.json +35 -0
- package/skills/meeting-brief-generator/references/brief-format.md +114 -0
- package/skills/meeting-brief-generator/references/output-template.md +150 -0
- package/skills/meta-ads-skill/README.md +100 -0
- package/skills/meta-ads-skill/SKILL.md +7 -0
- package/skills/meta-ads-skill/meta-ads-skill/SKILL.md +41 -0
- package/skills/meta-ads-skill/meta-ads-skill/references/report_templates.md +47 -0
- package/skills/meta-ads-skill/meta-ads-skill/references/workflows.md +51 -0
- package/skills/meta-ads-skill/meta-ads-skill/scripts/auth_check.py +22 -0
- package/skills/meta-ads-skill/meta-ads-skill/scripts/formatters.py +46 -0
- package/skills/newsletter-digest/.env.example +20 -0
- package/skills/newsletter-digest/README.md +147 -0
- package/skills/newsletter-digest/SKILL.md +221 -0
- package/skills/newsletter-digest/evals/evals.json +35 -0
- package/skills/newsletter-digest/feeds.json +7 -0
- package/skills/newsletter-digest/package.json +15 -0
- package/skills/newsletter-digest/references/digest-format.md +123 -0
- package/skills/newsletter-digest/references/output-template.md +136 -0
- package/skills/newsletter-digest/scripts/fetch-feeds.js +141 -0
- package/skills/newsletter-digest/scripts/ghost-publish.js +147 -0
- package/skills/noise2blog/.env.example +16 -0
- package/skills/noise2blog/README.md +107 -0
- package/skills/noise2blog/SKILL.md +229 -0
- package/skills/noise2blog/evals/evals.json +35 -0
- package/skills/noise2blog/references/blog-format.md +188 -0
- package/skills/noise2blog/references/output-template.md +184 -0
- package/skills/outreach-sequence-builder/.env.example +12 -0
- package/skills/outreach-sequence-builder/README.md +108 -0
- package/skills/outreach-sequence-builder/SKILL.md +248 -0
- package/skills/outreach-sequence-builder/evals/evals.json +36 -0
- package/skills/outreach-sequence-builder/references/output-template.md +171 -0
- package/skills/outreach-sequence-builder/references/sequence-format.md +167 -0
- package/skills/outreach-sequence-builder/references/signal-playbook.md +117 -0
- package/skills/position-me/README.md +71 -0
- package/skills/position-me/SKILL.md +7 -0
- package/skills/position-me/position-me/SKILL.md +50 -0
- package/skills/position-me/position-me/references/EVALUATION_SOP.md +40 -0
- package/skills/position-me/position-me/references/REPORT_TEMPLATE.md +58 -0
- package/skills/position-me/position-me/scripts/extract_links.py +49 -0
- package/skills/pr-description-writer/README.md +81 -0
- package/skills/pr-description-writer/SKILL.md +141 -0
- package/skills/pr-description-writer/evals/evals.json +35 -0
- package/skills/pr-description-writer/references/pr-format-guide.md +145 -0
- package/skills/producthunt-launch-kit/.env.example +7 -0
- package/skills/producthunt-launch-kit/README.md +95 -0
- package/skills/producthunt-launch-kit/SKILL.md +380 -0
- package/skills/producthunt-launch-kit/evals/evals.json +35 -0
- package/skills/producthunt-launch-kit/references/copy-rules.md +124 -0
- package/skills/reddit-icp-monitor/.env.example +16 -0
- package/skills/reddit-icp-monitor/README.md +117 -0
- package/skills/reddit-icp-monitor/SKILL.md +271 -0
- package/skills/reddit-icp-monitor/evals/evals.json +40 -0
- package/skills/reddit-icp-monitor/references/icp-format.md +131 -0
- package/skills/reddit-icp-monitor/references/reply-rules.md +110 -0
- package/skills/reddit-post-engine/.env.example +13 -0
- package/skills/reddit-post-engine/README.md +103 -0
- package/skills/reddit-post-engine/SKILL.md +303 -0
- package/skills/reddit-post-engine/evals/evals.json +35 -0
- package/skills/reddit-post-engine/references/subreddit-playbook.md +156 -0
- package/skills/schema-markup-generator/.env.example +19 -0
- package/skills/schema-markup-generator/README.md +114 -0
- package/skills/schema-markup-generator/SKILL.md +192 -0
- package/skills/schema-markup-generator/evals/evals.json +35 -0
- package/skills/schema-markup-generator/references/json-ld-spec.md +263 -0
- package/skills/schema-markup-generator/references/output-template.md +556 -0
- package/skills/show-hn-writer/.env.example +14 -0
- package/skills/show-hn-writer/README.md +88 -0
- package/skills/show-hn-writer/SKILL.md +303 -0
- package/skills/show-hn-writer/evals/evals.json +35 -0
- package/skills/show-hn-writer/references/hn-rules.md +74 -0
- package/skills/show-hn-writer/references/title-formulas.md +93 -0
- package/skills/stargazer/README.md +79 -0
- package/skills/stargazer/SKILL.md +7 -0
- package/skills/stargazer/stargazer-skill/SKILL.md +58 -0
- package/skills/stargazer/stargazer-skill/assets/.env.example +18 -0
- package/skills/stargazer/stargazer-skill/scripts/convert_to_csv.py +63 -0
- package/skills/stargazer/stargazer-skill/scripts/count_emails.py +52 -0
- package/skills/stargazer/stargazer-skill/scripts/stargazer_deep_extractor.py +450 -0
- package/skills/tweet-thread-from-blog/.env.example +14 -0
- package/skills/tweet-thread-from-blog/README.md +109 -0
- package/skills/tweet-thread-from-blog/SKILL.md +177 -0
- package/skills/tweet-thread-from-blog/evals/evals.json +35 -0
- package/skills/tweet-thread-from-blog/references/output-template.md +193 -0
- package/skills/tweet-thread-from-blog/references/thread-format.md +107 -0
- package/skills/twitter-GTM-find-skill/README.md +43 -0
- package/skills/twitter-GTM-find-skill/SKILL.md +7 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/SKILL.md +37 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/references/icp-checklist.md +35 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/package.json +23 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/run_pipeline.sh +8 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/debug.ts +23 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/extractor.ts +79 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/icp-filter.ts +87 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/index.ts +94 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/src/scraper.ts +41 -0
- package/skills/twitter-GTM-find-skill/twitter-GTM-find/scripts/tsconfig.json +13 -0
- package/skills/yc-intent-radar-skill/README.md +39 -0
- package/skills/yc-intent-radar-skill/SKILL.md +7 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/SKILL.md +59 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/auth.js +29 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/db.js +62 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/export_radar_candidates.js +40 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/package-lock.json +1525 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/package.json +12 -0
- package/skills/yc-intent-radar-skill/yc-jobs-scraper/scripts/scraper.js +217 -0
- package/src/e2e.test.ts +35 -0
- package/src/fs-adapters.test.ts +91 -0
- package/src/fs-adapters.ts +65 -0
- package/src/index.ts +182 -0
- package/src/transformers.ts +6 -0
- 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
|