🚀 refactor getgithubcontribution
This commit is contained in:
@@ -2,6 +2,28 @@
|
||||
|
||||
Get the github user contribution grid
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
|
||||
// colorScheme = [
|
||||
// "#ebedf0",
|
||||
// "#9be9a8",
|
||||
// ...
|
||||
// ]
|
||||
// cells = [
|
||||
// {
|
||||
// x: 3,
|
||||
// y: 0,
|
||||
// count: 3,
|
||||
// color: '#ebedf0',
|
||||
// date:'2019-01-18'
|
||||
// },
|
||||
// ...
|
||||
// ]
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
Based on the html page. Which is very unstable. Might switch to using github api but afaik it's a bit complex.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import { formatParams } from "../formatParams";
|
||||
|
||||
[
|
||||
//
|
||||
[{}, ""],
|
||||
[{ year: 2017 }, "from=2017-01-01&to=2017-12-31"],
|
||||
[{ from: new Date("2017-12-03") }, "from=2017-12-03"],
|
||||
[{ from: "2017-12-03" }, "from=2017-12-03"],
|
||||
[{ to: "2017-12-03" }, "to=2017-12-03"],
|
||||
].forEach(([params, res]) =>
|
||||
it(`should format ${JSON.stringify(params)}`, () => {
|
||||
expect(formatParams(params)).toBe(res);
|
||||
})
|
||||
);
|
||||
|
||||
it("should fail if the date is in the future", () => {
|
||||
expect(() => formatParams({ to: new Date() })).toThrow(Error);
|
||||
});
|
||||
@@ -1,14 +1,54 @@
|
||||
import { getGithubUserContribution } from "..";
|
||||
|
||||
it("should get user contribution", async () => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
describe("getGithubUserContribution", () => {
|
||||
const promise = getGithubUserContribution("platane");
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
it("should resolve", async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
it("should get colorScheme", async () => {
|
||||
const { colorScheme } = await promise;
|
||||
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should get around 365 cells", async () => {
|
||||
const { cells } = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThan(340);
|
||||
expect(cells.length).toBeLessThanOrEqual(367);
|
||||
});
|
||||
|
||||
it("cells should have x / y coords representing to a 7 x (365/7) (minus unfilled last row)", async () => {
|
||||
const { cells, colorScheme } = await promise;
|
||||
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
]);
|
||||
|
||||
const undefinedDays = Array.from({ length: Math.floor(365 / 7) - 1 })
|
||||
.map((x) => Array.from({ length: 7 }).map((y) => ({ x, y })))
|
||||
.flat()
|
||||
.filter(({ x, y }) => cells.some((c) => c.x === x && c.y === y));
|
||||
|
||||
expect(undefinedDays).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should match snapshot for year=2019", async () => {
|
||||
expect(
|
||||
await getGithubUserContribution("platane", { year: 2019 })
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
50
packages/github-user-contribution/formatParams.ts
Normal file
50
packages/github-user-contribution/formatParams.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type Options =
|
||||
| { from?: string | Date; to?: string | Date }
|
||||
| { year: number };
|
||||
|
||||
export const formatParams = (options: Options = {}) => {
|
||||
const sp = new URLSearchParams();
|
||||
|
||||
const o: any = { ...options };
|
||||
|
||||
if ("year" in options) {
|
||||
const from = new Date();
|
||||
from.setFullYear(options.year);
|
||||
from.setMonth(0);
|
||||
from.setDate(1);
|
||||
|
||||
const to = new Date();
|
||||
to.setFullYear(options.year);
|
||||
to.setMonth(11);
|
||||
to.setDate(31);
|
||||
|
||||
o.from = from;
|
||||
o.to = to;
|
||||
}
|
||||
|
||||
for (const s of ["from", "to"])
|
||||
if (o[s]) {
|
||||
const value = formatDate(o[s]);
|
||||
|
||||
if (value >= formatDate(new Date()))
|
||||
throw new Error("cannot get contribution for date in the future");
|
||||
|
||||
sp.set(s, value);
|
||||
}
|
||||
|
||||
return sp.toString();
|
||||
};
|
||||
|
||||
const formatDate = (input: Date | string) => {
|
||||
const d = new Date(input);
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
const date = d.getDate();
|
||||
|
||||
return [
|
||||
year,
|
||||
month.toString().padStart(2, "0"),
|
||||
date.toString().padStart(2, "0"),
|
||||
].join("-");
|
||||
};
|
||||
@@ -1,19 +1,34 @@
|
||||
import fetch from "node-fetch";
|
||||
import * as parser from "fast-xml-parser";
|
||||
import cheerio from "cheerio";
|
||||
import { formatParams, Options } from "./formatParams";
|
||||
|
||||
const findNode = (o: any, condition: (x: any) => boolean): any => {
|
||||
if (o && typeof o === "object") {
|
||||
if (condition(o)) return o;
|
||||
/**
|
||||
* get the contribution grid from a github user page
|
||||
*
|
||||
* @param userName github user name
|
||||
* @param options set the time range: from / to or year
|
||||
*/
|
||||
export const getGithubUserContribution = async (
|
||||
userName: string,
|
||||
options: Options = {}
|
||||
) => {
|
||||
// either use github.com/users/xxxx/contributions for previous years
|
||||
// or github.com/xxxx ( which gives the latest update to today result )
|
||||
const url =
|
||||
"year" in options || "from" in options || "to" in options
|
||||
? `https://github.com/users/${userName}/contributions?` +
|
||||
formatParams(options)
|
||||
: `https://github.com/${userName}`;
|
||||
|
||||
for (const c of Object.values(o)) {
|
||||
const res = findNode(c, condition);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
|
||||
const resText = await res.text();
|
||||
|
||||
return parseUserPage(resText);
|
||||
};
|
||||
|
||||
const ensureArray = (x: any) => (Array.isArray(x) ? x : [x]);
|
||||
|
||||
const defaultColorScheme = [
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
@@ -23,11 +38,7 @@ const defaultColorScheme = [
|
||||
];
|
||||
|
||||
const parseUserPage = (content: string) => {
|
||||
const o = parser.parse(content, {
|
||||
attrNodeName: "attr",
|
||||
attributeNamePrefix: "",
|
||||
ignoreAttributes: false,
|
||||
});
|
||||
const $ = cheerio.load(content);
|
||||
|
||||
//
|
||||
// parse colorScheme
|
||||
@@ -35,57 +46,84 @@ const parseUserPage = (content: string) => {
|
||||
const colorSchemeMap: Record<string, number> = Object.fromEntries(
|
||||
defaultColorScheme.map((color, i) => [color, i])
|
||||
);
|
||||
const legend = findNode(
|
||||
o,
|
||||
(x) => x.attr && x.attr.class && x.attr.class.trim() === "legend"
|
||||
);
|
||||
legend.li.forEach((x: any, i: number) => {
|
||||
const bgColor = x.attr.style.match(/background\-color: +(.+)/)![1]!;
|
||||
if (bgColor) {
|
||||
const color = bgColor.replace(/\s/g, "");
|
||||
colorSchemeMap[color] = i;
|
||||
$("ul.legend > li")
|
||||
.toArray()
|
||||
.forEach((x, i) => {
|
||||
const bgColor = x.attribs.style.match(/background\-color: +(.+)/)![1]!;
|
||||
if (bgColor) {
|
||||
const color = bgColor.replace(/\s/g, "");
|
||||
colorSchemeMap[color] = i;
|
||||
|
||||
if (!color.startsWith("var(--")) colorScheme[i] = color;
|
||||
}
|
||||
});
|
||||
if (!color.startsWith("var(--")) colorScheme[i] = color;
|
||||
}
|
||||
});
|
||||
|
||||
//
|
||||
// parse cells
|
||||
const svg = findNode(
|
||||
o,
|
||||
(x) =>
|
||||
x.attr && x.attr.class && x.attr.class.trim() === "js-calendar-graph-svg"
|
||||
);
|
||||
const rawCells = $(".js-calendar-graph rect[data-count]")
|
||||
.toArray()
|
||||
.map((x) => {
|
||||
const color = x.attribs.fill.trim();
|
||||
const count = +x.attribs["data-count"];
|
||||
const date = x.attribs["data-date"];
|
||||
|
||||
const cells = svg.g.g
|
||||
.map((g: any, x: number) =>
|
||||
ensureArray(g.rect).map(({ attr }: any, y: number) => {
|
||||
const color = attr.fill.trim();
|
||||
const count = +attr["data-count"];
|
||||
const date = attr["data-date"];
|
||||
const colorIndex = colorSchemeMap[color];
|
||||
|
||||
const k = colorSchemeMap[color];
|
||||
if (colorIndex === -1) throw new Error("could not map the cell color");
|
||||
|
||||
if (k === -1) throw new Error("could not map the cell color");
|
||||
return {
|
||||
svgPosition: getSvgPosition(x),
|
||||
color: colorScheme[colorIndex],
|
||||
count,
|
||||
date,
|
||||
};
|
||||
});
|
||||
|
||||
return { x, y, color, count, date, k };
|
||||
})
|
||||
)
|
||||
.flat();
|
||||
const xMap: Record<number, true> = {};
|
||||
const yMap: Record<number, true> = {};
|
||||
rawCells.forEach(({ svgPosition: { x, y } }) => {
|
||||
xMap[x] = true;
|
||||
yMap[y] = true;
|
||||
});
|
||||
|
||||
const xRange = Object.keys(xMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
const yRange = Object.keys(yMap)
|
||||
.map((x) => +x)
|
||||
.sort((a, b) => +a - +b);
|
||||
|
||||
const cells = rawCells.map(({ svgPosition, ...c }) => ({
|
||||
...c,
|
||||
x: xRange.indexOf(svgPosition.x),
|
||||
y: yRange.indexOf(svgPosition.y),
|
||||
}));
|
||||
|
||||
return { cells, colorScheme };
|
||||
};
|
||||
|
||||
/**
|
||||
* get the contribution grid from a github user page
|
||||
*
|
||||
* @param userName
|
||||
*/
|
||||
export const getGithubUserContribution = async (userName: string) => {
|
||||
const res = await fetch(`https://github.com/${userName}`);
|
||||
const resText = await res.text();
|
||||
// returns the position of the svg elements, accounting for it's transform and it's parent transform
|
||||
// ( only accounts for translate transform )
|
||||
const getSvgPosition = (e: cheerio.Element): { x: number; y: number } => {
|
||||
if (!e || e.tagName === "svg") return { x: 0, y: 0 };
|
||||
|
||||
return parseUserPage(resText);
|
||||
const p = getSvgPosition(e.parent);
|
||||
|
||||
if (e.attribs.x) p.x += +e.attribs.x;
|
||||
if (e.attribs.y) p.y += +e.attribs.y;
|
||||
|
||||
if (e.attribs.transform) {
|
||||
const m = e.attribs.transform.match(
|
||||
/translate\( *([\.\d]+) *, *([\.\d]+) *\)/
|
||||
);
|
||||
|
||||
if (m) {
|
||||
p.x += +m[1];
|
||||
p.y += +m[2];
|
||||
}
|
||||
}
|
||||
|
||||
return p;
|
||||
};
|
||||
|
||||
type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
"name": "@snk/github-user-contribution",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"node-fetch": "2.6.1",
|
||||
"fast-xml-parser": "3.17.4"
|
||||
"cheerio": "~1.0.0-rc.3",
|
||||
"node-fetch": "2.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cheerio": "0.22.22",
|
||||
"@types/node-fetch": "2.5.7"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user