mcmaster-scraper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thedjchi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcmaster-scraper
3
+ Version: 0.1.0
4
+ Summary: Fetch product tables from a McMaster-Carr URL as a DataFrame for complex filtering and calculations
5
+ Keywords: mcmaster,mcmaster-carr,scraper,extractor,parser,fetcher,part,catalog,product
6
+ Author: Alex
7
+ Author-email: Alex <thedjchidev@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Manufacturing
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Scientific/Engineering
22
+ Requires-Dist: diskcache>=5.6.3
23
+ Requires-Dist: pandas>=2.3.3
24
+ Requires-Dist: platformdirs>=3.5.1
25
+ Requires-Dist: playwright>=1.14.0
26
+ Requires-Python: >=3.10
27
+ Project-URL: github, https://github.com/thedjchi/mcmaster-scraper
28
+ Description-Content-Type: text/markdown
29
+
30
+ # McMaster-Scraper
31
+
32
+ A Python library for fetching product tables from a [McMaster-Carr](https://www.mcmaster.com) URL
33
+ as a [DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) for complex filtering and
34
+ calculations.
35
+
36
+ ![PyPI - Version](https://img.shields.io/pypi/v/mcmaster-scraper?style=for-the-badge)
37
+ ![PyPI - Python Versions](https://img.shields.io/pypi/pyversions/mcmaster-scraper?style=for-the-badge)
38
+ ![PyPI - License](https://img.shields.io/pypi/l/mcmaster-scraper?style=for-the-badge)
39
+
40
+ ## Features
41
+
42
+ - Caches data locally to speed up future calls
43
+ - Supports both sync/async APIs
44
+ - Works in Python files and Jupyter notebooks
45
+ - Includes convenience functions to quickly retrieve product tables from multiple URLs
46
+ - Typed functions for type-checking compatibility
47
+
48
+ ## Install
49
+
50
+ McMaster-Scraper is available on PyPi:
51
+
52
+ `pip install mcmaster-scraper`
53
+
54
+ McMaster-Scraper requires [Playwright](https://playwright.dev/python) to fetch the product tables. It is already
55
+ included as a dependency. However, you will need to install the browsers manually:
56
+
57
+ `playwright install`
58
+
59
+ ## Quick Start
60
+
61
+ To use the Sync API, import the `sync_api` module and call `get_products_from_url(s)`:
62
+
63
+ ```
64
+ from mcmaster_scraper.sync_api import get_products_from_url
65
+
66
+ url = "https://www.mcmaster.com/products/screws/socket-head-screws-2~/steel-socket-head-screws~~/"
67
+ data = get_products_from_url(url) # Returns a DataFrame with all the products from the URL
68
+
69
+ ... # Do stuff with the DataFrame (filter, perform calculations, etc.)
70
+ ```
71
+
72
+ Using the Async API is similar, import the `async_api` module and `await` the function call:
73
+
74
+ ```
75
+ from mcmaster_scraper.async_api import get_products_from_url
76
+
77
+ url = "https://www.mcmaster.com/products/screws/socket-head-screws-2~/steel-socket-head-screws~~/"
78
+ data = await get_products_from_url(url) # Returns a DataFrame with all the products from the URL
79
+
80
+ ... # Do stuff with the DataFrame (filter, perform calculations, etc.)
81
+ ```
82
+
83
+ ## Docs
84
+
85
+ ### API Reference
86
+
87
+ The API reference can be found on [GitHub Pages](https://thedjchi.github.io/mcmaster-scraper/mcmaster_scraper.html).
88
+
89
+ ### Examples
90
+
91
+ An example script can be found
92
+ in [docs/example.py](https://github.com/thedjchi/mcmaster-scraper/blob/master/docs/example.py).
93
+
94
+ ## Disclaimer
95
+
96
+ This library is for responsible data extraction only. Do not:
97
+
98
+ - Scrape beyond reasonable rates
99
+ - Violate Terms of Service
100
+ - Circumvent access controls
101
+ - Use data for unauthorized commercial purposes
102
+
103
+ ## Legal Notice
104
+
105
+ This library is provided as-is. Authors are not liable for any legal, technical, or business consequences resulting from
106
+ misuse of this library. Users assume full responsibility for compliance with applicable laws, regulations, and website
107
+ policies.
108
+
109
+ **By using this library, you acknowledge and agree to these responsibilities.**
110
+
111
+ ## License
112
+
113
+ [MIT](https://github.com/thedjchi/mcmaster-scraper/blob/master/LICENSE)
@@ -0,0 +1,84 @@
1
+ # McMaster-Scraper
2
+
3
+ A Python library for fetching product tables from a [McMaster-Carr](https://www.mcmaster.com) URL
4
+ as a [DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) for complex filtering and
5
+ calculations.
6
+
7
+ ![PyPI - Version](https://img.shields.io/pypi/v/mcmaster-scraper?style=for-the-badge)
8
+ ![PyPI - Python Versions](https://img.shields.io/pypi/pyversions/mcmaster-scraper?style=for-the-badge)
9
+ ![PyPI - License](https://img.shields.io/pypi/l/mcmaster-scraper?style=for-the-badge)
10
+
11
+ ## Features
12
+
13
+ - Caches data locally to speed up future calls
14
+ - Supports both sync/async APIs
15
+ - Works in Python files and Jupyter notebooks
16
+ - Includes convenience functions to quickly retrieve product tables from multiple URLs
17
+ - Typed functions for type-checking compatibility
18
+
19
+ ## Install
20
+
21
+ McMaster-Scraper is available on PyPi:
22
+
23
+ `pip install mcmaster-scraper`
24
+
25
+ McMaster-Scraper requires [Playwright](https://playwright.dev/python) to fetch the product tables. It is already
26
+ included as a dependency. However, you will need to install the browsers manually:
27
+
28
+ `playwright install`
29
+
30
+ ## Quick Start
31
+
32
+ To use the Sync API, import the `sync_api` module and call `get_products_from_url(s)`:
33
+
34
+ ```
35
+ from mcmaster_scraper.sync_api import get_products_from_url
36
+
37
+ url = "https://www.mcmaster.com/products/screws/socket-head-screws-2~/steel-socket-head-screws~~/"
38
+ data = get_products_from_url(url) # Returns a DataFrame with all the products from the URL
39
+
40
+ ... # Do stuff with the DataFrame (filter, perform calculations, etc.)
41
+ ```
42
+
43
+ Using the Async API is similar, import the `async_api` module and `await` the function call:
44
+
45
+ ```
46
+ from mcmaster_scraper.async_api import get_products_from_url
47
+
48
+ url = "https://www.mcmaster.com/products/screws/socket-head-screws-2~/steel-socket-head-screws~~/"
49
+ data = await get_products_from_url(url) # Returns a DataFrame with all the products from the URL
50
+
51
+ ... # Do stuff with the DataFrame (filter, perform calculations, etc.)
52
+ ```
53
+
54
+ ## Docs
55
+
56
+ ### API Reference
57
+
58
+ The API reference can be found on [GitHub Pages](https://thedjchi.github.io/mcmaster-scraper/mcmaster_scraper.html).
59
+
60
+ ### Examples
61
+
62
+ An example script can be found
63
+ in [docs/example.py](https://github.com/thedjchi/mcmaster-scraper/blob/master/docs/example.py).
64
+
65
+ ## Disclaimer
66
+
67
+ This library is for responsible data extraction only. Do not:
68
+
69
+ - Scrape beyond reasonable rates
70
+ - Violate Terms of Service
71
+ - Circumvent access controls
72
+ - Use data for unauthorized commercial purposes
73
+
74
+ ## Legal Notice
75
+
76
+ This library is provided as-is. Authors are not liable for any legal, technical, or business consequences resulting from
77
+ misuse of this library. Users assume full responsibility for compliance with applicable laws, regulations, and website
78
+ policies.
79
+
80
+ **By using this library, you acknowledge and agree to these responsibilities.**
81
+
82
+ ## License
83
+
84
+ [MIT](https://github.com/thedjchi/mcmaster-scraper/blob/master/LICENSE)
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "mcmaster-scraper"
3
+ version = "0.1.0"
4
+ description = "Fetch product tables from a McMaster-Carr URL as a DataFrame for complex filtering and calculations"
5
+ keywords = ["mcmaster", "mcmaster-carr", "scraper", "extractor", "parser", "fetcher", "part", "catalog", "product"]
6
+ classifiers = [
7
+ "Development Status :: 3 - Alpha",
8
+ "Intended Audience :: Manufacturing",
9
+ "Intended Audience :: Science/Research",
10
+ "Operating System :: OS Independent",
11
+ "Programming Language :: Python",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Topic :: Scientific/Engineering",
19
+ ]
20
+ readme = "README.md"
21
+ license = "MIT"
22
+ license-files = ["LICENSE*"]
23
+ authors = [
24
+ { name = "Alex", email = "thedjchidev@gmail.com" }
25
+ ]
26
+ requires-python = ">=3.10"
27
+ dependencies = [
28
+ "diskcache>=5.6.3",
29
+ "pandas>=2.3.3",
30
+ "platformdirs>=3.5.1",
31
+ "playwright>=1.14.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ github = "https://github.com/thedjchi/mcmaster-scraper"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.11.8,<0.12.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "black>=26.3.1",
44
+ "pdoc>=16.0.0",
45
+ "pre-commit>=4.6.0",
46
+ "ruff>=0.15.12",
47
+ ]
48
+
49
+ [tool.ruff]
50
+ lint.select = [
51
+ "E", # pycodestyle errors
52
+ "W", # pycodestyle warnings
53
+ "F", # pyflakes
54
+ "I", # isort
55
+ ]
@@ -0,0 +1,5 @@
1
+ """
2
+ .. include::../../README.md
3
+ :start-line: 2
4
+ :end-before: Docs
5
+ """
@@ -0,0 +1,60 @@
1
+ from fractions import Fraction
2
+
3
+ _STANDARD_HEADERS = {
4
+ "PART_NUMBER": "Part Number",
5
+ "PRICING": "Price",
6
+ }
7
+
8
+
9
+ def get_header_text(col_id: int, meta: dict):
10
+ # Column Id -> Column Metadata
11
+ column_metas = meta["ColumnIdToMetadata"]
12
+ column_meta = column_metas[col_id]
13
+
14
+ header_type = column_meta.get("Type")
15
+ if isinstance(header_type, str) and header_type in _STANDARD_HEADERS:
16
+ return _STANDARD_HEADERS[header_type]
17
+ else:
18
+ # Column Metadata -> Header
19
+ return _extract_text(column_meta)
20
+
21
+
22
+ def get_cell_text(cell_id: int, meta: dict):
23
+ # Cell ID -> Value Metadata ID
24
+ cell_metas = meta["CellIdToCellMetadata"]
25
+ cell_meta = cell_metas[cell_id]
26
+ value_meta_id = cell_meta["ValueMetadataIds"][0]
27
+
28
+ # Value Metadata ID -> Value Metadata
29
+ value_metas = meta["ValueMetadataIdToValueMetadata"]
30
+ value_meta = value_metas[value_meta_id]
31
+
32
+ # Value Metadata -> Value
33
+ return _extract_text(value_meta)
34
+
35
+
36
+ def _extract_text(meta_item: dict):
37
+ components = meta_item["Name"]["Components"]
38
+ text = " ".join(c["Text"] for c in components)
39
+
40
+ return _parse_number(text)
41
+
42
+
43
+ def _parse_number(text: str):
44
+ t = text.replace('"', "").strip()
45
+
46
+ if t == "":
47
+ return t
48
+
49
+ try:
50
+ return float(t)
51
+ except ValueError:
52
+ pass
53
+
54
+ try:
55
+ fraction = sum(Fraction(part) for part in t.split())
56
+ return float(fraction)
57
+ except ValueError:
58
+ pass
59
+
60
+ return text
@@ -0,0 +1,56 @@
1
+ import json
2
+ import logging
3
+ import re
4
+
5
+ from .._utils.page_provider import get_page
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ async def get_product_api_response(url: str) -> dict:
11
+ if not _is_valid_url(url):
12
+ raise ValueError("Not a McMaster-Carr URL")
13
+
14
+ logger.info("Finding API for product page...")
15
+ # Using Playwright because the API can only be discovered by loading the JavaScript
16
+ page = await get_page()
17
+
18
+ await page.goto(url)
19
+
20
+ # If the JSON is too large, the response will be evicted
21
+ # from the inspector cache before we can access it
22
+
23
+ # As a workaround, we can navigate to the API URL
24
+ # and extract the response from the page's body
25
+
26
+ product_api = "**/ProdPageWebPart.aspx?**"
27
+ async with page.expect_request(product_api, timeout=5000) as request:
28
+ value = await request.value
29
+ api_url = value.url
30
+
31
+ logger.info("Getting API response...")
32
+ await page.goto(api_url)
33
+
34
+ res = await page.locator("body").text_content()
35
+ assert res is not None
36
+
37
+ data = _extract_json_from_response(res)
38
+
39
+ await page.close()
40
+ return data
41
+
42
+
43
+ def _extract_json_from_response(res: str) -> dict:
44
+ start = res.find("{")
45
+ end = res.rfind("}")
46
+
47
+ if start == -1 or end == -1:
48
+ raise ValueError("No JSON found in API response")
49
+
50
+ json_str = res[start : end + 1]
51
+ return json.loads(json_str)
52
+
53
+
54
+ def _is_valid_url(url: str) -> bool:
55
+ pattern = re.compile(r"^https?://(www\.)?mcmaster\.com(/\S*)?$")
56
+ return bool(pattern.match(url))
@@ -0,0 +1,49 @@
1
+ from pandas import DataFrame
2
+
3
+ from ._text_parser import get_cell_text, get_header_text
4
+
5
+
6
+ def get_product_tables(json: dict) -> dict[str, DataFrame]:
7
+ tables = _find_pivot_tables(json)
8
+ dataframes = {k: _parse_pivot_table(v) for k, v in tables.items()}
9
+ return dataframes
10
+
11
+
12
+ def _find_pivot_tables(root: dict) -> dict:
13
+ stack = [root]
14
+ while stack:
15
+ node = stack.pop()
16
+
17
+ if isinstance(node, dict):
18
+ if node.get("Name") == "ProductPresentations":
19
+ return {
20
+ product["Display"]["Title"]: product["Table"]
21
+ for product in node["Data"]
22
+ }
23
+ else:
24
+ stack.extend(node.values())
25
+
26
+ elif isinstance(node, list):
27
+ stack.extend(node)
28
+
29
+ raise KeyError("The McMaster URL provided does not have a visible product table.")
30
+
31
+
32
+ def _parse_pivot_table(table: dict) -> DataFrame:
33
+ col_ids = table["ColumnIds"]
34
+ rows = table["Rows"]
35
+ meta = table["Metadata"]
36
+
37
+ # Build headers
38
+ headers = [get_header_text(col_id, meta) for col_id in col_ids]
39
+
40
+ # Build row data
41
+ def get_row_data(row: dict):
42
+ cell_ids = row["ColumnIdToCellIdMap"]
43
+ return {
44
+ header: get_cell_text(cell_ids[col_id], meta)
45
+ for col_id, header in zip(col_ids, headers)
46
+ }
47
+
48
+ data = [get_row_data(row) for row in rows]
49
+ return DataFrame(data)
@@ -0,0 +1,55 @@
1
+ import asyncio
2
+ import sys
3
+ import threading
4
+ from asyncio import AbstractEventLoop, CancelledError
5
+ from concurrent.futures import Future
6
+ from typing import Any, Coroutine, TypeVar, Union
7
+
8
+ T = TypeVar("T")
9
+
10
+ _loop: Union[AbstractEventLoop, None] = None
11
+ _started = threading.Event()
12
+ _lock = threading.Lock()
13
+
14
+
15
+ async def run_in_loop_async(func: Coroutine[Any, Any, T]) -> T:
16
+ c_future = _run_in_loop(func)
17
+ a_future = asyncio.wrap_future(c_future)
18
+ try:
19
+ return await a_future
20
+ except CancelledError:
21
+ c_future.cancel()
22
+ raise
23
+
24
+
25
+ def run_in_loop_sync(func: Coroutine[Any, Any, T]) -> T:
26
+ return _run_in_loop(func).result()
27
+
28
+
29
+ def _run_in_loop(func: Coroutine[Any, Any, T]) -> Future[T]:
30
+ loop = _ensure_loop()
31
+ return asyncio.run_coroutine_threadsafe(func, loop)
32
+
33
+
34
+ def _ensure_loop() -> AbstractEventLoop:
35
+ global _loop
36
+ with _lock:
37
+ if _loop is None:
38
+ t = threading.Thread(target=_run_loop, daemon=True)
39
+ t.start()
40
+ _started.wait()
41
+
42
+ assert _loop is not None
43
+ return _loop
44
+
45
+
46
+ def _run_loop():
47
+ global _loop
48
+ if sys.platform.startswith("win"):
49
+ loop = asyncio.ProactorEventLoop()
50
+ else:
51
+ loop = asyncio.SelectorEventLoop()
52
+ asyncio.set_event_loop(loop)
53
+ _loop = loop
54
+ _started.set()
55
+ loop.run_forever()
@@ -0,0 +1,39 @@
1
+ import asyncio
2
+ from typing import Union
3
+
4
+ from playwright.async_api import (
5
+ Browser,
6
+ BrowserContext,
7
+ Page,
8
+ Playwright,
9
+ async_playwright,
10
+ )
11
+
12
+ _browser: Union[Browser, None] = None
13
+ _browser_context: Union[BrowserContext, None] = None
14
+ _playwright: Union[Playwright, None] = None
15
+ _lock = asyncio.Lock()
16
+
17
+
18
+ async def _ensure_browser_context() -> BrowserContext:
19
+ global _browser, _browser_context, _playwright
20
+
21
+ async with _lock:
22
+ if _browser_context:
23
+ return _browser_context
24
+
25
+ _playwright = await async_playwright().start()
26
+ assert _playwright
27
+
28
+ _browser = await _playwright.chromium.launch()
29
+ assert _browser
30
+
31
+ _browser_context = await _browser.new_context()
32
+ assert _browser_context
33
+
34
+ return _browser_context
35
+
36
+
37
+ async def get_page() -> Page:
38
+ browser_context = await _ensure_browser_context()
39
+ return await browser_context.new_page()
@@ -0,0 +1,49 @@
1
+ import hashlib
2
+ from asyncio import create_task, gather
3
+
4
+ import diskcache as dc
5
+ import platformdirs
6
+ from pandas import DataFrame, concat
7
+
8
+ from ._api.scraper import get_product_api_response
9
+ from ._api.table_parser import get_product_tables
10
+ from ._utils.event_loop_wrapper import run_in_loop_async
11
+
12
+
13
+ async def get_products_from_url(url: str, refresh: bool = False) -> DataFrame:
14
+ """Gets product tables from a McMaster-Carr URL.
15
+
16
+ See Also
17
+ --------
18
+ sync_api.get_products_from_url
19
+ """
20
+ cache_dir = platformdirs.user_cache_dir(
21
+ appname="mcmaster-scraper", appauthor=False, ensure_exists=True
22
+ )
23
+ cache = dc.Cache(cache_dir, eviction_policy="least-recently-used")
24
+ key = hashlib.md5(url.encode()).hexdigest()
25
+
26
+ if key in cache and not refresh:
27
+ json = cache[key]
28
+ else:
29
+ json = await run_in_loop_async(get_product_api_response(url))
30
+ cache[key] = json
31
+
32
+ tables = get_product_tables(json)
33
+ tables_with_product_type = [
34
+ table.assign(**{"Product Type": product}) for product, table in tables.items()
35
+ ]
36
+ return concat(tables_with_product_type, ignore_index=True)
37
+
38
+
39
+ async def get_products_from_urls(
40
+ urls: list[str], refresh: bool = False
41
+ ) -> list[DataFrame]:
42
+ """Gets product tables from a list of McMaster-Carr URLs.
43
+
44
+ See Also
45
+ --------
46
+ sync_api.get_products_from_urls
47
+ """
48
+ tasks = [create_task(get_products_from_url(url, refresh)) for url in urls]
49
+ return await gather(*tasks)
File without changes
@@ -0,0 +1,42 @@
1
+ from pandas import DataFrame
2
+
3
+ from . import async_api
4
+ from ._utils.event_loop_wrapper import run_in_loop_sync
5
+
6
+
7
+ def get_products_from_url(url: str, refresh: bool = False) -> DataFrame:
8
+ """Gets product tables from a McMaster-Carr URL.
9
+
10
+ If there are multiple product tables, they will be merged,
11
+ and an additional "Product Type" column will be added.
12
+
13
+ Parameters
14
+ ----------
15
+ url : str
16
+ The URL to scrape.
17
+ Must be a valid McMaster-Carr URL.
18
+ The product tables must be visible on the webpage.
19
+ refresh : bool, optional
20
+ Whether to refresh the cached data. Default is False.
21
+
22
+ Returns
23
+ -------
24
+ DataFrame
25
+ A pandas DataFrame containing the combined product tables.
26
+
27
+ Raises
28
+ ------
29
+ ValueError
30
+ If the URL is not a valid McMaster-Carr URL.
31
+ """
32
+ return run_in_loop_sync(async_api.get_products_from_url(url, refresh))
33
+
34
+
35
+ def get_products_from_urls(urls: list[str], refresh: bool = False) -> list[DataFrame]:
36
+ """Gets product tables from a list of McMaster-Carr URLs.
37
+
38
+ See Also
39
+ --------
40
+ get_products_from_url
41
+ """
42
+ return run_in_loop_sync(async_api.get_products_from_urls(urls, refresh))