pingrabber 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pingrabber/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ pingrabber
3
+ ==========
4
+
5
+ Thư viện đơn giản giúp cào (scrape) ảnh chất lượng cao từ một board
6
+ Pinterest công khai thông qua RSS feed của Pinterest.
7
+
8
+ Cách dùng nhanh:
9
+
10
+ import pingrabber
11
+
12
+ # Tải ảnh từ board hoặc pin đơn lẻ (tự nhận diện loại URL)
13
+ pingrabber.download("https://www.pinterest.com/username/boardname/")
14
+
15
+ # Tìm theo từ khóa, lấy link ảnh raw (không tải file về máy)
16
+ links = pingrabber.search("thiên nhiên")
17
+
18
+ """
19
+
20
+ from .core import PinGrabber, download, search
21
+
22
+ __all__ = ["PinGrabber", "download", "search"]
23
+
24
+ __version__ = "0.1.7"
pingrabber/core.py ADDED
@@ -0,0 +1,749 @@
1
+ """
2
+ pingrabber.core
3
+ ~~~~~~~~~~~~~~~~
4
+
5
+ Logic chính: tải ảnh chất lượng cao từ Pinterest, hỗ trợ 2 dạng URL:
6
+
7
+ 1. URL BOARD (pinterest.com/<user>/<board>/):
8
+ Lấy RSS feed của board, phân tích bằng BeautifulSoup để tìm các
9
+ liên kết ảnh, sau đó tải ảnh gốc (chất lượng cao nhất) về máy.
10
+
11
+ 2. URL PIN ĐƠN LẺ (pinterest.com/pin/<id>/):
12
+ Pin lẻ không có RSS feed riêng, nên thư viện sẽ fetch trực tiếp
13
+ trang HTML của pin và lấy ảnh từ thẻ <meta property="og:image">.
14
+
15
+ Hàm `download()` / `PinGrabber.download()` tự động nhận diện loại URL
16
+ và xử lý phù hợp, người dùng không cần phân biệt thủ công.
17
+
18
+ Ghi chú kỹ thuật:
19
+ Pinterest cung cấp RSS feed công khai cho mỗi board theo dạng:
20
+ https://www.pinterest.com/<user>/<board>.rss
21
+
22
+ Mỗi <item> trong feed chứa một đoạn HTML (trong <description>) có thẻ
23
+ <img src="..."> trỏ tới ảnh thumbnail. URL ảnh thumbnail thường có dạng:
24
+ https://i.pinimg.com/236x/xx/xx/xx/xxxxxxx.jpg
25
+
26
+ Để lấy ảnh gốc (kích thước lớn nhất), ta thay thế phần kích thước
27
+ (ví dụ "236x") bằng "originals".
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ import time
35
+ import random
36
+ import logging
37
+ from typing import List, Optional
38
+ from urllib.parse import urlparse
39
+
40
+ import requests
41
+ from bs4 import BeautifulSoup
42
+
43
+ # `ddgs` (trước đây là `duckduckgo-search`) là dependency TÙY CHỌN, chỉ dùng
44
+ # cho tính năng search() theo từ khóa. Nếu chưa cài, search() sẽ tự động
45
+ # rơi về phương án dự phòng dùng requests + BeautifulSoup thuần.
46
+ try:
47
+ from ddgs import DDGS # package mới, khuyến nghị
48
+ _HAS_DDGS = True
49
+ except ImportError:
50
+ try:
51
+ from duckduckgo_search import DDGS # tên package cũ, vẫn còn trên PyPI
52
+ _HAS_DDGS = True
53
+ except ImportError:
54
+ DDGS = None
55
+ _HAS_DDGS = False
56
+
57
+ logger = logging.getLogger("pingrabber")
58
+ logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
59
+
60
+ # Danh sách User-Agent thật để xoay vòng, giảm khả năng bị nhận diện là bot
61
+ # khi gọi đến công cụ tìm kiếm (search engine thường nhạy với UA hơn Pinterest).
62
+ USER_AGENTS = [
63
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
64
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
65
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
66
+ "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
67
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
68
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
69
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) "
70
+ "Gecko/20100101 Firefox/125.0",
71
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) "
72
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
73
+ ]
74
+
75
+ # User-Agent mặc định dùng cho các request tải ảnh/RSS (ổn định, không cần xoay)
76
+ DEFAULT_HEADERS = {
77
+ "User-Agent": USER_AGENTS[0],
78
+ }
79
+
80
+
81
+ def _random_search_headers() -> dict:
82
+ """Tạo header ngẫu nhiên (User-Agent + Accept-Language) cho mỗi lần search,
83
+ giúp giảm khả năng bị nhận diện và chặn theo pattern cố định."""
84
+ return {
85
+ "User-Agent": random.choice(USER_AGENTS),
86
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
87
+ "Accept-Language": random.choice(["en-US,en;q=0.9", "vi-VN,vi;q=0.9,en;q=0.8"]),
88
+ }
89
+
90
+ # Regex để tìm URL ảnh i.pinimg.com bên trong đoạn HTML mô tả của RSS item
91
+ IMG_URL_PATTERN = re.compile(
92
+ r"https?://i\.pinimg\.com/[^\s\"'<>]+\.(?:jpg|jpeg|png|gif|webp)",
93
+ re.IGNORECASE,
94
+ )
95
+
96
+ # Các kích thước thumbnail phổ biến mà Pinterest dùng trong URL ảnh,
97
+ # cần thay bằng "originals" để lấy ảnh gốc chất lượng cao nhất.
98
+ THUMBNAIL_SIZE_PATTERN = re.compile(
99
+ r"/(\d+x(?:\d+)?|originals)/", re.IGNORECASE
100
+ )
101
+
102
+ # Nhận diện URL pin đơn lẻ, ví dụ: pinterest.com/pin/119134352618387326/
103
+ SINGLE_PIN_PATTERN = re.compile(r"^/pin/(\d+)", re.IGNORECASE)
104
+
105
+ # Danh sách công cụ tìm kiếm để thử lần lượt (fallback) khi một engine bị
106
+ # chặn hoặc trả lỗi. Mỗi engine có format URL kết quả khác nhau nên cần
107
+ # parser riêng (xem hàm _parse_search_results).
108
+ SEARCH_ENGINES = [
109
+ {
110
+ "name": "duckduckgo_html",
111
+ "url": "https://html.duckduckgo.com/html/",
112
+ "param": "q",
113
+ },
114
+ {
115
+ "name": "duckduckgo_lite",
116
+ "url": "https://lite.duckduckgo.com/lite/",
117
+ "param": "q",
118
+ },
119
+ {
120
+ "name": "bing",
121
+ "url": "https://www.bing.com/search",
122
+ "param": "q",
123
+ },
124
+ {
125
+ "name": "brave",
126
+ "url": "https://search.brave.com/search",
127
+ "param": "q",
128
+ },
129
+ {
130
+ "name": "yandex_lite",
131
+ "url": "https://yandex.com", # Bản Lite của Yandex Nga, HTML siêu nhẹ, ít check bot
132
+ "param": "text",
133
+ },
134
+ {
135
+ "name": "yahoo",
136
+ "url": "https://yahoo.com", # HTML lỏng lẻo giống Bing, quét rất mượt
137
+ "param": "p",
138
+ },
139
+ {
140
+ "name": "ecosia",
141
+ "url": "https://ecosia.org", # Sử dụng lõi Bing nhưng hệ thống lọc bot lỏng hơn
142
+ "param": "q",
143
+ },
144
+ {
145
+ "name": "qwant_lite",
146
+ "url": "https://qwant.com", # Phiên bản không JavaScript của công cụ tìm kiếm Pháp
147
+ "param": "q",
148
+ },
149
+ {
150
+ "name": "mojeek",
151
+ "url": "https://mojeek.com", # Bộ máy độc lập hoàn toàn, không có hệ thống chống bot gắt gao
152
+ "param": "q",
153
+ },
154
+ {
155
+ "name": "gibiru",
156
+ "url": "https://gibiru.com", # Trang tìm kiếm ẩn danh, cấu trúc HTML thô sơ
157
+ "param": "q",
158
+ },
159
+ {
160
+ "name": "ask",
161
+ "url": "https://ask.com", # Trang tìm kiếm lâu đời, máy chủ lọc bot rất yếu
162
+ "param": "q",
163
+ },
164
+ {
165
+ "name": "seznam",
166
+ "url": "https://seznam.cz", # Công cụ tìm kiếm nội địa của Séc, bot cào thoải mái
167
+ "param": "q",
168
+ }
169
+ ]
170
+
171
+
172
+ # Regex nhận diện URL board hợp lệ trong kết quả tìm kiếm:
173
+ # pinterest.com/<user>/<board>/ nhưng KHÔNG phải /pin/..., /search/..., /explore/...
174
+ BOARD_URL_PATTERN = re.compile(
175
+ r"^https?://(?:[a-z]{2,3}\.)?pinterest\.[a-z.]+/"
176
+ r"(?!pin/|search/|explore/|today/)([\w.\-%]+)/([\w.\-%]+)/?$",
177
+ re.IGNORECASE,
178
+ )
179
+
180
+
181
+ class PinGrabberError(Exception):
182
+ """Lỗi tùy biến cho thư viện pingrabber."""
183
+
184
+
185
+ class PinGrabber:
186
+ """Đối tượng chính để cào và tải ảnh từ một board Pinterest."""
187
+
188
+ def __init__(self, timeout: int = 15, session: Optional[requests.Session] = None):
189
+ self.timeout = timeout
190
+ self.session = session or requests.Session()
191
+ self.session.headers.update(DEFAULT_HEADERS)
192
+
193
+ # ------------------------------------------------------------------ #
194
+ # Tiện ích: kiểm tra URL là pin đơn lẻ hay board
195
+ # ------------------------------------------------------------------ #
196
+ @staticmethod
197
+ def is_single_pin_url(url: str) -> bool:
198
+ """Trả về True nếu URL có dạng pinterest.com/pin/<id>/ (pin đơn lẻ)."""
199
+ parsed = urlparse(url)
200
+ return bool(SINGLE_PIN_PATTERN.match(parsed.path))
201
+
202
+ # ------------------------------------------------------------------ #
203
+ # TÌM KIẾM THEO TỪ KHÓA
204
+ # ------------------------------------------------------------------ #
205
+ @staticmethod
206
+ def _extract_bing_real_url(href: str) -> Optional[str]:
207
+ """Bing thường bọc link thật trong link redirect dạng base64
208
+ (tham số 'u', tiền tố 'a1' + base64 của URL thật). Hàm này giải mã
209
+ ra URL thật nếu có, ngược lại trả về href gốc không đổi."""
210
+ import base64
211
+ from urllib.parse import urlparse as _urlparse, parse_qs
212
+
213
+ parsed = _urlparse(href)
214
+ qs = parse_qs(parsed.query)
215
+ if "u" in qs:
216
+ raw = qs["u"][0]
217
+ if raw.startswith("a1"):
218
+ raw = raw[2:]
219
+ # Bing dùng base64 URL-safe, có thể thiếu padding '='
220
+ padding = "=" * (-len(raw) % 4)
221
+ try:
222
+ decoded = base64.urlsafe_b64decode(raw + padding).decode("utf-8", errors="ignore")
223
+ return decoded
224
+ except (ValueError, UnicodeDecodeError):
225
+ return href
226
+ return href
227
+
228
+ def _parse_search_results(self, engine_name: str, html: str) -> List[str]:
229
+ """Phân tích HTML kết quả tìm kiếm để lấy danh sách URL board hợp lệ.
230
+ Mỗi engine có cấu trúc HTML khác nhau nên xử lý link hơi khác nhau."""
231
+ soup = BeautifulSoup(html, "html.parser")
232
+ board_urls: List[str] = []
233
+
234
+ for link in soup.find_all("a", href=True):
235
+ href = link["href"]
236
+
237
+ if engine_name == "bing":
238
+ href = self._extract_bing_real_url(href) or href
239
+
240
+ match = BOARD_URL_PATTERN.match(href)
241
+ if match:
242
+ normalized = href.split("?")[0].rstrip("/") + "/"
243
+ if normalized not in board_urls:
244
+ board_urls.append(normalized)
245
+
246
+ return board_urls
247
+
248
+ def _find_boards_via_ddgs(self, keyword: str, max_results: int = 5) -> List[str]:
249
+ """
250
+ Tìm board bằng package `ddgs` (trước đây là `duckduckgo-search`).
251
+
252
+ Ưu điểm so với cách tự gọi requests:
253
+ - Package được cộng đồng duy trì, tự xử lý header/cookie giống
254
+ trình duyệt thật hơn, tỷ lệ bị chặn thấp hơn.
255
+ - Hỗ trợ proxy (http/https/socks5) ngay trong constructor, đây
256
+ là cách hiệu quả nhất để né chặn theo IP nếu bạn có proxy.
257
+ - Tự động thử nhiều backend bên trong (DuckDuckGo, Bing, Google...).
258
+
259
+ Trả về [] nếu ddgs chưa được cài, hoặc nếu gọi thất bại (lỗi sẽ
260
+ được log lại, không raise, để find_boards() có thể rơi về fallback).
261
+ """
262
+ if not _HAS_DDGS:
263
+ return []
264
+
265
+ query = f"site:pinterest.com {keyword}"
266
+ try:
267
+ ddgs = DDGS(timeout=self.timeout)
268
+ try:
269
+ raw_results = ddgs.text(query, max_results=max_results * 3)
270
+ finally:
271
+ # Một số phiên bản DDGS hỗ trợ context manager / .close(),
272
+ # đóng lại nếu có để giải phóng kết nối HTTP.
273
+ close_fn = getattr(ddgs, "close", None)
274
+ if callable(close_fn):
275
+ close_fn()
276
+ except Exception as exc: # noqa: BLE001 - ddgs có thể raise nhiều loại lỗi riêng
277
+ logger.warning(" [ddgs] Gọi thất bại: %s", exc)
278
+ return []
279
+
280
+ board_urls: List[str] = []
281
+ for item in raw_results or []:
282
+ # Tùy phiên bản, key có thể là "href" hoặc "url"
283
+ href = item.get("href") or item.get("url") or ""
284
+ if not href:
285
+ continue
286
+ match = BOARD_URL_PATTERN.match(href)
287
+ if match:
288
+ normalized = href.split("?")[0].rstrip("/") + "/"
289
+ if normalized not in board_urls:
290
+ board_urls.append(normalized)
291
+
292
+ return board_urls[:max_results]
293
+
294
+ def _find_boards_via_requests(
295
+ self,
296
+ keyword: str,
297
+ max_results: int = 5,
298
+ max_retries: int = 2,
299
+ delay_seconds: float = 1.5,
300
+ ) -> List[str]:
301
+ """
302
+ Phương án DỰ PHÒNG: tự gọi requests đến nhiều search engine, dùng khi
303
+ package `ddgs` chưa được cài hoặc gọi thất bại. Có xoay User-Agent,
304
+ retry, và fallback giữa nhiều engine (DuckDuckGo HTML/Lite, Bing).
305
+ """
306
+ query = f"site:pinterest.com {keyword}"
307
+ last_error: Optional[Exception] = None
308
+
309
+ for engine in SEARCH_ENGINES:
310
+ for attempt in range(1, max_retries + 1):
311
+ try:
312
+ headers = _random_search_headers()
313
+ resp = self.session.get(
314
+ engine["url"],
315
+ params={engine["param"]: query},
316
+ headers=headers,
317
+ timeout=self.timeout,
318
+ )
319
+
320
+ if resp.status_code in (403, 429):
321
+ logger.warning(
322
+ " [%s] Bị chặn (HTTP %d), thử lại lần %d/%d...",
323
+ engine["name"], resp.status_code, attempt, max_retries,
324
+ )
325
+ time.sleep(delay_seconds + random.uniform(0, 1.5))
326
+ continue
327
+
328
+ resp.raise_for_status()
329
+
330
+ board_urls = self._parse_search_results(engine["name"], resp.text)
331
+ if board_urls:
332
+ logger.info(
333
+ " [%s] Tìm thấy %d board.", engine["name"], len(board_urls)
334
+ )
335
+ return board_urls[:max_results]
336
+
337
+ logger.info(" [%s] Không có kết quả phù hợp, thử engine khác.", engine["name"])
338
+ break # Engine trả về OK nhưng không có board -> chuyển engine khác
339
+
340
+ except requests.RequestException as exc:
341
+ last_error = exc
342
+ logger.warning(
343
+ " [%s] Lỗi request (lần %d/%d): %s",
344
+ engine["name"], attempt, max_retries, exc,
345
+ )
346
+ time.sleep(delay_seconds + random.uniform(0, 1.5))
347
+
348
+ # Nghỉ một chút trước khi chuyển sang engine kế tiếp
349
+ time.sleep(delay_seconds)
350
+
351
+ if last_error:
352
+ logger.error(
353
+ "Tất cả công cụ tìm kiếm đều thất bại. Lỗi cuối: %s. "
354
+ "Mạng của bạn có thể đang bị chặn truy cập search engine — "
355
+ "hãy thử lại sau, đổi mạng/VPN, hoặc tự tìm board qua trình "
356
+ "duyệt rồi dùng download(url_board) trực tiếp.",
357
+ last_error,
358
+ )
359
+ return []
360
+
361
+ def find_boards(
362
+ self,
363
+ keyword: str,
364
+ max_results: int = 5,
365
+ max_retries: int = 2,
366
+ delay_seconds: float = 1.5,
367
+ ) -> List[str]:
368
+ """
369
+ Tìm các URL board Pinterest liên quan đến từ khóa.
370
+
371
+ Thứ tự ưu tiên:
372
+ 1. Dùng package `ddgs` (khuyến nghị) nếu đã cài — package này
373
+ được cộng đồng duy trì, tự xử lý giả lập trình duyệt tốt
374
+ hơn và hỗ trợ proxy, nên tỷ lệ bị chặn thấp hơn nhiều.
375
+ 2. Nếu chưa cài `ddgs` hoặc gọi thất bại, tự động rơi về
376
+ phương án dự phòng: gọi requests trực tiếp đến nhiều search
377
+ engine (DuckDuckGo HTML/Lite, Bing), có xoay User-Agent,
378
+ retry và delay giữa các lần thử.
379
+
380
+ Để dùng phương án 1 (khuyến nghị), cài thêm:
381
+ pip install ddgs
382
+
383
+ Lưu ý: đây KHÔNG phải tìm kiếm trực tiếp trong Pinterest (trang đó
384
+ cần JS render nên requests không đọc được), mà là dùng index của
385
+ công cụ tìm kiếm để tìm ra các board công khai liên quan. Kết quả
386
+ có thể rỗng tùy theo IP/mạng của bạn — nếu vậy, hãy tự tìm board
387
+ qua trình duyệt và dùng download(url_board) trực tiếp.
388
+
389
+ Trả về danh sách URL board (đã loại trùng), tối đa max_results.
390
+ """
391
+ if _HAS_DDGS:
392
+ logger.info("Đang tìm board qua package ddgs...")
393
+ board_urls = self._find_boards_via_ddgs(keyword, max_results=max_results)
394
+ if board_urls:
395
+ logger.info(" [ddgs] Tìm thấy %d board.", len(board_urls))
396
+ return board_urls
397
+ logger.info(" [ddgs] Không có kết quả, chuyển sang phương án dự phòng (requests).")
398
+ else:
399
+ logger.info(
400
+ "Package 'ddgs' chưa được cài (pip install ddgs) — "
401
+ "dùng phương án dự phòng qua requests."
402
+ )
403
+
404
+ return self._find_boards_via_requests(
405
+ keyword,
406
+ max_results=max_results,
407
+ max_retries=max_retries,
408
+ delay_seconds=delay_seconds,
409
+ )
410
+
411
+ def search(
412
+ self,
413
+ keyword: str,
414
+ max_boards: int = 3,
415
+ max_images_per_board: int = 25,
416
+ max_retries: int = 2,
417
+ delay_seconds: float = 1.5,
418
+ ) -> List[str]:
419
+ """
420
+ Tìm board liên quan đến từ khóa, rồi tự động lấy danh sách URL ảnh
421
+ gốc (chất lượng cao) từ các board đó. KHÔNG tải ảnh về máy, chỉ
422
+ trả về danh sách link raw để bạn tự quyết định dùng tiếp.
423
+
424
+ Ví dụ:
425
+ import pingrabber
426
+ links = pingrabber.search("thiên nhiên")
427
+ print(links) # ['https://i.pinimg.com/originals/.../a.jpg', ...]
428
+
429
+ Args:
430
+ keyword: từ khóa tìm kiếm, ví dụ "thiên nhiên", "nature".
431
+ max_boards: số board tối đa sẽ quét qua.
432
+ max_images_per_board: số ảnh tối đa lấy từ mỗi board.
433
+ max_retries: số lần thử lại mỗi search engine nếu bị chặn/lỗi.
434
+ delay_seconds: thời gian nghỉ cơ bản (giây) giữa các lần thử,
435
+ giúp giảm khả năng bị search engine chặn vì gọi quá nhanh.
436
+
437
+ Trả về danh sách URL ảnh gốc (string), rỗng nếu không tìm được gì.
438
+ Nếu trả về rỗng, kiểm tra log WARNING/ERROR để biết nguyên nhân cụ
439
+ thể (bị chặn, hết engine, hay không có board liên quan).
440
+ """
441
+ logger.info("Đang tìm board liên quan đến từ khóa: %s", keyword)
442
+ board_urls = self.find_boards(
443
+ keyword,
444
+ max_results=max_boards,
445
+ max_retries=max_retries,
446
+ delay_seconds=delay_seconds,
447
+ )
448
+
449
+ if not board_urls:
450
+ logger.warning(
451
+ "Không tìm thấy board nào liên quan đến: %s. "
452
+ "Nếu log phía trên cho thấy lỗi 403/429 ở tất cả engine, "
453
+ "khả năng cao mạng/IP của bạn đang bị search engine chặn.",
454
+ keyword,
455
+ )
456
+ return []
457
+
458
+ logger.info("Tìm thấy %d board, đang lấy ảnh...", len(board_urls))
459
+
460
+ all_image_urls: List[str] = []
461
+ for board_url in board_urls:
462
+ try:
463
+ rss_url = self.build_rss_url(board_url)
464
+ rss_content = self.fetch_rss(rss_url)
465
+ image_urls = self.extract_image_urls(rss_content)[:max_images_per_board]
466
+ logger.info(" + %s -> %d ảnh", board_url, len(image_urls))
467
+ for url in image_urls:
468
+ if url not in all_image_urls:
469
+ all_image_urls.append(url)
470
+ # Delay nhẹ giữa các board để tránh gửi request quá dồn dập
471
+ time.sleep(delay_seconds * 0.5)
472
+ except PinGrabberError as exc:
473
+ logger.error(" Lỗi khi xử lý board %s: %s", board_url, exc)
474
+
475
+ logger.info("Hoàn tất. Tổng cộng %d link ảnh raw.", len(all_image_urls))
476
+ return all_image_urls
477
+
478
+ # ------------------------------------------------------------------ #
479
+ # Bước 1: Chuyển URL board thường -> URL RSS feed
480
+ # ------------------------------------------------------------------ #
481
+ @staticmethod
482
+ def build_rss_url(board_url: str) -> str:
483
+ """
484
+ Chuyển một URL board Pinterest thông thường thành URL RSS feed.
485
+
486
+ Ví dụ:
487
+ https://www.pinterest.com/username/boardname/
488
+ -> https://www.pinterest.com/username/boardname.rss
489
+ """
490
+ parsed = urlparse(board_url)
491
+ if "pinterest." not in parsed.netloc:
492
+ raise PinGrabberError(f"URL không hợp lệ, không phải Pinterest: {board_url}")
493
+
494
+ path = parsed.path.strip("/")
495
+ if not path:
496
+ raise PinGrabberError(f"Không tìm thấy đường dẫn board trong URL: {board_url}")
497
+
498
+ if path.endswith(".rss"):
499
+ rss_path = path
500
+ else:
501
+ rss_path = f"{path}.rss"
502
+
503
+ return f"https://{parsed.netloc}/{rss_path}"
504
+
505
+ # ------------------------------------------------------------------ #
506
+ # Bước 2: Lấy nội dung RSS feed
507
+ # ------------------------------------------------------------------ #
508
+ def fetch_rss(self, rss_url: str) -> str:
509
+ """Gửi request lấy nội dung RSS feed (dạng XML/text)."""
510
+ try:
511
+ resp = self.session.get(rss_url, timeout=self.timeout)
512
+ resp.raise_for_status()
513
+ except requests.RequestException as exc:
514
+ raise PinGrabberError(f"Không thể tải RSS feed: {rss_url} ({exc})") from exc
515
+ return resp.text
516
+
517
+ # ------------------------------------------------------------------ #
518
+ # Bước 3: Parse RSS bằng BeautifulSoup -> lấy danh sách URL ảnh gốc
519
+ # ------------------------------------------------------------------ #
520
+ def extract_image_urls(self, rss_content: str) -> List[str]:
521
+ """
522
+ Dùng BeautifulSoup để parse XML của RSS feed, lấy tất cả thẻ <img>
523
+ nằm trong mỗi <item><description>, rồi nâng cấp URL thumbnail
524
+ thành URL ảnh gốc ("originals").
525
+ """
526
+ soup = BeautifulSoup(rss_content, "lxml-xml")
527
+ items = soup.find_all("item")
528
+
529
+ if not items:
530
+ # Một số feed có thể không parse đúng theo "lxml-xml" (hiếm),
531
+ # thử lại bằng parser HTML mặc định như phương án dự phòng.
532
+ soup = BeautifulSoup(rss_content, "html.parser")
533
+ items = soup.find_all("item")
534
+
535
+ image_urls: List[str] = []
536
+
537
+ for item in items:
538
+ description = item.find("description")
539
+ if not description or not description.text:
540
+ continue
541
+
542
+ # description chứa HTML dạng escape, parse tiếp lần 2
543
+ desc_soup = BeautifulSoup(description.text, "html.parser")
544
+ img_tags = desc_soup.find_all("img")
545
+
546
+ for img in img_tags:
547
+ src = img.get("src")
548
+ if not src:
549
+ continue
550
+ original_url = self._to_original_quality(src)
551
+ if original_url not in image_urls:
552
+ image_urls.append(original_url)
553
+
554
+ # Phòng trường hợp ảnh nằm trong text thay vì thẻ <img>
555
+ for match in IMG_URL_PATTERN.findall(description.text):
556
+ original_url = self._to_original_quality(match)
557
+ if original_url not in image_urls:
558
+ image_urls.append(original_url)
559
+
560
+ return image_urls
561
+
562
+ @staticmethod
563
+ def _to_original_quality(image_url: str) -> str:
564
+ """Thay phần kích thước thumbnail (vd: 236x) bằng 'originals'."""
565
+ return THUMBNAIL_SIZE_PATTERN.sub("/originals/", image_url, count=1)
566
+
567
+ # ------------------------------------------------------------------ #
568
+ # Xử lý PIN ĐƠN LẺ (không có RSS, phải fetch trực tiếp trang HTML)
569
+ # ------------------------------------------------------------------ #
570
+ def fetch_pin_page(self, pin_url: str) -> str:
571
+ """Tải nội dung HTML của trang pin đơn lẻ."""
572
+ try:
573
+ resp = self.session.get(pin_url, timeout=self.timeout)
574
+ resp.raise_for_status()
575
+ except requests.RequestException as exc:
576
+ raise PinGrabberError(f"Không thể tải trang pin: {pin_url} ({exc})") from exc
577
+ return resp.text
578
+
579
+ def extract_single_pin_image(self, html_content: str) -> Optional[str]:
580
+ """
581
+ Dùng BeautifulSoup để tìm ảnh chính của một pin đơn lẻ.
582
+
583
+ Pinterest nhúng ảnh chính của pin vào thẻ:
584
+ <meta property="og:image" content="https://i.pinimg.com/originals/...">
585
+ Đây thường đã là ảnh chất lượng gốc, không cần nâng cấp thêm.
586
+ Nếu không tìm thấy, thử phương án dự phòng là quét toàn bộ HTML
587
+ bằng regex để tìm URL ảnh i.pinimg.com.
588
+ """
589
+ soup = BeautifulSoup(html_content, "html.parser")
590
+
591
+ og_image = soup.find("meta", property="og:image")
592
+ if og_image and og_image.get("content"):
593
+ return self._to_original_quality(og_image["content"])
594
+
595
+ # Phương án dự phòng: quét toàn bộ HTML tìm URL ảnh pinimg.com
596
+ matches = IMG_URL_PATTERN.findall(html_content)
597
+ if matches:
598
+ return self._to_original_quality(matches[0])
599
+
600
+ return None
601
+
602
+ def download_single_pin(self, pin_url: str, output_dir: str = "downloads") -> List[str]:
603
+ """
604
+ Tải ảnh từ một URL pin đơn lẻ, ví dụ:
605
+ https://www.pinterest.com/pin/119134352618387326/
606
+
607
+ Trả về danh sách đường dẫn file đã lưu (rỗng nếu không tìm thấy ảnh).
608
+ """
609
+ os.makedirs(output_dir, exist_ok=True)
610
+
611
+ logger.info("Đang tải trang pin: %s", pin_url)
612
+ html_content = self.fetch_pin_page(pin_url)
613
+
614
+ image_url = self.extract_single_pin_image(html_content)
615
+ if not image_url:
616
+ logger.warning("Không tìm thấy ảnh nào trong pin: %s", pin_url)
617
+ return []
618
+
619
+ try:
620
+ path = self.download_image(image_url, output_dir)
621
+ logger.info("Đã tải: %s", path)
622
+ return [path]
623
+ except PinGrabberError as exc:
624
+ logger.error("Lỗi tải ảnh: %s", exc)
625
+ return []
626
+
627
+ # ------------------------------------------------------------------ #
628
+ # Bước 4: Tải ảnh về máy
629
+ # ------------------------------------------------------------------ #
630
+ def download_image(self, image_url: str, output_dir: str) -> str:
631
+ """Tải một ảnh về thư mục output_dir, trả về đường dẫn file đã lưu."""
632
+ filename = os.path.basename(urlparse(image_url).path) or "image.jpg"
633
+ filepath = os.path.join(output_dir, filename)
634
+
635
+ try:
636
+ resp = self.session.get(image_url, timeout=self.timeout, stream=True)
637
+ resp.raise_for_status()
638
+ except requests.RequestException as exc:
639
+ raise PinGrabberError(f"Không thể tải ảnh: {image_url} ({exc})") from exc
640
+
641
+ with open(filepath, "wb") as f:
642
+ for chunk in resp.iter_content(chunk_size=8192):
643
+ if chunk:
644
+ f.write(chunk)
645
+
646
+ return filepath
647
+
648
+ # ------------------------------------------------------------------ #
649
+ # Hàm tổng hợp: từ URL board -> tải toàn bộ ảnh gốc về máy
650
+ # ------------------------------------------------------------------ #
651
+ def download(self, url: str, output_dir: str = "downloads") -> List[str]:
652
+ """
653
+ Quy trình đầy đủ, tự động nhận diện loại URL:
654
+
655
+ - Nếu là URL pin đơn lẻ (pinterest.com/pin/<id>/):
656
+ fetch trực tiếp trang HTML -> lấy ảnh từ og:image -> tải về.
657
+ - Nếu là URL board (pinterest.com/username/boardname/):
658
+ 1. Tạo URL RSS từ URL board.
659
+ 2. Tải nội dung RSS.
660
+ 3. Trích xuất danh sách URL ảnh gốc chất lượng cao.
661
+ 4. Tải toàn bộ ảnh về output_dir.
662
+
663
+ Trả về danh sách đường dẫn các file ảnh đã lưu thành công.
664
+ """
665
+ if self.is_single_pin_url(url):
666
+ logger.info("Phát hiện URL pin đơn lẻ, chuyển sang chế độ tải pin lẻ.")
667
+ return self.download_single_pin(url, output_dir=output_dir)
668
+
669
+ os.makedirs(output_dir, exist_ok=True)
670
+
671
+ rss_url = self.build_rss_url(url)
672
+ logger.info("Đang lấy RSS feed: %s", rss_url)
673
+
674
+ rss_content = self.fetch_rss(rss_url)
675
+ image_urls = self.extract_image_urls(rss_content)
676
+
677
+ if not image_urls:
678
+ logger.warning("Không tìm thấy ảnh nào trong board: %s", url)
679
+ return []
680
+
681
+ logger.info("Tìm thấy %d ảnh. Bắt đầu tải...", len(image_urls))
682
+
683
+ saved_paths: List[str] = []
684
+ for index, img_url in enumerate(image_urls, start=1):
685
+ try:
686
+ path = self.download_image(img_url, output_dir)
687
+ saved_paths.append(path)
688
+ logger.info("(%d/%d) Đã tải: %s", index, len(image_urls), path)
689
+ except PinGrabberError as exc:
690
+ logger.error("(%d/%d) Lỗi tải ảnh: %s", index, len(image_urls), exc)
691
+
692
+ logger.info("Hoàn tất. Đã tải %d/%d ảnh.", len(saved_paths), len(image_urls))
693
+ return saved_paths
694
+
695
+
696
+ # ---------------------------------------------------------------------- #
697
+ # Hàm shortcut cấp module để dùng nhanh: pingrabber.download(url)
698
+ # ---------------------------------------------------------------------- #
699
+ def download(url: str, output_dir: str = "downloads", timeout: int = 15) -> List[str]:
700
+ """
701
+ Hàm tiện ích nhanh, không cần khởi tạo PinGrabber thủ công.
702
+ Tự động nhận diện URL là board hay pin đơn lẻ và xử lý phù hợp.
703
+
704
+ Ví dụ:
705
+ import pingrabber
706
+
707
+ # Board
708
+ pingrabber.download("https://www.pinterest.com/username/boardname/")
709
+
710
+ # Pin đơn lẻ
711
+ pingrabber.download("https://www.pinterest.com/pin/119134352618387326/")
712
+ """
713
+ grabber = PinGrabber(timeout=timeout)
714
+ return grabber.download(url, output_dir=output_dir)
715
+
716
+
717
+ def search(
718
+ keyword: str,
719
+ max_boards: int = 3,
720
+ max_images_per_board: int = 25,
721
+ max_retries: int = 2,
722
+ delay_seconds: float = 1.5,
723
+ timeout: int = 15,
724
+ ) -> List[str]:
725
+ """
726
+ Hàm tiện ích nhanh: tìm board liên quan đến từ khóa và trả về danh sách
727
+ link ảnh raw (chất lượng gốc), không tải file về máy.
728
+
729
+ Tự động xoay User-Agent, thử lại (retry) và chuyển đổi giữa nhiều
730
+ công cụ tìm kiếm (DuckDuckGo, Bing) nếu bị chặn.
731
+
732
+ Ví dụ:
733
+ import pingrabber
734
+
735
+ links = pingrabber.search("thiên nhiên")
736
+ for url in links:
737
+ print(url)
738
+
739
+ # Tăng số lần thử lại và thời gian nghỉ nếu mạng dễ bị chặn
740
+ links = pingrabber.search("nature", max_retries=4, delay_seconds=3)
741
+ """
742
+ grabber = PinGrabber(timeout=timeout)
743
+ return grabber.search(
744
+ keyword,
745
+ max_boards=max_boards,
746
+ max_images_per_board=max_images_per_board,
747
+ max_retries=max_retries,
748
+ delay_seconds=delay_seconds,
749
+ )
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: pingrabber
3
+ Version: 0.1.7
4
+ Summary: A lightweight Python library that scrapes high-quality images from any public Pinterest board using the official RSS feed
5
+ Home-page: https://github.com/VVui-blip/pin_grabber
6
+ Author: VVui-blip
7
+ Author-email: VVui-blip <vuv54581@gmail.com>
8
+ Maintainer-email: VVui-blip <vuv54581@gmail.com>
9
+ License: MIT
10
+ Project-URL: Homepage, https://github.com/VVui-blip/image-data-scraping-resource-pack-from-Pinterest-
11
+ Project-URL: Repository, https://github.com/VVui-blip/image-data-scraping-resource-pack-from-Pinterest-.git
12
+ Project-URL: Issues, https://github.com/VVui-blip/image-data-scraping-resource-pack-from-Pinterest-/issues
13
+ Project-URL: Documentation, https://github.com/VVui-blip/image-data-scraping-resource-pack-from-Pinterest-/blob/main/README.md
14
+ Keywords: pinterest,scraper,image-downloader,rss,web-scraping
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.7
20
+ Classifier: Programming Language :: Python :: 3.8
21
+ Classifier: Programming Language :: Python :: 3.9
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
25
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Screen Capture
26
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
27
+ Requires-Python: >=3.7
28
+ Description-Content-Type: text/markdown
29
+ Requires-Dist: requests>=2.25.0
30
+ Requires-Dist: beautifulsoup4>=4.9.0
31
+ Requires-Dist: lxml>=4.6.0
32
+ Provides-Extra: search
33
+ Requires-Dist: ddgs>=1.0.0; extra == "search"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
36
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
37
+ Requires-Dist: black>=22.0.0; extra == "dev"
38
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
39
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
40
+ Dynamic: author
41
+ Dynamic: home-page
42
+ Dynamic: requires-python
43
+
44
+ # PinGrabber
45
+
46
+ [![PyPI version](https://img.shields.io/badge/pypi-v0.1.0-blue)](https://pypi.org/project/pingrabber/)
47
+ [![Python 3.7+](https://img.shields.io/badge/python-3.7+-green.svg)](https://www.python.org/downloads/)
48
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
49
+
50
+ **PinGrabber** is a lightweight Python library that scrapes high‑quality images from any public Pinterest board using the official RSS feed provided by Pinterest. It extracts the original, full‑resolution images and downloads them to your local machine with minimal effort.
51
+
52
+ ![Pinterest Banner](https://www.logo.wine/a/logo/Pinterest/Pinterest-Icon-White-Dark-Background-Logo.wine.svg)
53
+
54
+ > **Important** – Use this tool only with public boards and for personal or educational purposes. Always respect Pinterest’s [Terms of Service](https://www.pinterest.com/terms/) and the copyright of image authors.
55
+
56
+ ---
57
+
58
+ ## Table of Contents
59
+
60
+ - [Features](#features)
61
+ - [Installation](#installation)
62
+ - [Quick Start](#quick-start)
63
+ - [Advanced Usage](#advanced-usage)
64
+ - [How It Works](#how-it-works)
65
+ - [Project Structure](#project-structure)
66
+ - [Dependencies](#dependencies)
67
+ - [License](#license)
68
+
69
+ ---
70
+
71
+ ## Features
72
+
73
+ - Simple shortcut function – download an entire board or a single pin with one line of code.
74
+ - Automatic conversion of thumbnail URLs to original high‑resolution images.
75
+ - Customizable output directory.
76
+ - Both high‑level wrapper and low‑level class for fine‑grained control.
77
+ - Keyword search that returns raw image URLs without downloading.
78
+ - Built with `requests`, `BeautifulSoup`, and `lxml` for fast and reliable parsing.
79
+
80
+ ---
81
+
82
+ ## Installation
83
+
84
+ You can install PinGrabber directly from the GitHub repository:
85
+
86
+ ```bash
87
+ pip install git+https://github.com/VVui-blip/image-data-scraping-resource-pack-from-Pinterest-.git
88
+ ```
89
+
90
+ Alternatively, if you have the source code locally:
91
+
92
+ ```bash
93
+ pip install -r requirements.txt
94
+ pip install .
95
+ ```
96
+
97
+ The required dependencies (requests, beautifulsoup4, lxml) will be installed automatically.
98
+
99
+ Optional dependency for better keyword search
100
+
101
+ For a more robust and stable keyword search, we recommend installing the ddgs package (community‑maintained, formerly duckduckgo-search):
102
+
103
+ ```bash
104
+ pip install ddgs
105
+ ```
106
+
107
+ Or install it together with PinGrabber:
108
+
109
+ ```bash
110
+ pip install .[search]
111
+ ```
112
+
113
+ ddgs provides a more reliable way to query search engines (DuckDuckGo, Bing, Google, etc.) and supports proxy configuration right in the code. Without ddgs, the search() function will fall back to direct requests calls to search engines, which are more prone to blocking.
114
+
115
+ ---
116
+
117
+ ## Quick Start
118
+
119
+ pingrabber.download(url) automatically detects whether the given URL is a board or a single pin and handles it accordingly – you don't need to differentiate manually.
120
+
121
+ Download all images from a board
122
+
123
+ ```python
124
+ import pingrabber
125
+
126
+ pingrabber.download("https://www.pinterest.com/username/boardname/")
127
+ ```
128
+
129
+ Download a single pin
130
+
131
+ ```python
132
+ import pingrabber
133
+
134
+ pingrabber.download("https://www.pinterest.com/pin/119134352618387326/")
135
+ ```
136
+
137
+ To save images to a custom directory:
138
+
139
+ ```python
140
+ import pingrabber
141
+
142
+ pingrabber.download(
143
+ "https://www.pinterest.com/username/boardname/",
144
+ output_dir="my_pinterest_images"
145
+ )
146
+ ```
147
+
148
+ Search images by keyword (returns raw links, no download)
149
+
150
+ ```python
151
+ import pingrabber
152
+
153
+ links = pingrabber.search("nature")
154
+ for url in links:
155
+ print(url)
156
+ ```
157
+
158
+ This function does not download any images – it only returns a list of high‑quality raw image URLs. You can preview them, filter, or download them manually with requests if needed.
159
+
160
+ How it works: search() tries multiple search engines (DuckDuckGo HTML, DuckDuckGo Lite, Bing) using the site:pinterest.com <keyword> query. It rotates user‑agents, retries, and adds delays between attempts to reduce the chance of being blocked. If one engine fails (403/429), it automatically switches to the next. It does not scrape Pinterest’s search page directly, because that page requires JavaScript rendering which requests cannot handle.
161
+
162
+ Customise the number of boards to scan, images per board, retries, and delay:
163
+
164
+ ```python
165
+ links = pingrabber.search(
166
+ "nature",
167
+ max_boards=5,
168
+ max_images_per_board=10,
169
+ max_retries=3,
170
+ delay_seconds=2.5
171
+ )
172
+ ```
173
+
174
+ If search() always returns empty: check the logged WARNING/ERROR messages. If you see 403/429 errors for all fallback engines, your network/IP is likely being rate‑limited by the search engines (common on cloud servers, VPNs, or IPs that have sent many requests). In that case:
175
+
176
+ · Install ddgs if you haven’t (pip install ddgs) – this is the most significant improvement.
177
+ · If ddgs is installed but still returns nothing, try using a proxy directly with the package:
178
+ ```python
179
+ from ddgs import DDGS
180
+ with DDGS(proxy="socks5://127.0.0.1:9050", timeout=15) as ddgs:
181
+ results = ddgs.text("site:pinterest.com nature", max_results=5)
182
+ print(results)
183
+ ```
184
+ If that returns results, you can initialise PinGrabber with a similarly proxied session, or simply use the found board URLs with download().
185
+ · Increase max_retries and delay_seconds (this only affects the fallback method).
186
+ · Try a different network/VPN.
187
+ · Alternatively, use the most reliable approach: find a board manually through your browser and call pingrabber.download(board_url) directly – this does not depend on search engines and is always stable.
188
+
189
+ ---
190
+
191
+ ## Advanced Usage
192
+
193
+ For more control, use the PinGrabber class:
194
+
195
+ ```python
196
+ from pingrabber import PinGrabber
197
+
198
+ grabber = PinGrabber(timeout=30)
199
+
200
+ # Download all original images
201
+ saved_files = grabber.download(
202
+ "https://www.pinterest.com/username/boardname/",
203
+ output_dir="high_res_pins"
204
+ )
205
+
206
+ print(f"Downloaded {len(saved_files)} images")
207
+ ```
208
+
209
+ If you only need the image URLs (without downloading):
210
+
211
+ ```python
212
+ from pingrabber import PinGrabber
213
+
214
+ grabber = PinGrabber()
215
+ rss_url = grabber.build_rss_url("https://www.pinterest.com/username/boardname/")
216
+ rss_content = grabber.fetch_rss(rss_url)
217
+ image_urls = grabber.extract_image_urls(rss_content)
218
+
219
+ for url in image_urls:
220
+ print(url)
221
+ ```
222
+
223
+ ---
224
+
225
+ ## How It Works
226
+
227
+ 1. Board/Pin URL to RSS Feed – The provided URL is converted to an RSS feed URL (appending .rss for boards, or using the pin’s RSS endpoint).
228
+ 2. Fetch RSS – The RSS content is retrieved via a requests GET request.
229
+ 3. Parse and Extract – BeautifulSoup with the lxml parser extracts all <img> tags inside the RSS items.
230
+ 4. Upgrade to Original – Thumbnail URLs (e.g., 236x) are transformed into originals URLs to fetch the highest available quality.
231
+ 5. Download – Each image is downloaded and saved to the specified output directory with a unique filename.
232
+
233
+ ---
234
+
235
+ ## Project Structure
236
+
237
+ ```
238
+ pin_grabber/
239
+ ├── pingrabber/
240
+ │ ├── __init__.py # Package entry point
241
+ │ └── core.py # Main logic (PinGrabber class + helper functions)
242
+ ├── requirements.txt # Python dependencies
243
+ ├── README.md # This file
244
+ ├── setup.py # Packaging configuration
245
+ └── LICENSE # MIT License
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Dependencies
251
+
252
+ · Python 3.7+
253
+ · requests – HTTP requests.
254
+ · beautifulsoup4 – HTML/XML parsing.
255
+ · lxml – Fast XML/HTML parser.
256
+
257
+ All dependencies are listed in requirements.txt and will be installed when using pip install . or the Git install command.
258
+
259
+ ---
260
+
261
+ ## License
262
+
263
+ This project is released under the MIT License. See the LICENSE file for details.
264
+
265
+ Disclaimer: This tool is provided “as is”. You are solely responsible for ensuring that your usage complies with Pinterest’s Terms of Service and applicable copyright laws.
266
+
267
+ ---
268
+
269
+ Built with attention for developers who need a quick, clean Pinterest image scraper.
@@ -0,0 +1,7 @@
1
+ pingrabber/__init__.py,sha256=3cNX-r8mewlRs5OPu5Kd8IG7yJFOvbqvodGX9Gun6yw,624
2
+ pingrabber/core.py,sha256=HqbwyfUpQIjXu3f0xFFzldC_VnHYvfkdTLoHvMRJRE4,31453
3
+ pingrabber-0.1.7.dist-info/METADATA,sha256=VN4rHDng8fPo5FGYrNpYPhoAJ86GnFV8l0jqNPxxGvA,9851
4
+ pingrabber-0.1.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ pingrabber-0.1.7.dist-info/entry_points.txt,sha256=Q3icyZV7pv0xwDk69vA8nIN-cJN2083fYF714gVj8Eo,51
6
+ pingrabber-0.1.7.dist-info/top_level.txt,sha256=inAN9tNeeGLwL32Yn9wucNDQqIZWuExlW6wb02X2e48,11
7
+ pingrabber-0.1.7.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pingrabber = pingrabber.cli:main
@@ -0,0 +1 @@
1
+ pingrabber