Skip to content

Commit 5cfc4ce

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
code cleanup
1 parent 2f9bb2b commit 5cfc4ce

File tree

4 files changed

+183
-15
lines changed

4 files changed

+183
-15
lines changed

pkg/cache/cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const errorTTL = 5 * 24 * time.Hour // Cache HTTP errors for 5 days
1919
var globalRateLimiter = newGlobalRateLimiter()
2020

2121
func newGlobalRateLimiter() *DomainRateLimiter {
22-
r := NewDomainRateLimiter(600 * time.Millisecond)
22+
r := NewDomainRateLimiter(200 * time.Millisecond)
2323
r.SetDomainDelay("www.linkedin.com", 1200*time.Millisecond)
2424
return r
2525
}

pkg/cache/ratelimit.go

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ func (r *DomainRateLimiter) SetDomainDelay(domain string, delay time.Duration) {
3434
// Wait blocks until it's safe to make a request to the given URL's domain.
3535
// It ensures at least minDelay has passed since the last request to that domain.
3636
func (r *DomainRateLimiter) Wait(rawURL string) {
37-
domain := extractDomain(rawURL)
38-
if domain == "" {
37+
u, err := url.Parse(rawURL)
38+
if err != nil || u.Host == "" {
3939
return
4040
}
41+
domain := u.Host
4142

4243
// Get or create per-domain mutex
4344
muI, _ := r.mu.LoadOrStore(domain, &sync.Mutex{})
@@ -70,12 +71,3 @@ func (r *DomainRateLimiter) Wait(rawURL string) {
7071
// Record this request
7172
r.lastRequest.Store(domain, time.Now())
7273
}
73-
74-
// extractDomain returns the host portion of a URL, or empty string on error.
75-
func extractDomain(rawURL string) string {
76-
u, err := url.Parse(rawURL)
77-
if err != nil {
78-
return ""
79-
}
80-
return u.Host
81-
}

pkg/github/github.go

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ func (e *APIError) Error() string {
224224
}
225225

226226
func (c *Client) fetchAPI(ctx context.Context, urlStr, username string) (*profile.Profile, error) {
227+
// Try GraphQL first (gets social accounts), fall back to REST API
228+
if c.token != "" {
229+
prof, err := c.fetchGraphQL(ctx, urlStr, username)
230+
if err == nil {
231+
return prof, nil
232+
}
233+
c.logger.WarnContext(ctx, "GraphQL fetch failed, falling back to REST API", "error", err)
234+
}
235+
236+
// REST API fallback
227237
apiURL := "https://api.github.com/users/" + username
228238

229239
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody)
@@ -245,6 +255,161 @@ func (c *Client) fetchAPI(ctx context.Context, urlStr, username string) (*profil
245255
return parseJSON(body, urlStr, username)
246256
}
247257

258+
func (c *Client) fetchGraphQL(ctx context.Context, urlStr, username string) (*profile.Profile, error) {
259+
query := `
260+
query($login: String!) {
261+
user(login: $login) {
262+
name
263+
login
264+
location
265+
bio
266+
company
267+
websiteUrl
268+
twitterUsername
269+
createdAt
270+
updatedAt
271+
272+
socialAccounts(first: 10) {
273+
nodes {
274+
provider
275+
url
276+
displayName
277+
}
278+
}
279+
280+
followers {
281+
totalCount
282+
}
283+
following {
284+
totalCount
285+
}
286+
287+
repositories(first: 1, ownerAffiliations: OWNER) {
288+
totalCount
289+
}
290+
}
291+
}
292+
`
293+
294+
variables := map[string]string{"login": username}
295+
reqBody := map[string]any{
296+
"query": query,
297+
"variables": variables,
298+
}
299+
300+
jsonData, err := json.Marshal(reqBody)
301+
if err != nil {
302+
return nil, fmt.Errorf("marshaling GraphQL request: %w", err)
303+
}
304+
305+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.github.com/graphql", strings.NewReader(string(jsonData)))
306+
if err != nil {
307+
return nil, err
308+
}
309+
req.Header.Set("Authorization", "Bearer "+c.token)
310+
req.Header.Set("Content-Type", "application/json")
311+
req.Header.Set("User-Agent", "sociopath/1.0")
312+
313+
body, err := c.doAPIRequest(ctx, req)
314+
if err != nil {
315+
return nil, err
316+
}
317+
318+
return parseGraphQLResponse(body, urlStr, username)
319+
}
320+
321+
func parseGraphQLResponse(data []byte, urlStr, _ string) (*profile.Profile, error) {
322+
var response struct {
323+
Errors []struct {
324+
Message string `json:"message"`
325+
} `json:"errors"`
326+
Data struct {
327+
User struct {
328+
Name string `json:"name"`
329+
Login string `json:"login"`
330+
Location string `json:"location"`
331+
Bio string `json:"bio"`
332+
Company string `json:"company"`
333+
WebsiteURL string `json:"websiteUrl"`
334+
TwitterUser string `json:"twitterUsername"`
335+
SocialAccounts struct {
336+
Nodes []struct {
337+
URL string `json:"url"`
338+
Provider string `json:"provider"`
339+
DisplayName string `json:"displayName"`
340+
} `json:"nodes"`
341+
} `json:"socialAccounts"`
342+
Followers struct{ TotalCount int } `json:"followers"`
343+
Following struct{ TotalCount int } `json:"following"`
344+
Repositories struct{ TotalCount int } `json:"repositories"`
345+
} `json:"user"`
346+
} `json:"data"`
347+
}
348+
349+
if err := json.Unmarshal(data, &response); err != nil {
350+
return nil, fmt.Errorf("parsing GraphQL response: %w", err)
351+
}
352+
353+
if len(response.Errors) > 0 {
354+
return nil, fmt.Errorf("GraphQL error: %s", response.Errors[0].Message)
355+
}
356+
357+
user := response.Data.User
358+
prof := &profile.Profile{
359+
Platform: platform,
360+
URL: urlStr,
361+
Authenticated: true,
362+
Username: user.Login,
363+
Name: user.Name,
364+
Bio: user.Bio,
365+
Location: user.Location,
366+
Fields: make(map[string]string),
367+
}
368+
369+
// Add website
370+
if user.WebsiteURL != "" {
371+
website := user.WebsiteURL
372+
if !strings.HasPrefix(website, "http") {
373+
website = "https://" + website
374+
}
375+
prof.Website = website
376+
prof.Fields["website"] = website
377+
}
378+
379+
// Add company
380+
if user.Company != "" {
381+
company := strings.TrimPrefix(user.Company, "@")
382+
prof.Fields["company"] = company
383+
}
384+
385+
// Add stats
386+
if user.Repositories.TotalCount > 0 {
387+
prof.Fields["public_repos"] = strconv.Itoa(user.Repositories.TotalCount)
388+
}
389+
if user.Followers.TotalCount > 0 {
390+
prof.Fields["followers"] = strconv.Itoa(user.Followers.TotalCount)
391+
}
392+
if user.Following.TotalCount > 0 {
393+
prof.Fields["following"] = strconv.Itoa(user.Following.TotalCount)
394+
}
395+
396+
// Add Twitter from GraphQL
397+
if user.TwitterUser != "" {
398+
twitterURL := "https://twitter.com/" + user.TwitterUser
399+
prof.Fields["twitter"] = twitterURL
400+
prof.SocialLinks = append(prof.SocialLinks, twitterURL)
401+
}
402+
403+
// Add social accounts from GraphQL - this is the key improvement!
404+
for _, social := range user.SocialAccounts.Nodes {
405+
if social.URL != "" {
406+
prof.SocialLinks = append(prof.SocialLinks, social.URL)
407+
}
408+
}
409+
410+
return prof, nil
411+
}
412+
248413
func (c *Client) doAPIRequest(ctx context.Context, req *http.Request) ([]byte, error) {
249414
// Check cache first
250415
cacheKey := req.URL.String()
@@ -272,8 +437,10 @@ func (c *Client) doAPIRequest(ctx context.Context, req *http.Request) ([]byte, e
272437
defer func() { _ = resp.Body.Close() }() //nolint:errcheck // error ignored intentionally
273438

274439
// Parse rate limit headers (GitHub uses non-canonical casing, parse errors default to 0)
275-
rateLimitRemain, _ := strconv.Atoi(resp.Header.Get("X-RateLimit-Remaining")) //nolint:errcheck,canonicalheader // ok
276-
rateLimitReset, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64) //nolint:errcheck,canonicalheader // ok
440+
//nolint:errcheck,canonicalheader // GitHub uses non-canonical header casing
441+
rateLimitRemain, _ := strconv.Atoi(resp.Header.Get("X-RateLimit-Remaining"))
442+
//nolint:errcheck,canonicalheader // GitHub uses non-canonical header casing
443+
rateLimitReset, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64)
277444
resetTime := time.Unix(rateLimitReset, 0)
278445

279446
if resp.StatusCode != http.StatusOK {

pkg/sociopath/sociopath.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@ var (
7171
type Option func(*config)
7272

7373
type config struct {
74-
cookies map[string]string
7574
cache cache.HTTPCache
75+
cookies map[string]string
7676
logger *slog.Logger
77+
githubToken string
7778
browserCookies bool
7879
}
7980

@@ -97,6 +98,11 @@ func WithLogger(logger *slog.Logger) Option {
9798
return func(c *config) { c.logger = logger }
9899
}
99100

101+
// WithGitHubToken sets the GitHub API token for authenticated requests.
102+
func WithGitHubToken(token string) Option {
103+
return func(c *config) { c.githubToken = token }
104+
}
105+
100106
// Fetch retrieves a profile from the given URL.
101107
// The platform is automatically detected from the URL.
102108
func Fetch(ctx context.Context, url string, opts ...Option) (*profile.Profile, error) {
@@ -374,6 +380,9 @@ func fetchGitHub(ctx context.Context, url string, cfg *config) (*profile.Profile
374380
if cfg.logger != nil {
375381
opts = append(opts, github.WithLogger(cfg.logger))
376382
}
383+
if cfg.githubToken != "" {
384+
opts = append(opts, github.WithToken(cfg.githubToken))
385+
}
377386

378387
client, err := github.New(ctx, opts...)
379388
if err != nil {

0 commit comments

Comments
 (0)